diff --git a/.claude/hooks/issue-type-guard.sh b/.claude/hooks/issue-type-guard.sh index eb92a21..d029c18 100755 --- a/.claude/hooks/issue-type-guard.sh +++ b/.claude/hooks/issue-type-guard.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# PostToolUse Bash hook: `gh issue create` 직후 새 이슈의 "type 라벨" 검증. +# PostToolUse Bash hook: `gh issue create` 직후 새 이슈의 "type 라벨" + "본문 양식" 검증. # # GitHub 네이티브 Issue Type 은 organization 전용이라 개인 레포(1hyok/SlowClock)에는 없음 → # 종류 분류를 Task / Bug / Feature "라벨" 로 운영한다. 이 hook 은 새 이슈에 type 라벨이 @@ -9,6 +9,7 @@ # - fix(...) → Bug # - feat(...) → Feature # - chore/refactor/test/ci/build/docs(...) → Task +# - 본문이 .github/ISSUE_TEMPLATE/custom.md 양식(## 📜 Overview …)을 안 따르면 → 재작성 안내 # # Bypass: 의도적으로 prefix 와 다른 라벨을 쓰려면 issue 본문에 # `` marker 를 박는다. @@ -39,27 +40,35 @@ type_label="$(echo "$data" | jq -r '[.labels[].name] | map(select(. == "Task" or # override marker (Task|Bug|Feature) override="$(echo "$body" | grep -oE '' | grep -oE '(Task|Bug|Feature)' | head -1)" -# type 라벨 누락 +# ── 보정 필요사항 누적 (type 라벨 + 본문 양식) ────────────── +problems="" + +# 1) type 라벨 누락 if [ "$type_label" = "null" ]; then - jq -nc --arg url "$url" --arg n "$issue_num" \ - '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: ("Issue \($url) 에 type 라벨(Task/Bug/Feature) 누락. title prefix 에 맞는 라벨을 즉시 부여: gh issue edit \($n) --add-label . (fix→Bug, feat→Feature, chore/refactor/test/ci/build/docs→Task)")}}' - exit 0 + problems="${problems}- type 라벨(Task/Bug/Feature) 누락 → gh issue edit ${issue_num} --add-label (fix→Bug, feat→Feature, chore/refactor/test/ci/build/docs→Task)"$'\n' +# 2) title prefix ↔ 라벨 mismatch (override 없을 때만) +elif [ -z "$override" ]; then + expected="" + case "$title" in + fix\(*|fix:*) expected="Bug" ;; + feat\(*|feat:*) expected="Feature" ;; + chore\(*|chore:*|refactor\(*|refactor:*|test\(*|test:*|ci\(*|ci:*|build\(*|build:*|docs\(*|docs:*) expected="Task" ;; + esac + if [ -n "$expected" ] && [ "$type_label" != "$expected" ]; then + problems="${problems}- type 라벨=${type_label} ↔ title prefix mismatch (expected=${expected}) → gh issue edit ${issue_num} --remove-label ${type_label} --add-label ${expected} (의도면 본문에 )"$'\n' + fi fi -# override 있으면 통과 -[ -n "$override" ] && exit 0 - -# title prefix → expected Type 추론 -expected="" -case "$title" in - fix\(*|fix:*) expected="Bug" ;; - feat\(*|feat:*) expected="Feature" ;; - chore\(*|chore:*|refactor\(*|refactor:*|test\(*|test:*|ci\(*|ci:*|build\(*|build:*|docs\(*|docs:*) expected="Task" ;; -esac +# 3) 본문 양식 검사 (.github/ISSUE_TEMPLATE/custom.md 준수 — '📜 Overview' 섹션 필수) +if ! printf '%s' "$body" | grep -q "📜 Overview"; then + problems="${problems}- 본문이 이슈 템플릿 양식 위반 (## 📜 Overview / ## 📌 Child Issue / ## 📍 Note 필요) → gh issue edit ${issue_num} --body 로 .github/ISSUE_TEMPLATE/custom.md 양식에 맞춰 재작성"$'\n' +fi -if [ -n "$expected" ] && [ "$type_label" != "$expected" ]; then - jq -nc --arg url "$url" --arg actual "$type_label" --arg expected "$expected" --arg title "$title" --arg n "$issue_num" \ - '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: ("Issue \($url) 의 type 라벨=\($actual) 가 title prefix 와 mismatch. Title: \"\($title)\" → expected=\($expected). 보정: gh issue edit \($n) --remove-label \($actual) --add-label \($expected). 의도적이면 issue 본문에 `` 추가.")}}' +# ── 문제 있으면 한 번에 안내 ────────────────────────────── +if [ -n "$problems" ]; then + msg="Issue ${url} 보정 필요:"$'\n'"${problems}" + jq -nc --arg msg "$msg" \ + '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}' fi exit 0 diff --git a/.editorconfig b/.editorconfig index bdfc4a5..9300a2d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,6 @@ root = true [*.{kt,kts}] +ktlint_code_style = ktlint_official ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_package-name = disabled diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 02a37df..cd410b4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -61,6 +61,12 @@ jobs: run: | echo "$GOOGLE_SERVICES_JSON_B64" | base64 -d > app/google-services.json + # core/data 의 R.raw.service_account 컴파일 위해 stub raw 생성 (CI 런타임 미실행). + - name: Create stub service_account raw (CI only) + run: | + mkdir -p core/data/src/main/res/raw + printf '%s' '{"type":"service_account","project_id":"ci-stub"}' > core/data/src/main/res/raw/service_account + - name: Run Android Lint run: ./gradlew lintDebug --continue --parallel diff --git a/.github/workflows/screenshot.yml b/.github/workflows/screenshot.yml index aa0ac97..ea8e8f1 100644 --- a/.github/workflows/screenshot.yml +++ b/.github/workflows/screenshot.yml @@ -58,6 +58,13 @@ jobs: } EOF + # VertexAIRepository 가 core/data 의 R.raw.service_account 를 참조 — 멀티모듈에서 raw 리소스가 + # 없으면 R.raw 미생성으로 컴파일 실패. CI 는 실 키 불필요(런타임 미실행)라 stub raw 생성. + - name: Create stub service_account raw (CI only) + run: | + mkdir -p core/data/src/main/res/raw + printf '%s' '{"type":"service_account","project_id":"ci-stub"}' > core/data/src/main/res/raw/service_account + - name: Run validateScreenshotTest run: ./gradlew :app:validateScreenshotTest --parallel diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 19259b0..e567734 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -46,6 +46,12 @@ jobs: run: | echo "$GOOGLE_SERVICES_JSON_B64" | base64 -d > app/google-services.json + # core/data 의 R.raw.service_account 컴파일 위해 stub raw 생성 (CI 런타임 미실행). + - name: Create stub service_account raw (CI only) + run: | + mkdir -p core/data/src/main/res/raw + printf '%s' '{"type":"service_account","project_id":"ci-stub"}' > core/data/src/main/res/raw/service_account + - name: Run unit tests run: ./gradlew testDebugUnitTest --continue --parallel diff --git a/.idea/gradle.xml b/.idea/gradle.xml index d124cf2..2fd44c1 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -6,11 +6,23 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a22c263..2786b43 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,8 @@ plugins { alias(libs.plugins.ktlint.gradle) alias(libs.plugins.compose.screenshot) alias(libs.plugins.firebase.app.distribution) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) id("com.google.gms.google-services") } @@ -88,6 +90,17 @@ android { } dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:ui")) + implementation(project(":core:alarm")) + implementation(project(":core:data")) + implementation(project(":feature:main")) + implementation(project(":feature:addschedule")) + implementation(project(":feature:recommendation")) + implementation(project(":feature:information")) + implementation(project(":feature:profile")) + implementation(project(":feature:familygroup")) implementation(platform("com.google.firebase:firebase-bom:33.14.0")) implementation("com.google.firebase:firebase-analytics") implementation(libs.androidx.core.ktx) @@ -116,6 +129,9 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) implementation(libs.firebase.ui.auth) implementation(libs.volley) implementation(libs.androidx.material) @@ -138,6 +154,5 @@ dependencies { // ktlint (org.jlleitschuh.gradle.ktlint) — 버전을 lint.yml 의 ktlint_version 과 통일 ktlint { version.set("1.8.0") - android.set(true) ignoreFailures.set(false) } \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/slowclock/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/slowclock/ExampleInstrumentedTest.kt index f5c21f6..67e70b9 100644 --- a/app/src/androidTest/java/com/example/slowclock/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/example/slowclock/ExampleInstrumentedTest.kt @@ -19,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.example.slowclock", appContext.packageName) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/MainActivity.kt b/app/src/main/java/com/example/slowclock/MainActivity.kt index 1cae20a..4b6ed5e 100644 --- a/app/src/main/java/com/example/slowclock/MainActivity.kt +++ b/app/src/main/java/com/example/slowclock/MainActivity.kt @@ -24,9 +24,10 @@ import com.example.slowclock.ui.theme.SlowClockTheme import com.firebase.ui.auth.AuthUI import com.google.firebase.auth.FirebaseAuth import com.google.firebase.messaging.FirebaseMessaging +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch - +@AndroidEntryPoint class MainActivity : ComponentActivity() { private lateinit var authManager: AuthManager private val dummyDataManager = DummyDataManager() @@ -65,7 +66,7 @@ class MainActivity : ComponentActivity() { }, onError = { error -> Log.e("AUTH", "로그인 실패: $error") - } + }, ) // 로그인 상태 확인 @@ -87,7 +88,7 @@ class MainActivity : ComponentActivity() { authManager.ensureShareCodeForUser( currentUser.uid, currentUser.displayName ?: "", - currentUser.email ?: "" + currentUser.email ?: "", ) } addDummyData() @@ -128,17 +129,20 @@ class MainActivity : ComponentActivity() { val descriptionText = "일정 시간에 울리는 알림" val importance = NotificationManager.IMPORTANCE_HIGH val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM) - val audioAttributes = AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build() - - val channel = NotificationChannel("schedule_channel", name, importance).apply { - description = descriptionText - enableLights(true) - enableVibration(true) - setSound(soundUri, audioAttributes) // 🔊 사운드 설정 추가 - } + val audioAttributes = + AudioAttributes + .Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + + val channel = + NotificationChannel("schedule_channel", name, importance).apply { + description = descriptionText + enableLights(true) + enableVibration(true) + setSound(soundUri, audioAttributes) // 🔊 사운드 설정 추가 + } val notificationManager: NotificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager @@ -148,12 +152,12 @@ class MainActivity : ComponentActivity() { private fun requestNotificationPermission() { if (ContextCompat.checkSelfPermission( this, - android.Manifest.permission.POST_NOTIFICATIONS + android.Manifest.permission.POST_NOTIFICATIONS, ) != PackageManager.PERMISSION_GRANTED ) { requestPermissions( arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), - 1001 + 1001, ) } } @@ -162,13 +166,16 @@ class MainActivity : ComponentActivity() { val user = FirebaseAuth.getInstance().currentUser if (user != null) { FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> - val db = com.google.firebase.firestore.FirebaseFirestore.getInstance() - db.collection("users").document(user.uid) + val db = + com.google.firebase.firestore.FirebaseFirestore + .getInstance() + db + .collection("users") + .document(user.uid) .update("fcmToken", token) .addOnSuccessListener { Log.d("FCM", "로그인 후 토큰 Firestore 저장 성공") } .addOnFailureListener { e -> Log.e("FCM", "로그인 후 토큰 Firestore 저장 실패", e) } } } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/SlowClockApplication.kt b/app/src/main/java/com/example/slowclock/SlowClockApplication.kt index 2a1b876..0a73d11 100644 --- a/app/src/main/java/com/example/slowclock/SlowClockApplication.kt +++ b/app/src/main/java/com/example/slowclock/SlowClockApplication.kt @@ -4,7 +4,9 @@ import android.app.Application import com.google.firebase.FirebaseApp import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestoreSettings +import dagger.hilt.android.HiltAndroidApp +@HiltAndroidApp class SlowClockApplication : Application() { override fun onCreate() { super.onCreate() @@ -14,9 +16,11 @@ class SlowClockApplication : Application() { // Firestore 설정 val firestore = FirebaseFirestore.getInstance() - val settings = FirebaseFirestoreSettings.Builder() - .setPersistenceEnabled(true) // 오프라인 지원 활성화 - .build() + val settings = + FirebaseFirestoreSettings + .Builder() + .setPersistenceEnabled(true) // 오프라인 지원 활성화 + .build() firestore.firestoreSettings = settings } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/auth/AuthManager.kt b/app/src/main/java/com/example/slowclock/auth/AuthManager.kt index 19f9a45..684b758 100644 --- a/app/src/main/java/com/example/slowclock/auth/AuthManager.kt +++ b/app/src/main/java/com/example/slowclock/auth/AuthManager.kt @@ -18,52 +18,70 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await -class AuthManager(private val activity: ComponentActivity) { +class AuthManager( + private val activity: ComponentActivity, +) { private val firebaseAuth = FirebaseAuth.getInstance() private lateinit var signInLauncher: ActivityResultLauncher - fun initialize(onSuccess: () -> Unit, onError: (String) -> Unit = {}) { - signInLauncher = activity.registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - val response = IdpResponse.fromResultIntent(result.data) + fun initialize( + onSuccess: () -> Unit, + onError: (String) -> Unit = {}, + ) { + signInLauncher = + activity.registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + val response = IdpResponse.fromResultIntent(result.data) - if (result.resultCode == Activity.RESULT_OK) { - val user = firebaseAuth.currentUser - Log.d("AUTH", "로그인 성공: ${user?.displayName} (${user?.email})") - Log.d("AUTH", "=== 사용자 정보 확인 ===") - Log.d("AUTH", "displayName: '${user?.displayName}'") - Log.d("AUTH", "email: '${user?.email}'") - Log.d("AUTH", "photoUrl: '${user?.photoUrl}'") - Log.d("AUTH", "uid: '${user?.uid}'") - // Ensure shareCode exists for this user - user?.let { ensureShareCodeForUser(it.uid, it.displayName ?: "", it.email ?: "") } - onSuccess() - } else { - val error = response?.error?.message ?: "로그인이 취소되었습니다" - Log.e("AUTH", "로그인 실패: $error") - onError(error) + if (result.resultCode == Activity.RESULT_OK) { + val user = firebaseAuth.currentUser + Log.d("AUTH", "로그인 성공: ${user?.displayName} (${user?.email})") + Log.d("AUTH", "=== 사용자 정보 확인 ===") + Log.d("AUTH", "displayName: '${user?.displayName}'") + Log.d("AUTH", "email: '${user?.email}'") + Log.d("AUTH", "photoUrl: '${user?.photoUrl}'") + Log.d("AUTH", "uid: '${user?.uid}'") + // Ensure shareCode exists for this user + user?.let { ensureShareCodeForUser(it.uid, it.displayName ?: "", it.email ?: "") } + onSuccess() + } else { + val error = response?.error?.message ?: "로그인이 취소되었습니다" + Log.e("AUTH", "로그인 실패: $error") + onError(error) + } } - } } - fun ensureShareCodeForUser(uid: String, name: String, email: String) { + fun ensureShareCodeForUser( + uid: String, + name: String, + email: String, + ) { CoroutineScope(Dispatchers.IO).launch { try { - val userDoc = FirestoreDB.users.document(uid).get().await() + val userDoc = + FirestoreDB.users + .document(uid) + .get() + .await() val userModel = userDoc.toObject(User::class.java) if (userModel == null || userModel.shareCode.isBlank()) { // Generate unique 6-character code val code = generateUniqueShareCode() - val newUser = User( - id = uid, - name = name, - email = email, - shareCode = code, - createdAt = userModel?.createdAt ?: Timestamp.now(), - updatedAt = Timestamp.now() - ) - FirestoreDB.users.document(uid).set(newUser).await() + val newUser = + User( + id = uid, + name = name, + email = email, + shareCode = code, + createdAt = userModel?.createdAt ?: Timestamp.now(), + updatedAt = Timestamp.now(), + ) + FirestoreDB.users + .document(uid) + .set(newUser) + .await() } else { // Ensure name and email are always up to date val updates = mutableMapOf() @@ -71,7 +89,10 @@ class AuthManager(private val activity: ComponentActivity) { if (userModel.email != email && email.isNotBlank()) updates["email"] = email if (updates.isNotEmpty()) { updates["updatedAt"] = Timestamp.now() - FirestoreDB.users.document(uid).update(updates).await() + FirestoreDB.users + .document(uid) + .update(updates) + .await() } } } catch (e: Exception) { @@ -84,7 +105,13 @@ class AuthManager(private val activity: ComponentActivity) { val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" while (true) { val code = (1..6).map { chars.random() }.joinToString("") - val exists = FirestoreDB.users.whereEqualTo("shareCode", code).get().await().documents.isNotEmpty() + val exists = + FirestoreDB.users + .whereEqualTo("shareCode", code) + .get() + .await() + .documents + .isNotEmpty() if (!exists) return code } } @@ -93,26 +120,28 @@ class AuthManager(private val activity: ComponentActivity) { fun signInWithGoogle() { try { - val providers = arrayListOf( - AuthUI.IdpConfig.GoogleBuilder() - .setScopes( - listOf( - "https://www.googleapis.com/auth/userinfo.profile", // 프로필 이름 - "https://www.googleapis.com/auth/userinfo.email", // 이메일 - "https://www.googleapis.com/auth/calendar" // 기존 캘린더 - ) - ) - .build() - ) - - val signInIntent = AuthUI.getInstance() - .createSignInIntentBuilder() - .setAvailableProviders(providers) - .setTosAndPrivacyPolicyUrls( - "https://example.com/terms.html", - "https://example.com/privacy.html" + val providers = + arrayListOf( + AuthUI.IdpConfig + .GoogleBuilder() + .setScopes( + listOf( + "https://www.googleapis.com/auth/userinfo.profile", // 프로필 이름 + "https://www.googleapis.com/auth/userinfo.email", // 이메일 + "https://www.googleapis.com/auth/calendar", // 기존 캘린더 + ), + ).build(), ) - .build() + + val signInIntent = + AuthUI + .getInstance() + .createSignInIntentBuilder() + .setAvailableProviders(providers) + .setTosAndPrivacyPolicyUrls( + "https://example.com/terms.html", + "https://example.com/privacy.html", + ).build() Log.d("AUTH", "구글 로그인 시작") signInLauncher.launch(signInIntent) @@ -133,4 +162,4 @@ class AuthManager(private val activity: ComponentActivity) { // onComplete() // 실패해도 콜백 호출 // } // } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/data/remote/api/VertexAIServiceFactory.kt b/app/src/main/java/com/example/slowclock/data/remote/api/VertexAIServiceFactory.kt deleted file mode 100644 index cbc9876..0000000 --- a/app/src/main/java/com/example/slowclock/data/remote/api/VertexAIServiceFactory.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.slowclock.data.remote.api - -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory - -object VertexAIServiceFactory { - - private const val BASE_URL = "https://us-central1-aiplatform.googleapis.com/" - - fun create(): VertexAIService { - // 로깅 인터셉터 (디버그용) - val logging = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - } - - val client = OkHttpClient.Builder() - .addInterceptor(logging as Interceptor) - .build() - - val retrofit = Retrofit.Builder() - .baseUrl(BASE_URL) - .client(client) - .addConverterFactory(GsonConverterFactory.create()) - .build() - - return retrofit.create(VertexAIService::class.java) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt b/app/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt deleted file mode 100644 index 5b77e15..0000000 --- a/app/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt +++ /dev/null @@ -1,445 +0,0 @@ -// app/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt -package com.example.slowclock.data.remote.repository - -import android.content.Context -import android.util.Log -import com.example.slowclock.data.FirestoreDB -import com.example.slowclock.data.model.Schedule -import com.example.slowclock.notification.GuardianNotifier -import com.example.slowclock.util.AppError -import com.example.slowclock.util.toAppError -import com.google.firebase.Timestamp -import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.firestore.FirebaseFirestoreException -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.tasks.await -import java.util.Calendar - -/** - * Schedule 컬렉션에 대한 저장소 클래스 (정리된 버전) - */ -class ScheduleRepository { - private val auth = FirebaseAuth.getInstance() - private val schedulesCollection = FirestoreDB.schedules - - // 결과 타입 정의 - sealed class ScheduleResult { - data class Success(val data: T) : ScheduleResult() - data class Error(val error: AppError) : ScheduleResult() - } - - // 🔥 수동 파싱 함수 - toObject() 대신 이걸 써야 해 - private fun parseScheduleFromDocument(doc: com.google.firebase.firestore.DocumentSnapshot): Schedule? { - return try { - val data = doc.data ?: return null - - // 각 필드를 개별적으로 파싱해서 확실하게 가져옴 - val id = doc.id - val userId = data["userId"] as? String ?: "" - val familyGroupId = data["familyGroupId"] as? String ?: "" - val sharedCode = data["sharedCode"] as? String ?: "" - val title = data["title"] as? String ?: "" - val description = data["description"] as? String ?: "" - val startTime = data["startTime"] as? Timestamp ?: Timestamp.now() - val endTime = data["endTime"] as? Timestamp - val recurringType = data["recurringType"] as? String - val createdAt = data["createdAt"] as? Timestamp ?: Timestamp.now() - val updatedAt = data["updatedAt"] as? Timestamp ?: Timestamp.now() - - // Boolean 필드들을 명시적으로 변환 - val completed = when (val completed = data["completed"]) { - is Boolean -> completed - is String -> completed.toBoolean() - else -> { - Log.w( - "ScheduleRepo", - "completed 필드 파싱 실패: $completed (타입: ${completed?.javaClass})" - ) - false - } - } - - val recurring = when (val recurring = data["recurring"]) { - is Boolean -> recurring - is String -> recurring.toBoolean() - else -> false - } - - val skipped = when (val skipped = data["skipped"]) { - is Boolean -> skipped - is String -> skipped.toBoolean() - else -> false - } - - Log.d("ScheduleRepo", "파싱된 일정: $title, 완료상태: $completed (원본: ${data["completed"]})") - - Schedule( - id = id, - userId = userId, - familyGroupId = familyGroupId, - sharedCode = sharedCode, - title = title, - description = description, - startTime = startTime, - endTime = endTime, - completed = completed, - recurring = recurring, - recurringType = recurringType, - createdAt = createdAt, - updatedAt = updatedAt - ) - } catch (e: Exception) { - Log.e("ScheduleRepo", "일정 파싱 실패: ${doc.id}", e) - null - } - } - - // 특정 날짜의 data 가져오기 - suspend fun getSchedulesForDate(calendar: Calendar): ScheduleResult> { - val uid = auth.currentUser?.uid - if (uid == null) { - Log.e("ScheduleRepo", "사용자 로그인 안됨") - return ScheduleResult.Error(AppError.AuthError) - } - - val startOfDay = calendar.clone() as Calendar - startOfDay.set(Calendar.HOUR_OF_DAY, 0) - startOfDay.set(Calendar.MINUTE, 0) - startOfDay.set(Calendar.SECOND, 0) - startOfDay.set(Calendar.MILLISECOND, 0) - - val endOfDay = calendar.clone() as Calendar - endOfDay.set(Calendar.HOUR_OF_DAY, 23) - endOfDay.set(Calendar.MINUTE, 59) - endOfDay.set(Calendar.SECOND, 59) - endOfDay.set(Calendar.MILLISECOND, 999) - - return try { - val documents = schedulesCollection - .whereEqualTo("userId", uid) - .get() - .await() - - val allSchedules = documents.mapNotNull { parseScheduleFromDocument(it) } - - val selectedDateSchedules = allSchedules.filter { schedule -> - val scheduleTime = schedule.startTime.toDate() - scheduleTime.after(startOfDay.time) && scheduleTime.before(endOfDay.time) - }.sortedBy { it.startTime } - - ScheduleResult.Success(selectedDateSchedules) - } catch (e: Exception) { - ScheduleResult.Error(e.toAppError()) - } - } - - // 일정 추가 - suspend fun addSchedule(schedule: Schedule): ScheduleResult { - val uid = auth.currentUser?.uid - if (uid == null) { - Log.e("ScheduleRepo", "사용자 로그인 안됨") - return ScheduleResult.Error(AppError.AuthError) - } - - // 데이터 검증 - if (schedule.title.isBlank()) { - return ScheduleResult.Error(AppError.InvalidDataError) - } - - // Fetch user's shareCode - val userDoc = FirestoreDB.users.document(uid).get().await() - val userShareCode = userDoc.getString("shareCode") ?: "" - Log.d("ScheduleRepo", "Fetched userShareCode: '$userShareCode'") - if (userShareCode.isBlank()) { - Log.w("ScheduleRepo", "User's shareCode is blank! Schedule will be saved without a sharedCode.") - } - - val newSchedule = schedule.copy( - userId = uid, - sharedCode = userShareCode, - createdAt = Timestamp.now(), - updatedAt = Timestamp.now() - ) - - return try { - val docRef = schedulesCollection.document() - val scheduleWithId = newSchedule.copy(id = docRef.id) - Log.d("ScheduleRepo", "일정 저장 시도: ${scheduleWithId.title}") - - docRef.set(scheduleWithId).await() - Log.d("ScheduleRepo", "일정 저장 성공: ${docRef.id}") - ScheduleResult.Success(docRef.id) - - } catch (e: FirebaseFirestoreException) { - Log.e("ScheduleRepo", "Firestore 저장 에러: ${e.code}", e) - val error = when (e.code) { - FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError - FirebaseFirestoreException.Code.DEADLINE_EXCEEDED -> AppError.TimeoutError - FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError - FirebaseFirestoreException.Code.RESOURCE_EXHAUSTED -> AppError.StorageFullError - else -> AppError.SaveError - } - ScheduleResult.Error(error) - } catch (e: Exception) { - Log.e("ScheduleRepo", "일정 저장 실패", e) - ScheduleResult.Error(AppError.SaveError) - } - } - - // 일정 완료 상태 변경 - suspend fun markScheduleAsCompleted( - scheduleId: String, - completed: Boolean = true - ): ScheduleResult { - if (scheduleId.isBlank()) { - return ScheduleResult.Error(AppError.InvalidDataError) - } - - return try { - Log.d("ScheduleRepo", "완료 상태 변경 시도: $scheduleId -> $completed") - - schedulesCollection.document(scheduleId) - .update( - mapOf( - "completed" to completed, - "updatedAt" to Timestamp.now() - ) - ).await() - - Log.d("ScheduleRepo", "완료 상태 변경 성공: $scheduleId -> $completed") - ScheduleResult.Success(Unit) - - } catch (e: FirebaseFirestoreException) { - Log.e("ScheduleRepo", "완료 상태 변경 실패: ${e.code}", e) - val error = when (e.code) { - FirebaseFirestoreException.Code.NOT_FOUND -> AppError.NotFoundError - FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError - FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError - else -> AppError.GeneralError("상태 변경에 실패했습니다") - } - ScheduleResult.Error(error) - } catch (e: Exception) { - Log.e("ScheduleRepo", "완료 상태 변경 중 예상치 못한 에러", e) - ScheduleResult.Error(e.toAppError()) - } - } - - suspend fun updateSchedule(schedule: Schedule): ScheduleResult { - val uid = auth.currentUser?.uid - if (uid == null) { - return ScheduleResult.Error(AppError.AuthError) - } - - if (schedule.id.isBlank()) { - return ScheduleResult.Error(AppError.InvalidDataError) - } - - if (schedule.title.isBlank()) { - return ScheduleResult.Error(AppError.InvalidDataError) - } - - // Always preserve sharedCode and all fields from the original schedule - val updatedSchedule = schedule.copy( - userId = uid, // 현재 사용자 ID로 강제 설정 - updatedAt = Timestamp.now(), - sharedCode = schedule.sharedCode // ensure sharedCode is preserved - ) - - return try { - schedulesCollection.document(schedule.id) - .set(updatedSchedule) - .await() - - Log.d("ScheduleRepo", "일정 수정 성공: ${schedule.id}") - ScheduleResult.Success(Unit) - - } catch (e: FirebaseFirestoreException) { - Log.e("ScheduleRepo", "일정 수정 실패: ${e.code}", e) - val error = when (e.code) { - FirebaseFirestoreException.Code.NOT_FOUND -> AppError.NotFoundError - FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError - FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError - else -> AppError.SaveError - } - ScheduleResult.Error(error) - } catch (e: Exception) { - Log.e("ScheduleRepo", "일정 수정 중 예상치 못한 에러", e) - ScheduleResult.Error(AppError.SaveError) - } - } - - // 일정 삭제 - suspend fun deleteSchedule(scheduleId: String): ScheduleResult { - if (scheduleId.isBlank()) { - return ScheduleResult.Error(AppError.InvalidDataError) - } - - return try { - schedulesCollection.document(scheduleId) - .delete() - .await() - - Log.d("ScheduleRepo", "일정 삭제 성공: $scheduleId") - ScheduleResult.Success(Unit) - - } catch (e: FirebaseFirestoreException) { - Log.e("ScheduleRepo", "일정 삭제 실패: ${e.code}", e) - val error = when (e.code) { - FirebaseFirestoreException.Code.NOT_FOUND -> AppError.NotFoundError - FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError - FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError - else -> AppError.GeneralError("삭제에 실패했습니다") - } - ScheduleResult.Error(error) - } catch (e: Exception) { - Log.e("ScheduleRepo", "일정 삭제 중 예상치 못한 에러", e) - ScheduleResult.Error(e.toAppError()) - } - } - - // ID로 일정 가져오기 (편집용) - suspend fun getScheduleById(scheduleId: String): ScheduleResult { - if (scheduleId.isBlank()) { - return ScheduleResult.Error(AppError.InvalidDataError) - } - - return try { - val document = schedulesCollection.document(scheduleId).get().await() - - if (!document.exists()) { - return ScheduleResult.Error(AppError.NotFoundError) - } - - val schedule = parseScheduleFromDocument(document) - if (schedule != null) { - ScheduleResult.Success(schedule) - } else { - ScheduleResult.Error(AppError.InvalidDataError) - } - - } catch (e: FirebaseFirestoreException) { - Log.e("ScheduleRepo", "일정 조회 실패: ${e.code}", e) - val error = when (e.code) { - FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError - FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError - else -> AppError.NotFoundError - } - ScheduleResult.Error(error) - } catch (e: Exception) { - Log.e("ScheduleRepo", "일정 조회 중 예상치 못한 에러", e) - ScheduleResult.Error(e.toAppError()) - } - } - - // 공유 코드로 일정(리마인더) 목록 가져오기 - suspend fun getSchedulesBySharedCode(sharedCode: String): ScheduleResult> { - Log.d("ScheduleRepo", "공유코드로 일정 조회 시작: $sharedCode") - return try { - val documents = schedulesCollection - .whereEqualTo("sharedCode", sharedCode) - .get() - .await() - - Log.d("ScheduleRepo", "공유코드로 조회된 문서 수: ${documents.size()}") - - val schedules = documents.mapNotNull { document -> - parseScheduleFromDocument(document) - } - - if (schedules.isEmpty()) { - Log.i("ScheduleRepo", "공유코드로 불러온 일정이 없습니다.") - } else { - Log.i("ScheduleRepo", "공유코드로 불러온 일정: ${schedules.size}") - schedules.forEach { schedule -> - Log.d("ScheduleRepo", "공유 일정: ${schedule.title}, 완료: ${schedule.completed}") - } - } - ScheduleResult.Success(schedules) - } catch (e: FirebaseFirestoreException) { - Log.e("ScheduleRepo", "공유코드 일정 조회 실패: ${e.code}", e) - ScheduleResult.Error(AppError.GeneralError("공유코드 일정 조회 실패: ${e.localizedMessage}")) - } catch (e: Exception) { - Log.e("ScheduleRepo", "공유코드 일정 조회 중 에러", e) - ScheduleResult.Error(AppError.GeneralError("공유코드 일정 조회 중 에러: ${e.localizedMessage}")) - } - } - - // 공유 코드로 일정(리마인더) 목록 실시간 가져오기 - fun observeSchedulesBySharedCode(sharedCode: String): Flow> = callbackFlow { - val listener = schedulesCollection - .whereEqualTo("sharedCode", sharedCode) - .addSnapshotListener { snapshot, error -> - if (error != null) { - close(error) - return@addSnapshotListener - } - val schedules = snapshot?.documents?.mapNotNull { doc -> - parseScheduleFromDocument(doc) - } ?: emptyList() - trySend(schedules) - } - awaitClose { listener.remove() } - } - - // 공유코드로 같은 그룹의 모든 사용자에게 FCM 알림 발송 - suspend fun sendNotificationToShareCodeMembers( - context: Context, - shareCode: String, - title: String, - message: String - ) { - val currentUid = auth.currentUser?.uid ?: return - val users = FirestoreDB.users - .whereEqualTo("shareCode", shareCode) - .get().await() - .documents - .mapNotNull { it } - for (userDoc in users) { - val uid = userDoc.getString("id") ?: continue - if (uid == currentUid) continue // Skip self - val fcmToken = userDoc.getString("fcmToken") ?: continue - GuardianNotifier.sendReminderToUser(context, fcmToken, title, message) - } - - // Optimized: send to all tokens at once (if GuardianNotifier supports it) - val tokens = users - .filter { it.id != currentUid } - .mapNotNull { it.getString("fcmToken") } - .filter { it.isNotBlank() } - if (tokens.isNotEmpty()) { - GuardianNotifier.sendReminderToUsers(context, tokens, title, message) - } - } - - // 🔔 공유코드로 FCM 토큰 리스트 가져오기 (자기 자신 제외) - suspend fun getFcmTokensByShareCode(shareCode: String): List { - val currentUid = auth.currentUser?.uid ?: return emptyList() - return try { - val users = FirestoreDB.users - .whereEqualTo("shareCode", shareCode) - .get().await() - users.documents - .filter { it.id != currentUid } - .mapNotNull { it.getString("fcmToken") } - .filter { it.isNotBlank() } - } catch (e: Exception) { - Log.e("ScheduleRepo", "FCM 토큰 조회 실패", e) - emptyList() - } - } - - // 🔔 여러 사용자에게 FCM 알림 전송 - suspend fun notifyShareCodeMembers( - context: Context, - shareCode: String, - title: String, - message: String - ) { - val tokens = getFcmTokensByShareCode(shareCode) - if (tokens.isNotEmpty()) { - GuardianNotifier.sendReminderToUsers(context, tokens, title, message) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt b/app/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt deleted file mode 100644 index 599c50f..0000000 --- a/app/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.example.slowclock.data.remote.repository - -import android.content.Context -import com.example.slowclock.data.FirestoreDB -import com.example.slowclock.data.model.User -import com.example.slowclock.notification.GuardianNotifier -import com.example.slowclock.util.GroupIdHelper -import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.firestore.FirebaseFirestore -import com.google.firebase.firestore.SetOptions -import com.google.firebase.firestore.ktx.toObject -import kotlinx.coroutines.tasks.await - -/** - * User 컬렉션에 대한 저장소 클래스 - */ -class UserRepository { - private val auth = FirebaseAuth.getInstance() - private val usersCollection = FirestoreDB.users - - // 6-character code generator - private suspend fun generateUniqueShareCode(): String { - val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - while (true) { - val code = (1..6) - .map { chars.random() } - .joinToString("") - // Check uniqueness in Firestore - val exists = usersCollection - .whereEqualTo("shareCode", code) - .get().await().documents.isNotEmpty() - if (!exists) return code - } - } - - // 현재 로그인한 사용자 정보 가져오기 - suspend fun getCurrentUser(): User? { - val uid = auth.currentUser?.uid ?: return null - return try { - val document = usersCollection.document(uid).get().await() - document.toObject() - } catch (e: Exception) { - null - } - } - - // 사용자 정보 저장/업데이트 - suspend fun saveUser(user: User): Boolean { - return try { - val userToSave = if (user.shareCode.isBlank()) { - user.copy(shareCode = generateUniqueShareCode()) - } else user - usersCollection.document(user.id).set(userToSave).await() - true - } catch (e: Exception) { - false - } - } - - // ID로 사용자 정보 가져오기 - suspend fun getUserById(userId: String): User? { - return try { - val document = usersCollection.document(userId).get().await() - document.toObject() - } catch (e: Exception) { - null - } - } - - suspend fun createOrJoinGroup(inputGroupId: String? = null): Boolean { - val uid = FirebaseAuth.getInstance().currentUser?.uid ?: return false - val db = FirebaseFirestore.getInstance() - val groupId = inputGroupId ?: GroupIdHelper.generateGroupId(6) // Use input or generate new - val userMap = mapOf("familyGroupId" to groupId) - return try { - db.collection("users").document(uid).set(userMap, SetOptions.merge()).await() - true - } catch (e: Exception) { - false - } - } - - fun fetchFamilyTokens(groupId: String, onTokens: (List) -> Unit) { - val db = FirebaseFirestore.getInstance() - db.collection("users") - .whereEqualTo("familyGroupId", groupId) - .get() - .addOnSuccessListener { result -> - // fcmToken이 null이 아닌 것만 리스트로 반환 - val tokens = result.documents.mapNotNull { it.getString("fcmToken") } - onTokens(tokens) - } - .addOnFailureListener { - onTokens(emptyList()) // 실패 시 빈 리스트 반환 - } - } - - fun sendAlertToFamily(context: Context, groupId: String) { - fetchFamilyTokens(groupId) { tokens -> - tokens.forEach { token -> - GuardianNotifier.sendReminderToUser( - context = context, - fcmToken = token, - title = "가족 일정 알림", - message = "가족 그룹에서 새로운 알림이 도착했습니다." - ) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/example/slowclock/data/remote/repository/VertexAIRepository.kt b/app/src/main/java/com/example/slowclock/data/remote/repository/VertexAIRepository.kt deleted file mode 100644 index 65cb8f5..0000000 --- a/app/src/main/java/com/example/slowclock/data/remote/repository/VertexAIRepository.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.example.slowclock.data.remote.repository - -import android.content.Context -import android.util.Log -import com.example.slowclock.R -import com.example.slowclock.constants.AIConfig -import com.example.slowclock.data.remote.api.Content -import com.example.slowclock.data.remote.api.GenerateContentRequest -import com.example.slowclock.data.remote.api.GenerationConfig -import com.example.slowclock.data.remote.api.Part -import com.example.slowclock.data.remote.api.VertexAIService -import com.example.slowclock.data.remote.api.VertexAIServiceFactory -import com.google.auth.oauth2.ServiceAccountCredentials -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import retrofit2.HttpException - -class VertexAIRepository(private val context: Context) { - private val TAG = "VertexAI_SLOWCLOCK" - private val vertexAIService: VertexAIService = VertexAIServiceFactory.create() - - suspend fun generateScheduleRecommendation( - prompt: String - ): String? { - return try { - Log.d(TAG, "자격 증명 로드 시작") - // Load service account credentials from raw resource - val credentials = withContext(Dispatchers.IO) { - val creds = ServiceAccountCredentials.fromStream( - context.resources.openRawResource(R.raw.service_account) - ).createScoped(listOf("https://www.googleapis.com/auth/cloud-platform")) - creds.refresh() - creds - } - - Log.d(TAG, "인증 토큰 획득 성공") - val token = "Bearer ${credentials.accessToken.tokenValue}" - - Log.d(TAG, "API 요청 시작: $prompt") - val response = withContext(Dispatchers.IO) { - vertexAIService.generateContent( - projectId = AIConfig.PROJECT_ID, - location = AIConfig.LOCATION, - modelId = AIConfig.MODEL_ID, - request = GenerateContentRequest( - contents = listOf( - Content(role = "user", parts = listOf(Part(text = prompt))) - ), - generationConfig = GenerationConfig( - maxOutputTokens = 256, - temperature = 0.2f - ) - ), - auth = token - ) - } - - val result = response.candidates.firstOrNull()?.content?.parts - ?.joinToString("\n") { it.text } - - result - - } catch (e: HttpException) { - Log.e(TAG, "일정 추천 생성 실패: HTTP ${e.code()}", e) - null - } catch (e: Exception) { - Log.e(TAG, "상세 에러: ${e.stackTraceToString()}") - null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/slowclock/debug/AITestActivity.kt b/app/src/main/java/com/example/slowclock/debug/AITestActivity.kt index 715543f..39508b8 100644 --- a/app/src/main/java/com/example/slowclock/debug/AITestActivity.kt +++ b/app/src/main/java/com/example/slowclock/debug/AITestActivity.kt @@ -51,14 +51,15 @@ fun AITestScreen(aiManager: VertexAIRepository) { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Column( - modifier = Modifier - .padding(innerPadding) - .padding(16.dp) - .fillMaxSize() + modifier = + Modifier + .padding(innerPadding) + .padding(16.dp) + .fillMaxSize(), ) { Text( text = "AI 테스트", - style = MaterialTheme.typography.headlineMedium + style = MaterialTheme.typography.headlineMedium, ) Spacer(modifier = Modifier.height(16.dp)) @@ -67,7 +68,7 @@ fun AITestScreen(aiManager: VertexAIRepository) { value = prompt, onValueChange = { prompt = it }, label = { Text("프롬프트") }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(8.dp)) @@ -83,7 +84,7 @@ fun AITestScreen(aiManager: VertexAIRepository) { } } }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Text("실행") } @@ -92,15 +93,15 @@ fun AITestScreen(aiManager: VertexAIRepository) { Text( text = "결과:", - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = result, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/navigation/AppNavigation.kt b/app/src/main/java/com/example/slowclock/navigation/AppNavigation.kt index b6ae10e..3df1a94 100644 --- a/app/src/main/java/com/example/slowclock/navigation/AppNavigation.kt +++ b/app/src/main/java/com/example/slowclock/navigation/AppNavigation.kt @@ -7,13 +7,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument -import com.example.slowclock.ui.recommendation.RecommendationScreen -import com.example.slowclock.ui.timeline.TimelineScreen import com.example.slowclock.ui.addschedule.AddScheduleScreen import com.example.slowclock.ui.common.components.BottomNavigationBar import com.example.slowclock.ui.done.DoneScreen @@ -21,34 +19,37 @@ import com.example.slowclock.ui.information.InformationScreen import com.example.slowclock.ui.main.MainScreen import com.example.slowclock.ui.main.MainViewModel import com.example.slowclock.ui.profile.ProfileScreen +import com.example.slowclock.ui.recommendation.RecommendationScreen import com.example.slowclock.ui.settings.SettingsScreen +import com.example.slowclock.ui.timeline.TimelineScreen @Composable fun AppNavigation() { - val mainViewModel: MainViewModel = viewModel() + val mainViewModel: MainViewModel = hiltViewModel() val navController = rememberNavController() val currentRoute by navController.currentBackStackEntryFlow.collectAsState( - initial = navController.currentBackStackEntry + initial = navController.currentBackStackEntry, ) Scaffold( bottomBar = { BottomNavigationBar( currentRoute = currentRoute?.destination?.route ?: "main", - onNavigate = { navController.navigate(it) } + onNavigate = { navController.navigate(it) }, ) - } + }, ) { innerPadding -> NavHost( navController = navController, startDestination = "main", - modifier = Modifier.padding(innerPadding) + modifier = Modifier.padding(innerPadding), ) { composable("main") { - val result = navController.currentBackStackEntry - ?.savedStateHandle - ?.get("schedule_added") + val result = + navController.currentBackStackEntry + ?.savedStateHandle + ?.get("schedule_added") // 🔥 MainViewModel을 직접 전달 MainScreen( @@ -70,16 +71,16 @@ fun AppNavigation() { navController.currentBackStackEntry ?.savedStateHandle ?.remove("schedule_added") - } + }, ) } composable("done") { DoneScreen( - mainViewModel = mainViewModel + mainViewModel = mainViewModel, ) } - composable("information"){ + composable("information") { InformationScreen() } @@ -87,11 +88,12 @@ fun AppNavigation() { composable("settings") { SettingsScreen(navController = navController) } composable( - route = "add_schedule" - ) { - val initialTitle = navController.currentBackStackEntry - ?.savedStateHandle - ?.get("initial_title") + route = "add_schedule", + ) { + val initialTitle = + navController.currentBackStackEntry + ?.savedStateHandle + ?.get("initial_title") AddScheduleScreen( initialTitle = initialTitle, @@ -100,13 +102,12 @@ fun AppNavigation() { navController.previousBackStackEntry ?.savedStateHandle ?.set("schedule_added", true) - } navController.popBackStack() }, onNavigateToRecommendation = { navController.navigate("recommendation") - } + }, ) } @@ -119,8 +120,7 @@ fun AppNavigation() { navController.previousBackStackEntry ?.savedStateHandle ?.set("schedule_added", true) - } - else{ + } else { navController.navigate("main") { popUpTo("main") { inclusive = false } launchSingleTop = true @@ -129,13 +129,13 @@ fun AppNavigation() { }, onNavigateToRecommendation = { navController.navigate("recommendation") - } + }, ) } composable("profile") { ProfileScreen( - onNavigateBack = { navController.popBackStack() } + onNavigateBack = { navController.popBackStack() }, ) } @@ -145,9 +145,9 @@ fun AppNavigation() { composable("settings_share_code") { com.example.slowclock.ui.settings.SettingsScreenShareCode( - onReturn = { navController.popBackStack() } + onReturn = { navController.popBackStack() }, ) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/notification/GuardianNotifier.kt b/app/src/main/java/com/example/slowclock/notification/GuardianNotifier.kt deleted file mode 100644 index 1e9e87d..0000000 --- a/app/src/main/java/com/example/slowclock/notification/GuardianNotifier.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.example.slowclock.notification - -import android.content.Context -import android.util.Log -import com.android.volley.toolbox.JsonObjectRequest -import com.android.volley.toolbox.Volley -import org.json.JSONObject -import java.lang.reflect.Method - -object GuardianNotifier { - // Replace with your deployed Cloud Function URL - private const val CLOUD_FUNCTION_URL = "https://us-central1-slow-clock-scheduler.cloudfunctions.net/sendFcmNotification" - - fun sendReminderToUser(context: Context, fcmToken: String, title: String, message: String, shareCode: String? = null) { - val json = JSONObject().apply { - put("token", fcmToken) - put("title", title) - put("body", message) - if (shareCode != null) { - put("shareCode", shareCode) - } - } - - val request = object : JsonObjectRequest( - Method.POST, - CLOUD_FUNCTION_URL, - json, - { Log.d("FCM", "Push sent successfully via Cloud Function") }, - { error -> Log.e("FCM", "Push failed via Cloud Function", error) } - ) { - override fun getHeaders(): MutableMap { - return mutableMapOf( - "Content-Type" to "application/json" - ) - } - } - - Volley.newRequestQueue(context).add(request) - } - - fun sendReminderToUsers(context: Context, fcmTokens: List, title: String, message: String, shareCode: String? = null) { - for (token in fcmTokens) { - sendReminderToUser(context, token, title, message, shareCode) - } - } -} diff --git a/app/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt b/app/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt deleted file mode 100644 index 47ca600..0000000 --- a/app/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt +++ /dev/null @@ -1,243 +0,0 @@ -package com.example.slowclock.ui.addschedule - -import android.content.Context -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.slowclock.data.model.Schedule -import com.example.slowclock.data.remote.repository.ScheduleRepository -import com.example.slowclock.notification.requestExactAlarmPermissionIfNeeded -import com.example.slowclock.util.AppError -import com.example.slowclock.util.ScheduleAlarmHelper -import com.google.firebase.Timestamp -import com.google.firebase.auth.FirebaseAuth -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.util.Calendar - -data class AddScheduleUiState( - val title: String = "", - val description: String = "", - val selectedTime: Calendar = Calendar.getInstance(), - val endTime: Calendar? = null, - val recurring: Boolean = false, - val recurringType: String = "daily", - val showTimePicker: Boolean = false, - val showEndTimePicker: Boolean = false, - val isLoading: Boolean = false, - val isSuccess: Boolean = false, - val error: AppError? = null, - val canSave: Boolean = false, - val canRetry: Boolean = false, - val isEditMode: Boolean = false, - val editingScheduleId: String = "", - val editingSchedule: Schedule? = null // <-- add this -) - -class AddScheduleViewModel : ViewModel() { - private val scheduleRepository = ScheduleRepository() - - private val _uiState = MutableStateFlow(AddScheduleUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - fun updateTitle(title: String) { - _uiState.value = _uiState.value.copy( - title = title, - canSave = title.trim().isNotBlank(), - error = null - ) - } - - fun updateDescription(description: String) { - _uiState.value = _uiState.value.copy(description = description) - } - - fun updateTime(time: Calendar) { - val newTime = Calendar.getInstance().apply { - timeInMillis = time.timeInMillis - } - _uiState.value = _uiState.value.copy(selectedTime = newTime) - } - - fun updateEndTime(time: Calendar?) { - val newTime = time?.let { - Calendar.getInstance().apply { timeInMillis = it.timeInMillis } - } - _uiState.value = _uiState.value.copy(endTime = newTime) - } - - fun updateRecurring(recurring: Boolean) { - _uiState.value = _uiState.value.copy(recurring = recurring) - } - - fun updateRecurringType(type: String) { - _uiState.value = _uiState.value.copy(recurringType = type) - } - - fun showTimePicker(show: Boolean) { - _uiState.value = _uiState.value.copy(showTimePicker = show) - } - - fun showEndTimePicker(show: Boolean) { - _uiState.value = _uiState.value.copy(showEndTimePicker = show) - } - - fun loadScheduleForEdit(scheduleId: String) { - viewModelScope.launch { - _uiState.value = _uiState.value.copy( - isLoading = true, - error = null, - isEditMode = true, - editingScheduleId = scheduleId - ) - - when (val result = scheduleRepository.getScheduleById(scheduleId)) { - is ScheduleRepository.ScheduleResult.Success -> { - val schedule = result.data - val startCal = Calendar.getInstance().apply { - time = schedule.startTime.toDate() - } - val endCal = schedule.endTime?.let { endTime -> - Calendar.getInstance().apply { - time = endTime.toDate() - } - } - - _uiState.value = _uiState.value.copy( - title = schedule.title, - description = schedule.description, - selectedTime = startCal, - endTime = endCal, - recurring = schedule.recurring, - recurringType = schedule.recurringType ?: "daily", - isLoading = false, - canSave = schedule.title.isNotBlank(), - editingSchedule = schedule // <-- store full schedule - ) - } - - is ScheduleRepository.ScheduleResult.Error -> { - _uiState.value = _uiState.value.copy( - isLoading = false, - error = result.error, - canRetry = true - ) - } - } - } - } - - fun saveSchedule(context: Context) { - val currentTitle = _uiState.value.title.trim() - - // Validation (outside coroutine) - when { - currentTitle.isBlank() -> { - _uiState.value = _uiState.value.copy( - error = AppError.GeneralError("할 일을 입력해주세요") - ) - return - } - currentTitle.length > 100 -> { - _uiState.value = _uiState.value.copy( - error = AppError.GeneralError("제목이 너무 깁니다 (최대 100자)") - ) - return - } - _uiState.value.endTime?.let { end -> - end.timeInMillis <= _uiState.value.selectedTime.timeInMillis - } == true -> { - _uiState.value = _uiState.value.copy( - error = AppError.GeneralError("종료 시간은 시작 시간보다 늦어야 합니다") - ) - return - } - } - - viewModelScope.launch { - _uiState.value = _uiState.value.copy( - isLoading = true, - error = null, - canRetry = false - ) - - try { - Log.d("AddSchedule", "일정 저장 시작: $currentTitle") - Log.d("AddSchedule", "현재 사용자: ${FirebaseAuth.getInstance().currentUser?.uid}") - - val schedule = if (_uiState.value.isEditMode) { - // Use original schedule as base, update only changed fields - val original = _uiState.value.editingSchedule!! - original.copy( - title = currentTitle, - description = _uiState.value.description.trim(), - startTime = Timestamp(_uiState.value.selectedTime.time), - endTime = _uiState.value.endTime?.let { Timestamp(it.time) }, - recurring = _uiState.value.recurring, - recurringType = if (_uiState.value.recurring) _uiState.value.recurringType else null - ) - } else { - Schedule( - title = currentTitle, - description = _uiState.value.description.trim(), - startTime = Timestamp(_uiState.value.selectedTime.time), - endTime = _uiState.value.endTime?.let { Timestamp(it.time) }, - recurring = _uiState.value.recurring, - recurringType = if (_uiState.value.recurring) _uiState.value.recurringType else null - ) - } - - val result = if (_uiState.value.isEditMode) { - Log.d("AddSchedule", "일정 수정 모드") - scheduleRepository.updateSchedule(schedule) - } else { - Log.d("AddSchedule", "일정 추가 모드") - scheduleRepository.addSchedule(schedule) - } - - when (result) { - is ScheduleRepository.ScheduleResult.Success -> { - Log.d("AddSchedule", "저장 성공") - requestExactAlarmPermissionIfNeeded(context) - try { - ScheduleAlarmHelper.scheduleAlarm(context, schedule) - Log.d("AddSchedule", "알람 예약 성공") - } catch (e: Exception) { - Log.e("AddSchedule", "알람 예약 실패", e) - } - _uiState.value = _uiState.value.copy( - isLoading = false, - isSuccess = true - ) - } - is ScheduleRepository.ScheduleResult.Error -> { - Log.e("AddSchedule", "저장 실패: ${result.error.message}") - _uiState.value = _uiState.value.copy( - isLoading = false, - error = result.error, - canRetry = true - ) - } - } - } catch (e: Exception) { - Log.e("AddSchedule", "예상치 못한 저장 실패", e) - _uiState.value = _uiState.value.copy( - isLoading = false, - error = AppError.GeneralError("일정 저장 중 문제가 발생했습니다"), - canRetry = true - ) - } - } - } - - fun clearError() { - _uiState.value = _uiState.value.copy(error = null, canRetry = false) - } - - fun retryLastAction(context: Context) { - clearError() - saveSchedule(context) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupViewModel.kt b/app/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupViewModel.kt deleted file mode 100644 index 718b032..0000000 --- a/app/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupViewModel.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.example.slowclock.ui.familygroup - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.slowclock.data.remote.repository.UserRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -class FamilyGroupViewModel : ViewModel() { - private val userRepository = UserRepository() - - private val _statusMessage = MutableStateFlow("") - val statusMessage: StateFlow = _statusMessage - - fun createGroup() { - viewModelScope.launch { - val result = userRepository.createOrJoinGroup() - _statusMessage.value = if (result) "새 그룹 생성 완료!" else "그룹 생성 실패" - } - } - - fun joinGroup(inputGroupId: String) { - viewModelScope.launch { - val result = userRepository.createOrJoinGroup(inputGroupId) - _statusMessage.value = if (result) "그룹 참가 완료!" else "그룹 참가 실패" - } - } -} diff --git a/app/src/main/java/com/example/slowclock/ui/information/InfoViewModel.kt b/app/src/main/java/com/example/slowclock/ui/information/InfoViewModel.kt deleted file mode 100644 index c5c0c2d..0000000 --- a/app/src/main/java/com/example/slowclock/ui/information/InfoViewModel.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.example.slowclock.ui.information - - -import android.util.Log -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.slowclock.data.model.InfoData -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jsoup.Jsoup - -class InfoViewModel : ViewModel() { - private val _infoList = mutableStateListOf() - val infoList = _infoList - - private val _isLoading = mutableStateOf(false) - val isLoading = _isLoading - - fun fetchInfo() { - _isLoading.value = true - viewModelScope.launch{ - try{ - val fetchedInfo = getInfo() - _infoList.clear() - _infoList.addAll(fetchedInfo) - }catch (e: Exception){ - Log.e("Jsoup", "Error in fetch", e) - } finally { - _isLoading.value = false // 이게 반드시 필요함!! - } - } - } -} - -private suspend fun getInfo(): List = withContext(Dispatchers.IO){ - val baseUrl = "https://www.medicaltimes.com" - val doc = Jsoup.connect("$baseUrl/Main/News/List.html?MainCate=6&SubCate=79") - .userAgent("Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36") - .referrer("https://www.google.com") - .timeout(10000) - .get() - - val articles = doc.select("article.newsList_cont") - articles.mapNotNull { article -> - val title = article.selectFirst("h4.headLine")?.text()?.trim() - val relativeUrl = article.selectFirst("a")?.attr("href") - val fullUrl = if (!relativeUrl.isNullOrBlank()) "$baseUrl$relativeUrl" else null - - if (!title.isNullOrBlank() && !fullUrl.isNullOrBlank()) { - InfoData( - title = title, - infoUrl = fullUrl - ) - } else null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt b/app/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt deleted file mode 100644 index 2ad60c3..0000000 --- a/app/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt +++ /dev/null @@ -1,420 +0,0 @@ -// app/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt -package com.example.slowclock.ui.main - -import android.content.Context -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.slowclock.data.FirestoreDB -import com.example.slowclock.data.model.Schedule -import com.example.slowclock.data.model.User -import com.example.slowclock.data.remote.repository.ScheduleRepository -import com.example.slowclock.util.AppError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await -import kotlinx.coroutines.withContext -import java.util.Calendar - -data class MainUiState( - val todaySchedules: List = emptyList(), - val sharedReminders: List = emptyList(), // 공유 일정 - val currentSchedule: Schedule? = null, - val completedCount: Int = 0, - val totalCount: Int = 0, - val isLoading: Boolean = false, - val error: AppError? = null, // String → AppError 변경 - val selectedScheduleForDetail: Schedule? = null, - val canRetry: Boolean = false, // 재시도 가능 여부 추가 - val showDeleteConfirmDialog: Boolean = false, - val scheduleToDelete: Schedule? = null, - val sharedReminderOwners: Map = emptyMap() // userId -> name -) - -class MainViewModel : ViewModel() { - private val scheduleRepository = ScheduleRepository() - private var sharedRemindersJob: Job? = null - - private val _uiState = MutableStateFlow(MainUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - loadSchedules(Calendar.getInstance()) - } - -fun loadSchedules(calendar: Calendar) { - val scheduleDate = calendar.clone() as Calendar - scheduleDate.set(Calendar.HOUR_OF_DAY, 0) - scheduleDate.set(Calendar.MINUTE, 0) - scheduleDate.set(Calendar.SECOND, 0) - scheduleDate.set(Calendar.MILLISECOND, 0) - viewModelScope.launch { - _uiState.value = _uiState.value.copy( - isLoading = true, - error = null, - canRetry = false - ) - - try { - when (val result = scheduleRepository.getSchedulesForDate(scheduleDate)) { - is ScheduleRepository.ScheduleResult.Success -> { - val schedules = result.data - val currentTime = System.currentTimeMillis() - - val currentSchedule = schedules - .filter { !it.completed } - .let { incompleteSchedules -> - // 1단계: 현재 진행 중인 일정들 - val ongoingSchedules = incompleteSchedules.filter { schedule -> - val startTime = schedule.startTime.toDate().time - val endTime = - schedule.endTime?.toDate()?.time - ?: (startTime + 60 * 60 * 1000) - currentTime >= startTime && currentTime <= endTime - } - - if (ongoingSchedules.isNotEmpty()) { - // 진행 중: 끝나는 시간 빠른 순 - ongoingSchedules.minByOrNull { schedule -> - schedule.endTime?.toDate()?.time - ?: (schedule.startTime.toDate().time + 60 * 60 * 1000) - } - } else { - // 진행 중 없음: 시작 시간 빠른 순 → 끝나는 시간 빠른 순 - incompleteSchedules - .filter { it.startTime.toDate().time > currentTime } - .sortedWith( - compareBy { it.startTime.toDate().time } - .thenBy { schedule -> - schedule.endTime?.toDate()?.time - ?: (schedule.startTime.toDate().time + 60 * 60 * 1000) - } - ) - .firstOrNull() - } - } - - _uiState.value = MainUiState( - todaySchedules = schedules, - currentSchedule = currentSchedule, - completedCount = schedules.count { it.completed }, - totalCount = schedules.size, - isLoading = false - ) - - Log.d("MainViewModel", "일정 로드 성공: ${schedules.size}개") - } - - is ScheduleRepository.ScheduleResult.Error -> { - Log.e("MainViewModel", "일정 로드 실패: ${result.error.message}") - _uiState.value = _uiState.value.copy( - isLoading = false, - error = result.error, - canRetry = true // 재시도 가능 - ) - } - } - } catch (e: Exception) { - Log.e("MainViewModel", "예상치 못한 에러", e) - _uiState.value = _uiState.value.copy( - isLoading = false, - error = AppError.GeneralError("일정을 불러오는 중 문제가 발생했습니다"), - canRetry = true - ) - } - } -} - - fun toggleScheduleComplete(scheduleId: String) { - viewModelScope.launch { - val schedule = _uiState.value.todaySchedules.find { it.id == scheduleId } - schedule?.let { - // 낙관적 업데이트 (즉시 UI 변경) - val updatedSchedules = _uiState.value.todaySchedules.map { s -> - if (s.id == scheduleId) s.copy(completed = !s.completed) else s - } - - val currentTime = System.currentTimeMillis() - val currentSchedule = updatedSchedules.firstOrNull { schedule -> - !schedule.completed && - schedule.startTime.toDate().time <= currentTime && - (schedule.endTime?.toDate()?.time ?: Long.MAX_VALUE) > currentTime - } - - _uiState.value = _uiState.value.copy( - todaySchedules = updatedSchedules, - currentSchedule = currentSchedule, - completedCount = updatedSchedules.count { it.completed } - ) - - // 서버 업데이트 - when (val result = - scheduleRepository.markScheduleAsCompleted(scheduleId, !it.completed)) { - is ScheduleRepository.ScheduleResult.Success -> { - Log.d("MainViewModel", "완료 상태 변경 성공") - } - - is ScheduleRepository.ScheduleResult.Error -> { - Log.e("MainViewModel", "완료 상태 변경 실패: ${result.error.message}") - // 실패 시 원래 상태로 복구 - loadSchedules(Calendar.getInstance()) - - _uiState.value = _uiState.value.copy( - error = result.error, - canRetry = false - ) - } - } - } - } - } - - fun showScheduleDetail(scheduleId: String) { - val schedule = _uiState.value.todaySchedules.find { it.id == scheduleId } - _uiState.value = _uiState.value.copy(selectedScheduleForDetail = schedule) - } - - fun hideScheduleDetail() { - _uiState.value = _uiState.value.copy(selectedScheduleForDetail = null) - } - - fun clearError() { - _uiState.value = _uiState.value.copy(error = null, canRetry = false) - } - - fun retryLastAction() { - clearError() - loadSchedules(Calendar.getInstance()) - } - - fun showDeleteConfirmDialog(scheduleId: String) { - val schedule = _uiState.value.todaySchedules.find { it.id == scheduleId } - _uiState.value = _uiState.value.copy( - showDeleteConfirmDialog = true, - scheduleToDelete = schedule - ) - } - - fun hideDeleteConfirmDialog() { - _uiState.value = _uiState.value.copy( - showDeleteConfirmDialog = false, - scheduleToDelete = null - ) - } - - fun deleteSchedule(scheduleId: String) { - viewModelScope.launch { - _uiState.value = _uiState.value.copy( - isLoading = true, - error = null, - showDeleteConfirmDialog = false, - scheduleToDelete = null - ) - - when (val result = scheduleRepository.deleteSchedule(scheduleId)) { - is ScheduleRepository.ScheduleResult.Success -> { - Log.d("MainViewModel", "일정 삭제 성공") - // 목록에서 제거 - val updatedSchedules = - _uiState.value.todaySchedules.filter { it.id != scheduleId } - _uiState.value = _uiState.value.copy( - todaySchedules = updatedSchedules, - totalCount = updatedSchedules.size, - completedCount = updatedSchedules.count { it.completed }, - isLoading = false - ) - } - - is ScheduleRepository.ScheduleResult.Error -> { - Log.e("MainViewModel", "일정 삭제 실패: ${result.error.message}") - _uiState.value = _uiState.value.copy( - isLoading = false, - error = result.error, - canRetry = true - ) - } - } - } - } - - fun observeSharedReminders(shareCode: String?) { - if (shareCode.isNullOrBlank()) return - sharedRemindersJob?.cancel() - sharedRemindersJob = viewModelScope.launch { - scheduleRepository.observeSchedulesBySharedCode(shareCode).collectLatest { reminders -> - // Filter reminders to only include those with startTime on today's date - val today = java.util.Calendar.getInstance() - val filteredReminders = reminders.filter { schedule -> - val cal = java.util.Calendar.getInstance().apply { time = schedule.startTime.toDate() } - cal.get(java.util.Calendar.YEAR) == today.get(java.util.Calendar.YEAR) && - cal.get(java.util.Calendar.DAY_OF_YEAR) == today.get(java.util.Calendar.DAY_OF_YEAR) - } - _uiState.value = _uiState.value.copy(sharedReminders = filteredReminders) - // Optionally update owner names if needed - val userIds = filteredReminders.map { it.userId }.filter { it.isNotBlank() }.distinct() - val ownerMap = fetchUserNames(userIds) - _uiState.value = _uiState.value.copy(sharedReminderOwners = ownerMap) - } - } - } - - private suspend fun fetchUserNames(userIds: List): Map = withContext(Dispatchers.IO) { - if (userIds.isEmpty()) return@withContext emptyMap() - try { - val usersCollection = FirestoreDB.users - val chunks = userIds.chunked(10) // Firestore whereIn max 10 - val result = mutableMapOf() - for (chunk in chunks) { - val docs = usersCollection.whereIn("id", chunk).get().await() - for (doc in docs.documents) { - val user = doc.toObject(User::class.java) - if (user != null) { - result[user.id] = user.name - } - } - } - result - } catch (_: Exception) { - emptyMap() - } - } - - fun toggleSharedReminderComplete(scheduleId: String, context: Context) { - viewModelScope.launch { - val schedule = _uiState.value.sharedReminders.find { it.id == scheduleId } - schedule?.let { - // Optimistic update - val updatedReminders = _uiState.value.sharedReminders.map { s -> - if (s.id == scheduleId) s.copy(completed = !s.completed) else s - } - _uiState.value = _uiState.value.copy(sharedReminders = updatedReminders) - - // Update in Firestore - when (val result = - scheduleRepository.markScheduleAsCompleted(scheduleId, !it.completed)) { - is ScheduleRepository.ScheduleResult.Success -> { - // Send FCM notification to shareCode members - if (it.sharedCode.isNotBlank()) { - val title = if (!it.completed) "일정이 완료됨" else "일정이 미완료로 변경됨" - val message = - "${it.title} 일정이 ${if (!it.completed) "완료" else "미완료"} 처리되었습니다." - scheduleRepository.sendNotificationToShareCodeMembers( - context, - it.sharedCode, - title, - message - ) - } - } - is ScheduleRepository.ScheduleResult.Error -> { - // On error, reload shared reminders to revert - val shareCode = it.sharedCode - if (shareCode.isNotBlank()) { - observeSharedReminders(shareCode) - } - _uiState.value = _uiState.value.copy( - error = result.error, - canRetry = false - ) - } - } - } - } - } - - suspend fun notifyShareCodeMembersForSchedule(context: Context, schedule: Schedule, type: String) { - if (schedule.sharedCode.isBlank()) return - val (title, message) = when (type) { - "create" -> "새 일정이 추가되었습니다" to "${schedule.title} 일정이 추가되었습니다." - "edit" -> "일정이 수정되었습니다" to "${schedule.title} 일정이 수정되었습니다." - "delete" -> "일정이 삭제되었습니다" to "${schedule.title} 일정이 삭제되었습니다." - else -> return - } - scheduleRepository.sendNotificationToShareCodeMembers(context, schedule.sharedCode, title, message) - } - - fun addSharedSchedule(schedule: Schedule, context: Context) = viewModelScope.launch { - val result = scheduleRepository.addSchedule(schedule) - if (result is ScheduleRepository.ScheduleResult.Success) { - notifyShareCodeMembersForSchedule(context, schedule, "create") - } - } - - fun updateSharedSchedule(schedule: Schedule, context: Context) = viewModelScope.launch { - val result = scheduleRepository.updateSchedule(schedule) - if (result is ScheduleRepository.ScheduleResult.Success) { - notifyShareCodeMembersForSchedule(context, schedule, "edit") - } - } - - fun deleteSharedSchedule(schedule: Schedule, context: Context) = viewModelScope.launch { - val result = scheduleRepository.deleteSchedule(schedule.id) - if (result is ScheduleRepository.ScheduleResult.Success) { - notifyShareCodeMembersForSchedule(context, schedule, "delete") - } - } - - fun sendTestFcm(context: Context) { - viewModelScope.launch { - try { - val user = com.google.firebase.auth.FirebaseAuth.getInstance().currentUser - if (user != null) { - val db = com.google.firebase.firestore.FirebaseFirestore.getInstance() - val userDoc = db.collection("users").document(user.uid).get().await() - val fcmToken = userDoc.getString("fcmToken") - if (!fcmToken.isNullOrBlank()) { - com.example.slowclock.notification.GuardianNotifier.sendReminderToUser( - context, - fcmToken, - "FCM 테스트", - "이것은 테스트 메시지입니다." - ) - } else { - Log.e("TestFCM", "FCM 토큰이 없습니다.") - } - } - } catch (e: Exception) { - Log.e("TestFCM", "테스트 FCM 전송 실패", e) - } - } - } - - // --- ShareCode Watcher Logic --- - fun addShareCodeWatcher(context: Context, shareCode: String) { - Log.d("ShareCodeWatcher", "addShareCodeWatcher called with shareCode=$shareCode") - android.widget.Toast.makeText(context, "addShareCodeWatcher called", android.widget.Toast.LENGTH_SHORT).show() - val user = com.google.firebase.auth.FirebaseAuth.getInstance().currentUser ?: return - com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> - val db = com.google.firebase.firestore.FirebaseFirestore.getInstance() - db.collection("shareCodeWatchers") - .document(shareCode) - .collection("tokens") - .document(user.uid) - .set(mapOf("fcmToken" to token)) - .addOnSuccessListener { - Log.d("ShareCodeWatcher", "Watcher added for shareCode=$shareCode, userId=${user.uid}, token=$token") - android.widget.Toast.makeText(context, "Watcher added!", android.widget.Toast.LENGTH_SHORT).show() - } - .addOnFailureListener { e -> - Log.e("ShareCodeWatcher", "Failed to add watcher for shareCode=$shareCode, userId=${user.uid}", e) - android.widget.Toast.makeText(context, "Failed to add watcher: ${e.message}", android.widget.Toast.LENGTH_SHORT).show() - } - } - } - - fun removeShareCodeWatcher(shareCode: String) { - val user = com.google.firebase.auth.FirebaseAuth.getInstance().currentUser ?: return - val db = com.google.firebase.firestore.FirebaseFirestore.getInstance() - db.collection("shareCodeWatchers") - .document(shareCode) - .collection("tokens") - .document(user.uid) - .delete() - } - // --- End ShareCode Watcher Logic --- -} \ No newline at end of file diff --git a/app/src/main/java/com/example/slowclock/ui/recommendation/RecommendationScreen.kt b/app/src/main/java/com/example/slowclock/ui/recommendation/RecommendationScreen.kt deleted file mode 100644 index 93fe619..0000000 --- a/app/src/main/java/com/example/slowclock/ui/recommendation/RecommendationScreen.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.example.slowclock.ui.recommendation - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -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.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.example.slowclock.data.model.Recommendation -import com.example.slowclock.ui.recommendation.components.ADHDRecommendation -import com.example.slowclock.ui.recommendation.components.ElderlyRecommendation -import com.example.slowclock.ui.recommendation.components.StudentRecommendation - -@Composable -fun RecommendationScreen(navController: NavController, modifier: Modifier = Modifier) { - val userInfo = remember { mutableStateOf("Elderly") } - val allRecommendation = listOf( - Recommendation("밥 먹기"), - Recommendation("잠 자기"), - Recommendation("약 먹기"), - Recommendation("병원 예약하기"), - Recommendation("운동하기"), - Recommendation("감정 일기 쓰기","ADHD"), - Recommendation("회피 행동 돌아보기","ADHD"), - Recommendation("명상하기","ADHD"), - Recommendation("자습","학생"), - Recommendation("공부량 확인","학생"), - Recommendation("휴식하기","학생"), - Recommendation("복습하기","학생"), - Recommendation("햇빛 쬐기", "노인"), - Recommendation("일기 쓰기","노인"), - Recommendation("노인정에서 교류하기", "노인"), - Recommendation("음악 감상하기","노인"), - Recommendation("새로운 공부하기","노인"), - Recommendation("사회봉사하기","노인") - ) - Column(modifier = Modifier.fillMaxSize()) { - Row(modifier = Modifier.padding(8.dp)) { - Button(onClick = { userInfo.value = "Elderly" }, modifier = Modifier.padding(4.dp)) { - Text(text = "노인") - } - Button(onClick = { userInfo.value = "ADHD" }, modifier = Modifier.padding(4.dp)) { - Text(text = "ADHD") - } - Button(onClick = { userInfo.value = "Student" }, modifier = Modifier.padding(4.dp)) { - Text(text = "학생") - } - } - - when (userInfo.value) { - "Elderly" -> ElderlyRecommendation(list = allRecommendation, - onSelectRecommendation = { selectedTitle -> - navController.previousBackStackEntry - ?.savedStateHandle - ?.set("initial_title", selectedTitle) - navController.popBackStack() - }) - "ADHD" -> ADHDRecommendation(list = allRecommendation, - onSelectRecommendation = { selectedTitle -> - navController.previousBackStackEntry - ?.savedStateHandle - ?.set("initial_title", selectedTitle) - navController.popBackStack() - }) - else -> StudentRecommendation(list = allRecommendation, - onSelectRecommendation = { selectedTitle -> - navController.previousBackStackEntry - ?.savedStateHandle - ?.set("initial_title", selectedTitle) - navController.popBackStack() - }) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/slowclock/ui/theme/Type.kt b/app/src/main/java/com/example/slowclock/ui/theme/Type.kt deleted file mode 100644 index 36fdd2c..0000000 --- a/app/src/main/java/com/example/slowclock/ui/theme/Type.kt +++ /dev/null @@ -1,102 +0,0 @@ -// app/src/main/java/com/example/slowclock/ui/theme/Type.kt -package com.example.slowclock.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -// ============== 접근성 강화 타이포그래피 ============== - -val Typography = Typography( - // 대형 제목 (앱 이름, 주요 헤더) - headlineLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 32.sp, // 28sp → 32sp - lineHeight = 40.sp, - letterSpacing = 0.sp, - color = TextPrimary - ), - - // 중형 제목 (화면 제목, 섹션 헤더) - headlineMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 24.sp, // 20sp → 24sp - lineHeight = 32.sp, - letterSpacing = 0.sp, - color = TextPrimary - ), - - // 소형 제목 (카드 제목, 그룹 헤더) - headlineSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 20.sp, // 18sp → 20sp - lineHeight = 28.sp, - letterSpacing = 0.sp, - color = TextPrimary - ), - - // 큰 본문 (주요 내용, 일정 제목) - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 20.sp, // 16sp → 20sp (크게 증가) - lineHeight = 28.sp, // 24sp → 28sp - letterSpacing = 0.5.sp, - color = TextPrimary - ), - - // 중간 본문 (설명 텍스트, 부가 정보) - bodyMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 18.sp, // 14sp → 18sp (크게 증가) - lineHeight = 26.sp, // 20sp → 26sp - letterSpacing = 0.25.sp, - color = TextSecondary - ), - - // 작은 본문 (시간, 메타데이터) - bodySmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, // 12sp → 16sp (크게 증가) - lineHeight = 24.sp, // 16sp → 24sp - letterSpacing = 0.4.sp, - color = TextSecondary - ), - - // 버튼 텍스트 (모든 버튼) - labelLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, // 14sp → 18sp - lineHeight = 24.sp, // 20sp → 24sp - letterSpacing = 0.1.sp, - color = Surface - ), - - // 작은 버튼 텍스트 - labelMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 16.sp, // 12sp → 16sp - lineHeight = 20.sp, // 16sp → 20sp - letterSpacing = 0.5.sp, - color = Surface - ), - - // 아주 작은 라벨 (태그, 배지) - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, // 11sp → 14sp - lineHeight = 18.sp, // 16sp → 18sp - letterSpacing = 0.5.sp, - color = TextDisabled - ) -) \ No newline at end of file diff --git a/app/src/test/java/com/example/slowclock/ExampleUnitTest.kt b/app/src/test/java/com/example/slowclock/ExampleUnitTest.kt index 7a23f26..3383d31 100644 --- a/app/src/test/java/com/example/slowclock/ExampleUnitTest.kt +++ b/app/src/test/java/com/example/slowclock/ExampleUnitTest.kt @@ -13,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/build.gradle.kts b/build.gradle.kts index 851d110..afbb3d8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,17 @@ +buildscript { + dependencies { + // Hilt Gradle 플러그인(hiltAggregateDepsDebug)이 호출하는 JavaPoet ClassName.canonicalName() 보장 — 버전 충돌(NoSuchMethodError) 방지 + classpath("com.squareup:javapoet:1.13.0") + } +} + // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false id("com.google.gms.google-services") version "4.4.2" apply false } \ No newline at end of file diff --git a/core/alarm/build.gradle.kts b/core/alarm/build.gradle.kts new file mode 100644 index 0000000..4793f41 --- /dev/null +++ b/core/alarm/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.example.slowclock.core.alarm" + compileSdk = 35 + + defaultConfig { + minSdk = 32 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + api(project(":core:model")) + implementation(libs.androidx.core.ktx) + implementation(libs.firebase.messaging) + implementation(libs.firebase.auth) + implementation(libs.volley) +} diff --git a/app/src/main/java/com/example/slowclock/util/ScheduleAlarmHelper.kt b/core/alarm/src/main/java/com/example/slowclock/core/alarm/ScheduleAlarmHelper.kt similarity index 78% rename from app/src/main/java/com/example/slowclock/util/ScheduleAlarmHelper.kt rename to core/alarm/src/main/java/com/example/slowclock/core/alarm/ScheduleAlarmHelper.kt index 18dfdea..e449b83 100644 --- a/app/src/main/java/com/example/slowclock/util/ScheduleAlarmHelper.kt +++ b/core/alarm/src/main/java/com/example/slowclock/core/alarm/ScheduleAlarmHelper.kt @@ -1,4 +1,4 @@ -package com.example.slowclock.util +package com.example.slowclock.core.alarm import android.app.AlarmManager import android.app.PendingIntent @@ -6,12 +6,11 @@ import android.content.Context import android.content.Intent import android.os.Build import android.util.Log -import com.example.slowclock.receiver.AlarmReceiver import com.example.slowclock.data.model.Schedule +import com.example.slowclock.receiver.AlarmReceiver import java.util.* object ScheduleAlarmHelper { - private const val TAG = "ScheduleAlarmHelper" private const val END_ALARM_OFFSET = 9999 @@ -21,7 +20,11 @@ object ScheduleAlarmHelper { * @param schedule 스케줄 정보 * @param isFullScreen 풀스크린 알람 여부 (기본값: false) */ - fun scheduleAlarm(context: Context, schedule: Schedule, isFullScreen: Boolean = true) { + fun scheduleAlarm( + context: Context, + schedule: Schedule, + isFullScreen: Boolean = true, + ) { // 기존 알람 취소 후 새로 예약 cancelAlarm(context, schedule) @@ -49,7 +52,7 @@ object ScheduleAlarmHelper { schedule: Schedule, now: Long, alarmManager: AlarmManager, - isFullScreen: Boolean + isFullScreen: Boolean, ) { schedule.startTime?.toDate()?.time?.takeIf { it > now }?.let { triggerTime -> val intent = createAlarmIntent(context, schedule, "시작", isFullScreen) @@ -75,7 +78,7 @@ object ScheduleAlarmHelper { schedule: Schedule, now: Long, alarmManager: AlarmManager, - isFullScreen: Boolean + isFullScreen: Boolean, ) { schedule.endTime?.toDate()?.time?.takeIf { it > now }?.let { triggerTime -> val intent = createAlarmIntent(context, schedule, "종료", isFullScreen) @@ -100,40 +103,48 @@ object ScheduleAlarmHelper { context: Context, schedule: Schedule, type: String, - isFullScreen: Boolean - ): Intent { - return Intent(context, AlarmReceiver::class.java).apply { + isFullScreen: Boolean, + ): Intent = + Intent(context, AlarmReceiver::class.java).apply { putExtra("title", "${schedule.title} ($type)") putExtra("desc", schedule.description ?: "") putExtra("isFullScreen", isFullScreen) putExtra("scheduleId", schedule.id) putExtra("alarmType", type) } - } /** * PendingIntent를 생성합니다. */ - private fun createPendingIntent(context: Context, intent: Intent, requestCode: Int): PendingIntent { - return PendingIntent.getBroadcast( + private fun createPendingIntent( + context: Context, + intent: Intent, + requestCode: Int, + ): PendingIntent = + PendingIntent.getBroadcast( context, requestCode, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) - } /** * 정확한 알람을 설정합니다. */ - private fun setExactAlarm(alarmManager: AlarmManager, triggerTime: Long, pendingIntent: PendingIntent) { + private fun setExactAlarm( + alarmManager: AlarmManager, + triggerTime: Long, + pendingIntent: PendingIntent, + ) { when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT -> { alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) } + else -> { alarmManager.set(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) } @@ -143,7 +154,10 @@ object ScheduleAlarmHelper { /** * 스케줄의 모든 알람을 취소합니다. */ - fun cancelAlarm(context: Context, schedule: Schedule) { + fun cancelAlarm( + context: Context, + schedule: Schedule, + ) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager // 시작 알람 취소 @@ -161,22 +175,24 @@ object ScheduleAlarmHelper { alarmManager: AlarmManager, schedule: Schedule, requestCode: Int, - type: String + type: String, ) { try { - val intent = Intent(context, AlarmReceiver::class.java).apply { - putExtra("title", "${schedule.title} ($type)") - putExtra("desc", schedule.description ?: "") - putExtra("scheduleId", schedule.id) - putExtra("alarmType", type) - } + val intent = + Intent(context, AlarmReceiver::class.java).apply { + putExtra("title", "${schedule.title} ($type)") + putExtra("desc", schedule.description ?: "") + putExtra("scheduleId", schedule.id) + putExtra("alarmType", type) + } - val pendingIntent = PendingIntent.getBroadcast( - context, - requestCode, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + val pendingIntent = + PendingIntent.getBroadcast( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) alarmManager.cancel(pendingIntent) pendingIntent.cancel() // PendingIntent도 함께 취소 @@ -193,7 +209,7 @@ object ScheduleAlarmHelper { context: Context, schedule: Schedule, minutesBefore: Int = 10, - isFullScreen: Boolean = false + isFullScreen: Boolean = false, ) { schedule.startTime?.toDate()?.time?.let { startTime -> val reminderTime = startTime - (minutesBefore * 60 * 1000) @@ -201,13 +217,14 @@ object ScheduleAlarmHelper { if (reminderTime > now) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - val intent = Intent(context, AlarmReceiver::class.java).apply { - putExtra("title", "미리 알림: ${schedule.title}") - putExtra("desc", "${minutesBefore}분 후 시작됩니다") - putExtra("isFullScreen", isFullScreen) - putExtra("scheduleId", schedule.id) - putExtra("alarmType", "reminder") - } + val intent = + Intent(context, AlarmReceiver::class.java).apply { + putExtra("title", "미리 알림: ${schedule.title}") + putExtra("desc", "${minutesBefore}분 후 시작됩니다") + putExtra("isFullScreen", isFullScreen) + putExtra("scheduleId", schedule.id) + putExtra("alarmType", "reminder") + } val requestCode = generateReminderRequestCode(schedule.id) val pendingIntent = createPendingIntent(context, intent, requestCode) @@ -225,18 +242,22 @@ object ScheduleAlarmHelper { /** * 미리 알림 알람을 취소합니다. */ - fun cancelReminderAlarm(context: Context, schedule: Schedule) { + fun cancelReminderAlarm( + context: Context, + schedule: Schedule, + ) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val requestCode = generateReminderRequestCode(schedule.id) try { val intent = Intent(context, AlarmReceiver::class.java) - val pendingIntent = PendingIntent.getBroadcast( - context, - requestCode, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + val pendingIntent = + PendingIntent.getBroadcast( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) alarmManager.cancel(pendingIntent) pendingIntent.cancel() @@ -253,7 +274,7 @@ object ScheduleAlarmHelper { context: Context, schedule: Schedule, repeatInterval: Long = AlarmManager.INTERVAL_DAY, - isFullScreen: Boolean = false + isFullScreen: Boolean = false, ) { schedule.startTime?.toDate()?.time?.let { startTime -> val now = System.currentTimeMillis() @@ -274,7 +295,7 @@ object ScheduleAlarmHelper { AlarmManager.RTC_WAKEUP, nextTriggerTime, repeatInterval, - pendingIntent + pendingIntent, ) Log.d(TAG, "⏰ 반복 알람 예약 성공: ${schedule.title} at ${Date(nextTriggerTime)} (간격: ${repeatInterval}ms)") } catch (e: Exception) { @@ -287,37 +308,46 @@ object ScheduleAlarmHelper { * RequestCode 생성 함수들 */ private fun generateStartRequestCode(scheduleId: String): Int = scheduleId.hashCode() + private fun generateEndRequestCode(scheduleId: String): Int = scheduleId.hashCode() + END_ALARM_OFFSET + private fun generateReminderRequestCode(scheduleId: String): Int = scheduleId.hashCode() + 5000 + private fun generateRepeatingRequestCode(scheduleId: String): Int = scheduleId.hashCode() + 7000 /** * 알람 상태를 확인합니다. */ - fun isAlarmScheduled(context: Context, schedule: Schedule): Boolean { - return try { + fun isAlarmScheduled( + context: Context, + schedule: Schedule, + ): Boolean = + try { val intent = Intent(context, AlarmReceiver::class.java) - val pendingIntent = PendingIntent.getBroadcast( - context, - generateStartRequestCode(schedule.id), - intent, - PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE - ) + val pendingIntent = + PendingIntent.getBroadcast( + context, + generateStartRequestCode(schedule.id), + intent, + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE, + ) pendingIntent != null } catch (e: Exception) { Log.e(TAG, "알람 상태 확인 실패: ${e.message}") false } - } /** * 모든 알람을 취소합니다 (디버깅/테스트용) */ - fun cancelAllAlarms(context: Context, schedules: List) { + fun cancelAllAlarms( + context: Context, + schedules: List, + ) { schedules.forEach { schedule -> cancelAlarm(context, schedule) cancelReminderAlarm(context, schedule) } Log.d(TAG, "🛑 모든 알람 취소 완료") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/notification/FCMService.kt b/core/alarm/src/main/java/com/example/slowclock/notification/FCMService.kt similarity index 54% rename from app/src/main/java/com/example/slowclock/notification/FCMService.kt rename to core/alarm/src/main/java/com/example/slowclock/notification/FCMService.kt index 1c2d57e..89ef9e3 100644 --- a/app/src/main/java/com/example/slowclock/notification/FCMService.kt +++ b/core/alarm/src/main/java/com/example/slowclock/notification/FCMService.kt @@ -5,13 +5,11 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent import androidx.core.app.NotificationCompat -import com.example.slowclock.MainActivity -import com.example.slowclock.R +import com.example.slowclock.core.alarm.R import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage class FCMService : FirebaseMessagingService() { - override fun onMessageReceived(remoteMessage: RemoteMessage) { super.onMessageReceived(remoteMessage) val title = remoteMessage.notification?.title ?: "일정 알림" @@ -23,40 +21,60 @@ class FCMService : FirebaseMessagingService() { override fun onNewToken(token: String) { super.onNewToken(token) // Firestore에 FCM 토큰 저장 - val user = com.google.firebase.auth.FirebaseAuth.getInstance().currentUser + val user = + com.google.firebase.auth.FirebaseAuth + .getInstance() + .currentUser user?.let { - val db = com.google.firebase.firestore.FirebaseFirestore.getInstance() - db.collection("users").document(it.uid) + val db = + com.google.firebase.firestore.FirebaseFirestore + .getInstance() + db + .collection("users") + .document(it.uid) .update("fcmToken", token) .addOnSuccessListener { android.util.Log.d("FCM", "토큰 Firestore 저장 성공") } .addOnFailureListener { e -> android.util.Log.e("FCM", "토큰 Firestore 저장 실패", e) } } } - private fun sendNotification(title: String, messageBody: String) { - val intent = Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } + private fun sendNotification( + title: String, + messageBody: String, + ) { + val intent = + Intent().setClassName(this, "com.example.slowclock.MainActivity").apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } - val pendingIntent = PendingIntent.getActivity( - this, 0, intent, PendingIntent.FLAG_IMMUTABLE - ) + val pendingIntent = + PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE, + ) val channelId = "schedule_channel" - val notificationBuilder = NotificationCompat.Builder(this, channelId) - .setSmallIcon(R.drawable.ic_launcher_foreground) - .setContentTitle(title) - .setContentText(messageBody) - .setAutoCancel(true) - .setContentIntent(pendingIntent) + val notificationBuilder = + NotificationCompat + .Builder(this, channelId) + .setSmallIcon(R.drawable.baseline_access_alarm_24) + .setContentTitle(title) + .setContentText(messageBody) + .setAutoCancel(true) + .setContentIntent(pendingIntent) // 여기서 Context.NOTIFICATION_SERVICE 대신 NOTIFICATION_SERVICE만 써보기 val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - val channel = NotificationChannel( - channelId, "일정 알림", NotificationManager.IMPORTANCE_HIGH - ) + val channel = + NotificationChannel( + channelId, + "일정 알림", + NotificationManager.IMPORTANCE_HIGH, + ) notificationManager.createNotificationChannel(channel) notificationManager.notify(0, notificationBuilder.build()) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/notification/ForegroundService.kt b/core/alarm/src/main/java/com/example/slowclock/notification/ForegroundService.kt similarity index 61% rename from app/src/main/java/com/example/slowclock/notification/ForegroundService.kt rename to core/alarm/src/main/java/com/example/slowclock/notification/ForegroundService.kt index 3b7e952..d2dd95b 100644 --- a/app/src/main/java/com/example/slowclock/notification/ForegroundService.kt +++ b/core/alarm/src/main/java/com/example/slowclock/notification/ForegroundService.kt @@ -3,35 +3,41 @@ package com.example.slowclock.notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service -import android.content.pm.ServiceInfo // <--- IMPORT THIS import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo // <--- IMPORT THIS import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat class ForegroundService : Service() { - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { val channelId = "foreground_channel" val channelName = "Foreground Service" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - channelId, - channelName, - NotificationManager.IMPORTANCE_LOW - ) + val channel = + NotificationChannel( + channelId, + channelName, + NotificationManager.IMPORTANCE_LOW, + ) val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.createNotificationChannel(channel) } - val notification = NotificationCompat.Builder(this, channelId) - .setContentTitle("SlowClock 실행 중") - .setContentText("일정 알림 기능이 활성화되어 있습니다.") - .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) - .setOngoing(true) - .build() + val notification = + NotificationCompat + .Builder(this, channelId) + .setContentTitle("SlowClock 실행 중") + .setContentText("일정 알림 기능이 활성화되어 있습니다.") + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setOngoing(true) + .build() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Android 10 이상에서 서비스 타입 지정 필요 @@ -44,4 +50,4 @@ class ForegroundService : Service() { } override fun onBind(intent: Intent?): IBinder? = null -} \ No newline at end of file +} diff --git a/core/alarm/src/main/java/com/example/slowclock/notification/GuardianNotifier.kt b/core/alarm/src/main/java/com/example/slowclock/notification/GuardianNotifier.kt new file mode 100644 index 0000000..ad65132 --- /dev/null +++ b/core/alarm/src/main/java/com/example/slowclock/notification/GuardianNotifier.kt @@ -0,0 +1,59 @@ +package com.example.slowclock.notification + +import android.content.Context +import android.util.Log +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.Volley +import org.json.JSONObject +import java.lang.reflect.Method + +object GuardianNotifier { + // Replace with your deployed Cloud Function URL + private const val CLOUD_FUNCTION_URL = "https://us-central1-slow-clock-scheduler.cloudfunctions.net/sendFcmNotification" + + fun sendReminderToUser( + context: Context, + fcmToken: String, + title: String, + message: String, + shareCode: String? = null, + ) { + val json = + JSONObject().apply { + put("token", fcmToken) + put("title", title) + put("body", message) + if (shareCode != null) { + put("shareCode", shareCode) + } + } + + val request = + object : JsonObjectRequest( + Method.POST, + CLOUD_FUNCTION_URL, + json, + { Log.d("FCM", "Push sent successfully via Cloud Function") }, + { error -> Log.e("FCM", "Push failed via Cloud Function", error) }, + ) { + override fun getHeaders(): MutableMap = + mutableMapOf( + "Content-Type" to "application/json", + ) + } + + Volley.newRequestQueue(context).add(request) + } + + fun sendReminderToUsers( + context: Context, + fcmTokens: List, + title: String, + message: String, + shareCode: String? = null, + ) { + for (token in fcmTokens) { + sendReminderToUser(context, token, title, message, shareCode) + } + } +} diff --git a/app/src/main/java/com/example/slowclock/notification/RequestPermissionAlarm.kt b/core/alarm/src/main/java/com/example/slowclock/notification/RequestPermissionAlarm.kt similarity index 99% rename from app/src/main/java/com/example/slowclock/notification/RequestPermissionAlarm.kt rename to core/alarm/src/main/java/com/example/slowclock/notification/RequestPermissionAlarm.kt index 85ff2e6..ec8d5c3 100644 --- a/app/src/main/java/com/example/slowclock/notification/RequestPermissionAlarm.kt +++ b/core/alarm/src/main/java/com/example/slowclock/notification/RequestPermissionAlarm.kt @@ -15,4 +15,3 @@ fun requestExactAlarmPermissionIfNeeded(context: Context) { } } } - diff --git a/app/src/main/java/com/example/slowclock/reciever/AlarmDismissReceiver.kt b/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmDismissReceiver.kt similarity index 89% rename from app/src/main/java/com/example/slowclock/reciever/AlarmDismissReceiver.kt rename to core/alarm/src/main/java/com/example/slowclock/receiver/AlarmDismissReceiver.kt index 0d4f812..be49efb 100644 --- a/app/src/main/java/com/example/slowclock/reciever/AlarmDismissReceiver.kt +++ b/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmDismissReceiver.kt @@ -7,7 +7,10 @@ import android.content.Intent import android.util.Log class AlarmDismissReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { + override fun onReceive( + context: Context, + intent: Intent, + ) { Log.d("AlarmDismissReceiver", "알림 닫기 버튼 클릭됨") val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -19,4 +22,4 @@ class AlarmDismissReceiver : BroadcastReceiver() { // notificationManager.cancel(notificationId) // } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/reciever/AlarmReceiver.kt b/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmReceiver.kt similarity index 75% rename from app/src/main/java/com/example/slowclock/reciever/AlarmReceiver.kt rename to core/alarm/src/main/java/com/example/slowclock/receiver/AlarmReceiver.kt index 175f3db..1097902 100644 --- a/app/src/main/java/com/example/slowclock/reciever/AlarmReceiver.kt +++ b/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmReceiver.kt @@ -9,16 +9,20 @@ import androidx.core.content.ContextCompat import com.example.slowclock.ui.alarm.AlarmTriggerService class AlarmReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { + override fun onReceive( + context: Context, + intent: Intent, + ) { val title = intent.getStringExtra("title") ?: "Schedule Reminder" val desc = intent.getStringExtra("desc") ?: "" val isFullScreen = intent.getBooleanExtra("isFullScreen", false) - val serviceIntent = Intent(context, AlarmTriggerService::class.java).apply { - putExtra("title", title) - putExtra("desc", desc) - putExtra("isFullScreen", isFullScreen) - } + val serviceIntent = + Intent(context, AlarmTriggerService::class.java).apply { + putExtra("title", title) + putExtra("desc", desc) + putExtra("isFullScreen", isFullScreen) + } Log.d("AlarmReceiver", "🔔 알람 수신됨 (풀스크린: $isFullScreen), 서비스 실행: $title / $desc") @@ -32,4 +36,4 @@ class AlarmReceiver : BroadcastReceiver() { Log.e("AlarmReceiver", "서비스 시작 실패: ${e.message}") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt b/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt similarity index 70% rename from app/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt rename to core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt index ff10b4d..9917ddc 100644 --- a/app/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt +++ b/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt @@ -10,12 +10,11 @@ import android.os.Looper import android.view.WindowManager import android.widget.Button import android.widget.TextView -import com.example.slowclock.R +import com.example.slowclock.core.alarm.R import java.text.SimpleDateFormat import java.util.* class AlarmFullScreenActivity : Activity() { - private var mediaPlayer: MediaPlayer? = null private val timeHandler = Handler(Looper.getMainLooper()) private lateinit var timeRunnable: Runnable @@ -30,9 +29,9 @@ class AlarmFullScreenActivity : Activity() { } else { window.addFlags( WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or - WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD, ) } @@ -58,27 +57,30 @@ class AlarmFullScreenActivity : Activity() { } private fun updateCurrentTime(timeTextView: TextView) { - timeRunnable = object : Runnable { - override fun run() { - val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - timeTextView.text = sdf.format(Date()) - timeHandler.postDelayed(this, 1000) + timeRunnable = + object : Runnable { + override fun run() { + val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + timeTextView.text = sdf.format(Date()) + timeHandler.postDelayed(this, 1000) + } } - } timeHandler.post(timeRunnable) } private fun playAlarmSound() { try { - val alarmUri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM) - ?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) - - mediaPlayer = MediaPlayer().apply { - setDataSource(this@AlarmFullScreenActivity, alarmUri) - isLooping = true - prepare() - start() - } + val alarmUri: Uri = + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM) + ?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + + mediaPlayer = + MediaPlayer().apply { + setDataSource(this@AlarmFullScreenActivity, alarmUri) + isLooping = true + prepare() + start() + } } catch (e: Exception) { e.printStackTrace() } diff --git a/app/src/main/java/com/example/slowclock/ui/alarm/AlarmTriggerService.kt b/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmTriggerService.kt similarity index 53% rename from app/src/main/java/com/example/slowclock/ui/alarm/AlarmTriggerService.kt rename to core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmTriggerService.kt index d6d1531..f1b6abc 100644 --- a/app/src/main/java/com/example/slowclock/ui/alarm/AlarmTriggerService.kt +++ b/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmTriggerService.kt @@ -7,12 +7,10 @@ import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat -import com.example.slowclock.MainActivity -import com.example.slowclock.R +import com.example.slowclock.core.alarm.R import com.example.slowclock.receiver.AlarmDismissReceiver class AlarmTriggerService : Service() { - companion object { private const val FOREGROUND_SERVICE_ID = 123 private const val NOTIFICATION_CHANNEL_ID = "alarm_notification_channel" @@ -23,18 +21,23 @@ class AlarmTriggerService : Service() { createNotificationChannel() } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { val title = intent?.getStringExtra("title") ?: "알람" val desc = intent?.getStringExtra("desc") ?: "" val isFullScreen = intent?.getBooleanExtra("isFullScreen", false) ?: false Log.d("AlarmTriggerService", "알람 서비스 시작: $title, 풀스크린: $isFullScreen") - val notification = if (isFullScreen) { - createFullScreenNotification(title, desc) - } else { - createHeadsUpNotification(title, desc) - } + val notification = + if (isFullScreen) { + createFullScreenNotification(title, desc) + } else { + createHeadsUpNotification(title, desc) + } // 포그라운드 서비스로 시작 startForeground(FOREGROUND_SERVICE_ID, notification) @@ -56,21 +59,34 @@ class AlarmTriggerService : Service() { return START_NOT_STICKY } - private fun createHeadsUpNotification(title: String, desc: String): Notification { - val tapIntent = Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - val tapPendingIntent = PendingIntent.getActivity( - this, 0, tapIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + private fun createHeadsUpNotification( + title: String, + desc: String, + ): Notification { + val tapIntent = + Intent().setClassName(this, "com.example.slowclock.MainActivity").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val tapPendingIntent = + PendingIntent.getActivity( + this, + 0, + tapIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) // 알림 닫기 액션 추가 val dismissIntent = Intent(this, AlarmDismissReceiver::class.java) - val dismissPendingIntent = PendingIntent.getBroadcast( - this, 0, dismissIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + val dismissPendingIntent = + PendingIntent.getBroadcast( + this, + 0, + dismissIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + return NotificationCompat + .Builder(this, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.baseline_access_alarm_24) .setContentTitle("⏰ $title") .setContentText(desc) @@ -85,24 +101,38 @@ class AlarmTriggerService : Service() { .build() } - private fun createFullScreenNotification(title: String, desc: String): Notification { - val fullScreenIntent = Intent(this, AlarmFullScreenActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra("title", title) - putExtra("desc", desc) - } - val fullScreenPendingIntent = PendingIntent.getActivity( - this, 1, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val mainActivityIntent = Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - val mainActivityPendingIntent = PendingIntent.getActivity( - this, 2, mainActivityIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + private fun createFullScreenNotification( + title: String, + desc: String, + ): Notification { + val fullScreenIntent = + Intent(this, AlarmFullScreenActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra("title", title) + putExtra("desc", desc) + } + val fullScreenPendingIntent = + PendingIntent.getActivity( + this, + 1, + fullScreenIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val mainActivityIntent = + Intent().setClassName(this, "com.example.slowclock.MainActivity").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val mainActivityPendingIntent = + PendingIntent.getActivity( + this, + 2, + mainActivityIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + return NotificationCompat + .Builder(this, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.baseline_access_alarm_24) .setContentTitle("⏰ $title") .setContentText(desc) @@ -118,20 +148,21 @@ class AlarmTriggerService : Service() { private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - NOTIFICATION_CHANNEL_ID, - "알람 알림", - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = "알람 및 미리 알림" - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - enableVibration(true) - enableLights(true) - setSound( - android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_ALARM), - null - ) - } + val channel = + NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "알람 알림", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "알람 및 미리 알림" + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + enableVibration(true) + enableLights(true) + setSound( + android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_ALARM), + null, + ) + } val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager nm.createNotificationChannel(channel) } @@ -143,4 +174,4 @@ class AlarmTriggerService : Service() { } override fun onBind(intent: Intent?): IBinder? = null -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable/alarm_background.xml b/core/alarm/src/main/res/drawable/alarm_background.xml similarity index 100% rename from app/src/main/res/drawable/alarm_background.xml rename to core/alarm/src/main/res/drawable/alarm_background.xml diff --git a/app/src/main/res/drawable/baseline_access_alarm_24.xml b/core/alarm/src/main/res/drawable/baseline_access_alarm_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_access_alarm_24.xml rename to core/alarm/src/main/res/drawable/baseline_access_alarm_24.xml diff --git a/app/src/main/res/drawable/baseline_close_24.xml b/core/alarm/src/main/res/drawable/baseline_close_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_close_24.xml rename to core/alarm/src/main/res/drawable/baseline_close_24.xml diff --git a/app/src/main/res/layout/activity_alarm_fullscreen.xml b/core/alarm/src/main/res/layout/activity_alarm_fullscreen.xml similarity index 100% rename from app/src/main/res/layout/activity_alarm_fullscreen.xml rename to core/alarm/src/main/res/layout/activity_alarm_fullscreen.xml diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts new file mode 100644 index 0000000..323161e --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.example.slowclock.core.common" + compileSdk = 35 + + defaultConfig { + minSdk = 32 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + // ScheduleUtils 가 Schedule(model) 을 다루고, ErrorType 이 Firestore 예외를 매핑하므로 model api 전파 + api(project(":core:model")) +} diff --git a/app/src/main/java/com/example/slowclock/constants/AIconfig.kt b/core/common/src/main/java/com/example/slowclock/constants/AIconfig.kt similarity index 99% rename from app/src/main/java/com/example/slowclock/constants/AIconfig.kt rename to core/common/src/main/java/com/example/slowclock/constants/AIconfig.kt index 8eb32e4..c8fdbc3 100644 --- a/app/src/main/java/com/example/slowclock/constants/AIconfig.kt +++ b/core/common/src/main/java/com/example/slowclock/constants/AIconfig.kt @@ -5,4 +5,4 @@ object AIConfig { const val PROJECT_ID = "slow-clock-scheduler" const val LOCATION = "us-central1" const val MODEL_ID = "gemini-2.0-flash-lite-001" -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/util/ErrorType.kt b/core/common/src/main/java/com/example/slowclock/util/ErrorType.kt similarity index 85% rename from app/src/main/java/com/example/slowclock/util/ErrorType.kt rename to core/common/src/main/java/com/example/slowclock/util/ErrorType.kt index c21f575..e81fcda 100644 --- a/app/src/main/java/com/example/slowclock/util/ErrorType.kt +++ b/core/common/src/main/java/com/example/slowclock/util/ErrorType.kt @@ -1,32 +1,40 @@ // app/src/main/java/com/example/slowclock/util/ErrorType.kt package com.example.slowclock.util -sealed class AppError(val message: String) { +sealed class AppError( + val message: String, +) { // 네트워크 관련 에러 object NetworkError : AppError("인터넷 연결을 확인해주세요") + object TimeoutError : AppError("요청 시간이 초과되었습니다. 다시 시도해주세요") // 인증 관련 에러 object AuthError : AppError("로그인이 필요합니다") + object PermissionError : AppError("권한이 없습니다") // 데이터 관련 에러 object InvalidDataError : AppError("잘못된 데이터입니다") + object NotFoundError : AppError("요청한 정보를 찾을 수 없습니다") // 저장 관련 에러 object SaveError : AppError("저장에 실패했습니다. 다시 시도해주세요") + object StorageFullError : AppError("저장 공간이 부족합니다") // 일반 에러 (Error → GeneralError로 변경) - class GeneralError(customMessage: String? = null) : AppError( - customMessage ?: "알 수 없는 오류가 발생했습니다" - ) + class GeneralError( + customMessage: String? = null, + ) : AppError( + customMessage ?: "알 수 없는 오류가 발생했습니다", + ) } // 에러 타입 변환 유틸리티 -fun Throwable.toAppError(): AppError { - return when { +fun Throwable.toAppError(): AppError = + when { message?.contains("network", ignoreCase = true) == true -> AppError.NetworkError message?.contains("timeout", ignoreCase = true) == true -> AppError.TimeoutError message?.contains("permission", ignoreCase = true) == true -> AppError.PermissionError @@ -35,4 +43,3 @@ fun Throwable.toAppError(): AppError { message?.contains("storage", ignoreCase = true) == true -> AppError.StorageFullError else -> AppError.GeneralError(message) } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/slowclock/util/GroupIdHelper.kt b/core/common/src/main/java/com/example/slowclock/util/GroupIdHelper.kt similarity index 100% rename from app/src/main/java/com/example/slowclock/util/GroupIdHelper.kt rename to core/common/src/main/java/com/example/slowclock/util/GroupIdHelper.kt diff --git a/app/src/main/java/com/example/slowclock/util/ScheduleUtils.kt b/core/common/src/main/java/com/example/slowclock/util/ScheduleUtils.kt similarity index 63% rename from app/src/main/java/com/example/slowclock/util/ScheduleUtils.kt rename to core/common/src/main/java/com/example/slowclock/util/ScheduleUtils.kt index 4e71bb9..1f6c87e 100644 --- a/app/src/main/java/com/example/slowclock/util/ScheduleUtils.kt +++ b/core/common/src/main/java/com/example/slowclock/util/ScheduleUtils.kt @@ -6,17 +6,19 @@ import com.example.slowclock.data.model.Schedule /** * 일정에 추가 정보가 있는지 확인 */ -fun hasExtraInfo(schedule: Schedule): Boolean { - return schedule.description.isNotBlank() || - schedule.endTime != null || - schedule.recurring -} +fun hasExtraInfo(schedule: Schedule): Boolean = + schedule.description.isNotBlank() || + schedule.endTime != null || + schedule.recurring /** * 일정이 현재 진행 중인지 확인 */ -fun isOngoing(schedule: Schedule, currentTime: Long = System.currentTimeMillis()): Boolean { +fun isOngoing( + schedule: Schedule, + currentTime: Long = System.currentTimeMillis(), +): Boolean { val startTime = schedule.startTime.toDate().time val endTime = schedule.endTime?.toDate()?.time ?: (startTime + 60 * 60 * 1000) return currentTime >= startTime && currentTime <= endTime -} \ No newline at end of file +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 0000000..5272181 --- /dev/null +++ b/core/data/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.example.slowclock.core.data" + compileSdk = 35 + + defaultConfig { + minSdk = 32 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + api(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:alarm")) // GuardianNotifier (FCM 전송) 호출 + + implementation(libs.firebase.auth) + + // VertexAI (AI 추천 — 현재 debug 경로) + implementation(libs.retrofit) + implementation(libs.converter.gson) + implementation(libs.logging.interceptor) + implementation(libs.google.auth.library.oauth2.http) + + // Hilt: @Inject 생성자 Repository 의 Factory 가 본 모듈에서 생성되도록 + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) +} diff --git a/app/src/main/java/com/example/slowclock/data/DummyDataManager.kt b/core/data/src/main/java/com/example/slowclock/data/DummyDataManager.kt similarity index 74% rename from app/src/main/java/com/example/slowclock/data/DummyDataManager.kt rename to core/data/src/main/java/com/example/slowclock/data/DummyDataManager.kt index 4f1b537..6a4158a 100644 --- a/app/src/main/java/com/example/slowclock/data/DummyDataManager.kt +++ b/core/data/src/main/java/com/example/slowclock/data/DummyDataManager.kt @@ -20,16 +20,17 @@ class DummyDataManager { if (existing.isEmpty()) { Log.d("DUMMY", "일정 없음, 더미 데이터 추가") - val schedules = listOf( - Schedule(title = "아침 운동", startTime = getTodayTime(9, 0)), - Schedule( - title = "점심 약속", - startTime = getTodayTime(12, 30), - completed = true - ), - Schedule(title = "저녁 산책", startTime = getTodayTime(18, 0)), - Schedule(title = "지금 할 일", startTime = Timestamp.now()) - ) + val schedules = + listOf( + Schedule(title = "아침 운동", startTime = getTodayTime(9, 0)), + Schedule( + title = "점심 약속", + startTime = getTodayTime(12, 30), + completed = true, + ), + Schedule(title = "저녁 산책", startTime = getTodayTime(18, 0)), + Schedule(title = "지금 할 일", startTime = Timestamp.now()), + ) schedules.forEach { schedule -> when (val addResult = scheduleRepository.addSchedule(schedule)) { @@ -57,11 +58,14 @@ class DummyDataManager { } } - private fun getTodayTime(hour: Int, minute: Int): Timestamp { + private fun getTodayTime( + hour: Int, + minute: Int, + ): Timestamp { val calendar = Calendar.getInstance() calendar.set(Calendar.HOUR_OF_DAY, hour) calendar.set(Calendar.MINUTE, minute) calendar.set(Calendar.SECOND, 0) return Timestamp(calendar.time) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/data/FirestoreDB.kt b/core/data/src/main/java/com/example/slowclock/data/FirestoreDB.kt similarity index 99% rename from app/src/main/java/com/example/slowclock/data/FirestoreDB.kt rename to core/data/src/main/java/com/example/slowclock/data/FirestoreDB.kt index 14fef3c..f61d859 100644 --- a/app/src/main/java/com/example/slowclock/data/FirestoreDB.kt +++ b/core/data/src/main/java/com/example/slowclock/data/FirestoreDB.kt @@ -17,4 +17,4 @@ object FirestoreDB { val notifications: CollectionReference = db.collection("notifications") val scheduleRecommendations: CollectionReference = db.collection("scheduleRecommendations") val familyGroups: CollectionReference = db.collection("familyGroups") -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/data/remote/api/VertexAI.kt b/core/data/src/main/java/com/example/slowclock/data/remote/api/VertexAI.kt similarity index 78% rename from app/src/main/java/com/example/slowclock/data/remote/api/VertexAI.kt rename to core/data/src/main/java/com/example/slowclock/data/remote/api/VertexAI.kt index b2d0856..354872c 100644 --- a/app/src/main/java/com/example/slowclock/data/remote/api/VertexAI.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/api/VertexAI.kt @@ -13,34 +13,34 @@ interface VertexAIService { @Path("location") location: String, @Path("modelId") modelId: String, @Body request: GenerateContentRequest, - @Header("Authorization") auth: String + @Header("Authorization") auth: String, ): GenerateContentResponse } // 요청/응답 모델 data class GenerateContentRequest( val contents: List, - val generationConfig: GenerationConfig + val generationConfig: GenerationConfig, ) data class Content( val role: String, - val parts: List + val parts: List, ) data class Part( - val text: String + val text: String, ) data class GenerationConfig( val maxOutputTokens: Int, - val temperature: Float + val temperature: Float, ) data class GenerateContentResponse( - val candidates: List + val candidates: List, ) data class Candidate( - val content: Content -) \ No newline at end of file + val content: Content, +) diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/api/VertexAIServiceFactory.kt b/core/data/src/main/java/com/example/slowclock/data/remote/api/VertexAIServiceFactory.kt new file mode 100644 index 0000000..163dd79 --- /dev/null +++ b/core/data/src/main/java/com/example/slowclock/data/remote/api/VertexAIServiceFactory.kt @@ -0,0 +1,35 @@ +package com.example.slowclock.data.remote.api + +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +object VertexAIServiceFactory { + private const val BASE_URL = "https://us-central1-aiplatform.googleapis.com/" + + fun create(): VertexAIService { + // 로깅 인터셉터 (디버그용) + val logging = + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + val client = + OkHttpClient + .Builder() + .addInterceptor(logging as Interceptor) + .build() + + val retrofit = + Retrofit + .Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + return retrofit.create(VertexAIService::class.java) + } +} diff --git a/app/src/main/java/com/example/slowclock/data/remote/repository/FamilyGroupRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/FamilyGroupRepository.kt similarity index 80% rename from app/src/main/java/com/example/slowclock/data/remote/repository/FamilyGroupRepository.kt rename to core/data/src/main/java/com/example/slowclock/data/remote/repository/FamilyGroupRepository.kt index f712050..af7011c 100644 --- a/app/src/main/java/com/example/slowclock/data/remote/repository/FamilyGroupRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/FamilyGroupRepository.kt @@ -53,13 +53,14 @@ class FamilyGroupRepository { suspend fun createFamilyGroup(name: String): String? { val uid = auth.currentUser?.uid ?: return null - val familyGroup = FamilyGroup( - name = name, - ownerUserId = uid, - memberIds = listOf(uid), - createdAt = Timestamp.now(), - updatedAt = Timestamp.now() - ) + val familyGroup = + FamilyGroup( + name = name, + ownerUserId = uid, + memberIds = listOf(uid), + createdAt = Timestamp.now(), + updatedAt = Timestamp.now(), + ) return try { val docRef = familyGroupsCollection.document() @@ -72,46 +73,51 @@ class FamilyGroupRepository { } // 가족 그룹에 멤버 추가 - suspend fun addMemberToGroup(groupId: String, memberId: String): Boolean { - return try { - familyGroupsCollection.document(groupId) + suspend fun addMemberToGroup( + groupId: String, + memberId: String, + ): Boolean = + try { + familyGroupsCollection + .document(groupId) .update( mapOf( "memberIds" to FieldValue.arrayUnion(memberId), - "updatedAt" to Timestamp.now() - ) + "updatedAt" to Timestamp.now(), + ), ).await() true } catch (e: Exception) { false } - } // 가족 그룹에서 멤버 제거 - suspend fun removeMemberFromGroup(groupId: String, memberId: String): Boolean { - return try { - familyGroupsCollection.document(groupId) + suspend fun removeMemberFromGroup( + groupId: String, + memberId: String, + ): Boolean = + try { + familyGroupsCollection + .document(groupId) .update( mapOf( "memberIds" to FieldValue.arrayRemove(memberId), - "updatedAt" to Timestamp.now() - ) + "updatedAt" to Timestamp.now(), + ), ).await() true } catch (e: Exception) { false } - } // 가족 그룹 정보 가져오기 - suspend fun getFamilyGroupById(groupId: String): FamilyGroup? { - return try { + suspend fun getFamilyGroupById(groupId: String): FamilyGroup? = + try { val document = familyGroupsCollection.document(groupId).get().await() document.toObject() } catch (e: Exception) { null } - } // 가족 그룹 삭제 suspend fun deleteFamilyGroup(groupId: String): Boolean { @@ -127,6 +133,7 @@ class FamilyGroupRepository { return false } } + // 4. 해당 그룹 전체의 FCM 토큰 쿼리 (코루틴) suspend fun fetchGroupMembersFcmTokens(groupId: String): List { val group = getFamilyGroupById(groupId) ?: return emptyList() @@ -141,15 +148,20 @@ class FamilyGroupRepository { } // 5. 그룹 전체에게 FCM 알림 발송 - suspend fun sendAlertToGroup(context: Context, groupId: String, title: String, message: String) { + suspend fun sendAlertToGroup( + context: Context, + groupId: String, + title: String, + message: String, + ) { val tokens = fetchGroupMembersFcmTokens(groupId) tokens.forEach { token -> GuardianNotifier.sendReminderToUser( context = context, fcmToken = token, title = title, - message = message + message = message, ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/data/remote/repository/NotificationRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/NotificationRepository.kt similarity index 83% rename from app/src/main/java/com/example/slowclock/data/remote/repository/NotificationRepository.kt rename to core/data/src/main/java/com/example/slowclock/data/remote/repository/NotificationRepository.kt index 43a5f74..e9adb5e 100644 --- a/app/src/main/java/com/example/slowclock/data/remote/repository/NotificationRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/NotificationRepository.kt @@ -36,10 +36,11 @@ class NotificationRepository { suspend fun addNotification(notification: Notification): String? { val uid = auth.currentUser?.uid ?: return null - val newNotification = notification.copy( - userId = uid, - createdAt = Timestamp.now() - ) + val newNotification = + notification.copy( + userId = uid, + createdAt = Timestamp.now(), + ) return try { val docRef = notificationsCollection.document() @@ -52,16 +53,16 @@ class NotificationRepository { } // 알림 읽음 상태 변경 - suspend fun markNotificationAsRead(notificationId: String): Boolean { - return try { - notificationsCollection.document(notificationId) + suspend fun markNotificationAsRead(notificationId: String): Boolean = + try { + notificationsCollection + .document(notificationId) .update("isRead", true) .await() true } catch (e: Exception) { false } - } // 모든 알림 읽음 상태로 변경 suspend fun markAllNotificationsAsRead(): Boolean { @@ -70,11 +71,12 @@ class NotificationRepository { return try { val batch = FirestoreDB.db.batch() - val unreadNotifications = notificationsCollection - .whereEqualTo("userId", uid) - .whereEqualTo("isRead", false) - .get() - .await() + val unreadNotifications = + notificationsCollection + .whereEqualTo("userId", uid) + .whereEqualTo("isRead", false) + .get() + .await() unreadNotifications.documents.forEach { document -> batch.update(document.reference, "isRead", true) @@ -88,12 +90,11 @@ class NotificationRepository { } // 알림 삭제 - suspend fun deleteNotification(notificationId: String): Boolean { - return try { + suspend fun deleteNotification(notificationId: String): Boolean = + try { notificationsCollection.document(notificationId).delete().await() true } catch (e: Exception) { false } - } -} \ No newline at end of file +} diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt new file mode 100644 index 0000000..d6a3135 --- /dev/null +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt @@ -0,0 +1,484 @@ +// app/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt +package com.example.slowclock.data.remote.repository + +import android.content.Context +import android.util.Log +import com.example.slowclock.data.FirestoreDB +import com.example.slowclock.data.model.Schedule +import com.example.slowclock.notification.GuardianNotifier +import com.example.slowclock.util.AppError +import com.example.slowclock.util.toAppError +import com.google.firebase.Timestamp +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestoreException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.tasks.await +import java.util.Calendar +import javax.inject.Inject + +/** + * Schedule 컬렉션에 대한 저장소 클래스 (정리된 버전) + */ +class ScheduleRepository + @Inject + constructor() { + private val auth = FirebaseAuth.getInstance() + private val schedulesCollection = FirestoreDB.schedules + + // 결과 타입 정의 + sealed class ScheduleResult { + data class Success( + val data: T, + ) : ScheduleResult() + + data class Error( + val error: AppError, + ) : ScheduleResult() + } + + // 🔥 수동 파싱 함수 - toObject() 대신 이걸 써야 해 + private fun parseScheduleFromDocument(doc: com.google.firebase.firestore.DocumentSnapshot): Schedule? { + return try { + val data = doc.data ?: return null + + // 각 필드를 개별적으로 파싱해서 확실하게 가져옴 + val id = doc.id + val userId = data["userId"] as? String ?: "" + val familyGroupId = data["familyGroupId"] as? String ?: "" + val sharedCode = data["sharedCode"] as? String ?: "" + val title = data["title"] as? String ?: "" + val description = data["description"] as? String ?: "" + val startTime = data["startTime"] as? Timestamp ?: Timestamp.now() + val endTime = data["endTime"] as? Timestamp + val recurringType = data["recurringType"] as? String + val createdAt = data["createdAt"] as? Timestamp ?: Timestamp.now() + val updatedAt = data["updatedAt"] as? Timestamp ?: Timestamp.now() + + // Boolean 필드들을 명시적으로 변환 + val completed = + when (val completed = data["completed"]) { + is Boolean -> { + completed + } + + is String -> { + completed.toBoolean() + } + + else -> { + Log.w( + "ScheduleRepo", + "completed 필드 파싱 실패: $completed (타입: ${completed?.javaClass})", + ) + false + } + } + + val recurring = + when (val recurring = data["recurring"]) { + is Boolean -> recurring + is String -> recurring.toBoolean() + else -> false + } + + val skipped = + when (val skipped = data["skipped"]) { + is Boolean -> skipped + is String -> skipped.toBoolean() + else -> false + } + + Log.d("ScheduleRepo", "파싱된 일정: $title, 완료상태: $completed (원본: ${data["completed"]})") + + Schedule( + id = id, + userId = userId, + familyGroupId = familyGroupId, + sharedCode = sharedCode, + title = title, + description = description, + startTime = startTime, + endTime = endTime, + completed = completed, + recurring = recurring, + recurringType = recurringType, + createdAt = createdAt, + updatedAt = updatedAt, + ) + } catch (e: Exception) { + Log.e("ScheduleRepo", "일정 파싱 실패: ${doc.id}", e) + null + } + } + + // 특정 날짜의 data 가져오기 + suspend fun getSchedulesForDate(calendar: Calendar): ScheduleResult> { + val uid = auth.currentUser?.uid + if (uid == null) { + Log.e("ScheduleRepo", "사용자 로그인 안됨") + return ScheduleResult.Error(AppError.AuthError) + } + + val startOfDay = calendar.clone() as Calendar + startOfDay.set(Calendar.HOUR_OF_DAY, 0) + startOfDay.set(Calendar.MINUTE, 0) + startOfDay.set(Calendar.SECOND, 0) + startOfDay.set(Calendar.MILLISECOND, 0) + + val endOfDay = calendar.clone() as Calendar + endOfDay.set(Calendar.HOUR_OF_DAY, 23) + endOfDay.set(Calendar.MINUTE, 59) + endOfDay.set(Calendar.SECOND, 59) + endOfDay.set(Calendar.MILLISECOND, 999) + + return try { + val documents = + schedulesCollection + .whereEqualTo("userId", uid) + .get() + .await() + + val allSchedules = documents.mapNotNull { parseScheduleFromDocument(it) } + + val selectedDateSchedules = + allSchedules + .filter { schedule -> + val scheduleTime = schedule.startTime.toDate() + scheduleTime.after(startOfDay.time) && scheduleTime.before(endOfDay.time) + }.sortedBy { it.startTime } + + ScheduleResult.Success(selectedDateSchedules) + } catch (e: Exception) { + ScheduleResult.Error(e.toAppError()) + } + } + + // 일정 추가 + suspend fun addSchedule(schedule: Schedule): ScheduleResult { + val uid = auth.currentUser?.uid + if (uid == null) { + Log.e("ScheduleRepo", "사용자 로그인 안됨") + return ScheduleResult.Error(AppError.AuthError) + } + + // 데이터 검증 + if (schedule.title.isBlank()) { + return ScheduleResult.Error(AppError.InvalidDataError) + } + + // Fetch user's shareCode + val userDoc = + FirestoreDB.users + .document(uid) + .get() + .await() + val userShareCode = userDoc.getString("shareCode") ?: "" + Log.d("ScheduleRepo", "Fetched userShareCode: '$userShareCode'") + if (userShareCode.isBlank()) { + Log.w("ScheduleRepo", "User's shareCode is blank! Schedule will be saved without a sharedCode.") + } + + val newSchedule = + schedule.copy( + userId = uid, + sharedCode = userShareCode, + createdAt = Timestamp.now(), + updatedAt = Timestamp.now(), + ) + + return try { + val docRef = schedulesCollection.document() + val scheduleWithId = newSchedule.copy(id = docRef.id) + Log.d("ScheduleRepo", "일정 저장 시도: ${scheduleWithId.title}") + + docRef.set(scheduleWithId).await() + Log.d("ScheduleRepo", "일정 저장 성공: ${docRef.id}") + ScheduleResult.Success(docRef.id) + } catch (e: FirebaseFirestoreException) { + Log.e("ScheduleRepo", "Firestore 저장 에러: ${e.code}", e) + val error = + when (e.code) { + FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError + FirebaseFirestoreException.Code.DEADLINE_EXCEEDED -> AppError.TimeoutError + FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError + FirebaseFirestoreException.Code.RESOURCE_EXHAUSTED -> AppError.StorageFullError + else -> AppError.SaveError + } + ScheduleResult.Error(error) + } catch (e: Exception) { + Log.e("ScheduleRepo", "일정 저장 실패", e) + ScheduleResult.Error(AppError.SaveError) + } + } + + // 일정 완료 상태 변경 + suspend fun markScheduleAsCompleted( + scheduleId: String, + completed: Boolean = true, + ): ScheduleResult { + if (scheduleId.isBlank()) { + return ScheduleResult.Error(AppError.InvalidDataError) + } + + return try { + Log.d("ScheduleRepo", "완료 상태 변경 시도: $scheduleId -> $completed") + + schedulesCollection + .document(scheduleId) + .update( + mapOf( + "completed" to completed, + "updatedAt" to Timestamp.now(), + ), + ).await() + + Log.d("ScheduleRepo", "완료 상태 변경 성공: $scheduleId -> $completed") + ScheduleResult.Success(Unit) + } catch (e: FirebaseFirestoreException) { + Log.e("ScheduleRepo", "완료 상태 변경 실패: ${e.code}", e) + val error = + when (e.code) { + FirebaseFirestoreException.Code.NOT_FOUND -> AppError.NotFoundError + FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError + FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError + else -> AppError.GeneralError("상태 변경에 실패했습니다") + } + ScheduleResult.Error(error) + } catch (e: Exception) { + Log.e("ScheduleRepo", "완료 상태 변경 중 예상치 못한 에러", e) + ScheduleResult.Error(e.toAppError()) + } + } + + suspend fun updateSchedule(schedule: Schedule): ScheduleResult { + val uid = auth.currentUser?.uid + if (uid == null) { + return ScheduleResult.Error(AppError.AuthError) + } + + if (schedule.id.isBlank()) { + return ScheduleResult.Error(AppError.InvalidDataError) + } + + if (schedule.title.isBlank()) { + return ScheduleResult.Error(AppError.InvalidDataError) + } + + // Always preserve sharedCode and all fields from the original schedule + val updatedSchedule = + schedule.copy( + userId = uid, // 현재 사용자 ID로 강제 설정 + updatedAt = Timestamp.now(), + sharedCode = schedule.sharedCode, // ensure sharedCode is preserved + ) + + return try { + schedulesCollection + .document(schedule.id) + .set(updatedSchedule) + .await() + + Log.d("ScheduleRepo", "일정 수정 성공: ${schedule.id}") + ScheduleResult.Success(Unit) + } catch (e: FirebaseFirestoreException) { + Log.e("ScheduleRepo", "일정 수정 실패: ${e.code}", e) + val error = + when (e.code) { + FirebaseFirestoreException.Code.NOT_FOUND -> AppError.NotFoundError + FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError + FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError + else -> AppError.SaveError + } + ScheduleResult.Error(error) + } catch (e: Exception) { + Log.e("ScheduleRepo", "일정 수정 중 예상치 못한 에러", e) + ScheduleResult.Error(AppError.SaveError) + } + } + + // 일정 삭제 + suspend fun deleteSchedule(scheduleId: String): ScheduleResult { + if (scheduleId.isBlank()) { + return ScheduleResult.Error(AppError.InvalidDataError) + } + + return try { + schedulesCollection + .document(scheduleId) + .delete() + .await() + + Log.d("ScheduleRepo", "일정 삭제 성공: $scheduleId") + ScheduleResult.Success(Unit) + } catch (e: FirebaseFirestoreException) { + Log.e("ScheduleRepo", "일정 삭제 실패: ${e.code}", e) + val error = + when (e.code) { + FirebaseFirestoreException.Code.NOT_FOUND -> AppError.NotFoundError + FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError + FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError + else -> AppError.GeneralError("삭제에 실패했습니다") + } + ScheduleResult.Error(error) + } catch (e: Exception) { + Log.e("ScheduleRepo", "일정 삭제 중 예상치 못한 에러", e) + ScheduleResult.Error(e.toAppError()) + } + } + + // ID로 일정 가져오기 (편집용) + suspend fun getScheduleById(scheduleId: String): ScheduleResult { + if (scheduleId.isBlank()) { + return ScheduleResult.Error(AppError.InvalidDataError) + } + + return try { + val document = schedulesCollection.document(scheduleId).get().await() + + if (!document.exists()) { + return ScheduleResult.Error(AppError.NotFoundError) + } + + val schedule = parseScheduleFromDocument(document) + if (schedule != null) { + ScheduleResult.Success(schedule) + } else { + ScheduleResult.Error(AppError.InvalidDataError) + } + } catch (e: FirebaseFirestoreException) { + Log.e("ScheduleRepo", "일정 조회 실패: ${e.code}", e) + val error = + when (e.code) { + FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError + FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError + else -> AppError.NotFoundError + } + ScheduleResult.Error(error) + } catch (e: Exception) { + Log.e("ScheduleRepo", "일정 조회 중 예상치 못한 에러", e) + ScheduleResult.Error(e.toAppError()) + } + } + + // 공유 코드로 일정(리마인더) 목록 가져오기 + suspend fun getSchedulesBySharedCode(sharedCode: String): ScheduleResult> { + Log.d("ScheduleRepo", "공유코드로 일정 조회 시작: $sharedCode") + return try { + val documents = + schedulesCollection + .whereEqualTo("sharedCode", sharedCode) + .get() + .await() + + Log.d("ScheduleRepo", "공유코드로 조회된 문서 수: ${documents.size()}") + + val schedules = + documents.mapNotNull { document -> + parseScheduleFromDocument(document) + } + + if (schedules.isEmpty()) { + Log.i("ScheduleRepo", "공유코드로 불러온 일정이 없습니다.") + } else { + Log.i("ScheduleRepo", "공유코드로 불러온 일정: ${schedules.size}") + schedules.forEach { schedule -> + Log.d("ScheduleRepo", "공유 일정: ${schedule.title}, 완료: ${schedule.completed}") + } + } + ScheduleResult.Success(schedules) + } catch (e: FirebaseFirestoreException) { + Log.e("ScheduleRepo", "공유코드 일정 조회 실패: ${e.code}", e) + ScheduleResult.Error(AppError.GeneralError("공유코드 일정 조회 실패: ${e.localizedMessage}")) + } catch (e: Exception) { + Log.e("ScheduleRepo", "공유코드 일정 조회 중 에러", e) + ScheduleResult.Error(AppError.GeneralError("공유코드 일정 조회 중 에러: ${e.localizedMessage}")) + } + } + + // 공유 코드로 일정(리마인더) 목록 실시간 가져오기 + fun observeSchedulesBySharedCode(sharedCode: String): Flow> = + callbackFlow { + val listener = + schedulesCollection + .whereEqualTo("sharedCode", sharedCode) + .addSnapshotListener { snapshot, error -> + if (error != null) { + close(error) + return@addSnapshotListener + } + val schedules = + snapshot?.documents?.mapNotNull { doc -> + parseScheduleFromDocument(doc) + } ?: emptyList() + trySend(schedules) + } + awaitClose { listener.remove() } + } + + // 공유코드로 같은 그룹의 모든 사용자에게 FCM 알림 발송 + suspend fun sendNotificationToShareCodeMembers( + context: Context, + shareCode: String, + title: String, + message: String, + ) { + val currentUid = auth.currentUser?.uid ?: return + val users = + FirestoreDB.users + .whereEqualTo("shareCode", shareCode) + .get() + .await() + .documents + .mapNotNull { it } + for (userDoc in users) { + val uid = userDoc.getString("id") ?: continue + if (uid == currentUid) continue // Skip self + val fcmToken = userDoc.getString("fcmToken") ?: continue + GuardianNotifier.sendReminderToUser(context, fcmToken, title, message) + } + + // Optimized: send to all tokens at once (if GuardianNotifier supports it) + val tokens = + users + .filter { it.id != currentUid } + .mapNotNull { it.getString("fcmToken") } + .filter { it.isNotBlank() } + if (tokens.isNotEmpty()) { + GuardianNotifier.sendReminderToUsers(context, tokens, title, message) + } + } + + // 🔔 공유코드로 FCM 토큰 리스트 가져오기 (자기 자신 제외) + suspend fun getFcmTokensByShareCode(shareCode: String): List { + val currentUid = auth.currentUser?.uid ?: return emptyList() + return try { + val users = + FirestoreDB.users + .whereEqualTo("shareCode", shareCode) + .get() + .await() + users.documents + .filter { it.id != currentUid } + .mapNotNull { it.getString("fcmToken") } + .filter { it.isNotBlank() } + } catch (e: Exception) { + Log.e("ScheduleRepo", "FCM 토큰 조회 실패", e) + emptyList() + } + } + + // 🔔 여러 사용자에게 FCM 알림 전송 + suspend fun notifyShareCodeMembers( + context: Context, + shareCode: String, + title: String, + message: String, + ) { + val tokens = getFcmTokensByShareCode(shareCode) + if (tokens.isNotEmpty()) { + GuardianNotifier.sendReminderToUsers(context, tokens, title, message) + } + } + } diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt new file mode 100644 index 0000000..b9846f9 --- /dev/null +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt @@ -0,0 +1,129 @@ +package com.example.slowclock.data.remote.repository + +import android.content.Context +import com.example.slowclock.data.FirestoreDB +import com.example.slowclock.data.model.User +import com.example.slowclock.notification.GuardianNotifier +import com.example.slowclock.util.GroupIdHelper +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.SetOptions +import com.google.firebase.firestore.ktx.toObject +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +/** + * User 컬렉션에 대한 저장소 클래스 + */ +class UserRepository + @Inject + constructor() { + private val auth = FirebaseAuth.getInstance() + private val usersCollection = FirestoreDB.users + + // 6-character code generator + private suspend fun generateUniqueShareCode(): String { + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + while (true) { + val code = + (1..6) + .map { chars.random() } + .joinToString("") + // Check uniqueness in Firestore + val exists = + usersCollection + .whereEqualTo("shareCode", code) + .get() + .await() + .documents + .isNotEmpty() + if (!exists) return code + } + } + + // 현재 로그인한 사용자 정보 가져오기 + suspend fun getCurrentUser(): User? { + val uid = auth.currentUser?.uid ?: return null + return try { + val document = usersCollection.document(uid).get().await() + document.toObject() + } catch (e: Exception) { + null + } + } + + // 사용자 정보 저장/업데이트 + suspend fun saveUser(user: User): Boolean = + try { + val userToSave = + if (user.shareCode.isBlank()) { + user.copy(shareCode = generateUniqueShareCode()) + } else { + user + } + usersCollection.document(user.id).set(userToSave).await() + true + } catch (e: Exception) { + false + } + + // ID로 사용자 정보 가져오기 + suspend fun getUserById(userId: String): User? = + try { + val document = usersCollection.document(userId).get().await() + document.toObject() + } catch (e: Exception) { + null + } + + suspend fun createOrJoinGroup(inputGroupId: String? = null): Boolean { + val uid = FirebaseAuth.getInstance().currentUser?.uid ?: return false + val db = FirebaseFirestore.getInstance() + val groupId = inputGroupId ?: GroupIdHelper.generateGroupId(6) // Use input or generate new + val userMap = mapOf("familyGroupId" to groupId) + return try { + db + .collection("users") + .document(uid) + .set(userMap, SetOptions.merge()) + .await() + true + } catch (e: Exception) { + false + } + } + + fun fetchFamilyTokens( + groupId: String, + onTokens: (List) -> Unit, + ) { + val db = FirebaseFirestore.getInstance() + db + .collection("users") + .whereEqualTo("familyGroupId", groupId) + .get() + .addOnSuccessListener { result -> + // fcmToken이 null이 아닌 것만 리스트로 반환 + val tokens = result.documents.mapNotNull { it.getString("fcmToken") } + onTokens(tokens) + }.addOnFailureListener { + onTokens(emptyList()) // 실패 시 빈 리스트 반환 + } + } + + fun sendAlertToFamily( + context: Context, + groupId: String, + ) { + fetchFamilyTokens(groupId) { tokens -> + tokens.forEach { token -> + GuardianNotifier.sendReminderToUser( + context = context, + fcmToken = token, + title = "가족 일정 알림", + message = "가족 그룹에서 새로운 알림이 도착했습니다.", + ) + } + } + } + } diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/VertexAIRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/VertexAIRepository.kt new file mode 100644 index 0000000..4ce2f9b --- /dev/null +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/VertexAIRepository.kt @@ -0,0 +1,83 @@ +package com.example.slowclock.data.remote.repository + +import android.content.Context +import android.util.Log +import com.example.slowclock.constants.AIConfig +import com.example.slowclock.core.data.R +import com.example.slowclock.data.remote.api.Content +import com.example.slowclock.data.remote.api.GenerateContentRequest +import com.example.slowclock.data.remote.api.GenerationConfig +import com.example.slowclock.data.remote.api.Part +import com.example.slowclock.data.remote.api.VertexAIService +import com.example.slowclock.data.remote.api.VertexAIServiceFactory +import com.google.auth.oauth2.ServiceAccountCredentials +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import retrofit2.HttpException + +class VertexAIRepository( + private val context: Context, +) { + private val vertexAIService: VertexAIService = VertexAIServiceFactory.create() + + suspend fun generateScheduleRecommendation(prompt: String): String? = + try { + Log.d(TAG, "자격 증명 로드 시작") + // Load service account credentials from raw resource + val credentials = + withContext(Dispatchers.IO) { + val creds = + ServiceAccountCredentials + .fromStream( + context.resources.openRawResource(R.raw.service_account), + ).createScoped(listOf("https://www.googleapis.com/auth/cloud-platform")) + creds.refresh() + creds + } + + Log.d(TAG, "인증 토큰 획득 성공") + val token = "Bearer ${credentials.accessToken.tokenValue}" + + Log.d(TAG, "API 요청 시작: $prompt") + val response = + withContext(Dispatchers.IO) { + vertexAIService.generateContent( + projectId = AIConfig.PROJECT_ID, + location = AIConfig.LOCATION, + modelId = AIConfig.MODEL_ID, + request = + GenerateContentRequest( + contents = + listOf( + Content(role = "user", parts = listOf(Part(text = prompt))), + ), + generationConfig = + GenerationConfig( + maxOutputTokens = 256, + temperature = 0.2f, + ), + ), + auth = token, + ) + } + + val result = + response.candidates + .firstOrNull() + ?.content + ?.parts + ?.joinToString("\n") { it.text } + + result + } catch (e: HttpException) { + Log.e(TAG, "일정 추천 생성 실패: HTTP ${e.code()}", e) + null + } catch (e: Exception) { + Log.e(TAG, "상세 에러: ${e.stackTraceToString()}") + null + } + + companion object { + private const val TAG = "VertexAI_SLOWCLOCK" + } +} diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts new file mode 100644 index 0000000..b61463d --- /dev/null +++ b/core/model/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.example.slowclock.core.model" + compileSdk = 35 + + defaultConfig { + minSdk = 32 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + // 모델 public API 가 Firebase Timestamp/annotations 를 노출하므로 api 로 전파 + api(platform(libs.firebase.bom)) + api(libs.firebase.firestore) + implementation(libs.kotlinx.serialization.core) +} diff --git a/app/src/main/java/com/example/slowclock/data/model/FamilyGroup.kt b/core/model/src/main/java/com/example/slowclock/data/model/FamilyGroup.kt similarity index 88% rename from app/src/main/java/com/example/slowclock/data/model/FamilyGroup.kt rename to core/model/src/main/java/com/example/slowclock/data/model/FamilyGroup.kt index 9b5f1fc..75b3d30 100644 --- a/app/src/main/java/com/example/slowclock/data/model/FamilyGroup.kt +++ b/core/model/src/main/java/com/example/slowclock/data/model/FamilyGroup.kt @@ -10,5 +10,5 @@ data class FamilyGroup( val ownerUserId: String = "", val memberIds: List = emptyList(), val createdAt: Timestamp = Timestamp.now(), - val updatedAt: Timestamp = Timestamp.now() -) \ No newline at end of file + val updatedAt: Timestamp = Timestamp.now(), +) diff --git a/app/src/main/java/com/example/slowclock/data/model/InfoData.kt b/core/model/src/main/java/com/example/slowclock/data/model/InfoData.kt similarity index 77% rename from app/src/main/java/com/example/slowclock/data/model/InfoData.kt rename to core/model/src/main/java/com/example/slowclock/data/model/InfoData.kt index 3eb7ada..a8ab2bd 100644 --- a/app/src/main/java/com/example/slowclock/data/model/InfoData.kt +++ b/core/model/src/main/java/com/example/slowclock/data/model/InfoData.kt @@ -2,5 +2,5 @@ package com.example.slowclock.data.model data class InfoData( var title: String, - var infoUrl: String + var infoUrl: String, ) diff --git a/app/src/main/java/com/example/slowclock/data/model/Notification.kt b/core/model/src/main/java/com/example/slowclock/data/model/Notification.kt similarity index 88% rename from app/src/main/java/com/example/slowclock/data/model/Notification.kt rename to core/model/src/main/java/com/example/slowclock/data/model/Notification.kt index 8130b40..1e4b718 100644 --- a/app/src/main/java/com/example/slowclock/data/model/Notification.kt +++ b/core/model/src/main/java/com/example/slowclock/data/model/Notification.kt @@ -11,5 +11,5 @@ data class Notification( val message: String = "", val relatedScheduleId: String? = null, val isRead: Boolean = false, - val createdAt: Timestamp = Timestamp.now() -) \ No newline at end of file + val createdAt: Timestamp = Timestamp.now(), +) diff --git a/app/src/main/java/com/example/slowclock/data/model/Recommendation.kt b/core/model/src/main/java/com/example/slowclock/data/model/Recommendation.kt similarity index 78% rename from app/src/main/java/com/example/slowclock/data/model/Recommendation.kt rename to core/model/src/main/java/com/example/slowclock/data/model/Recommendation.kt index 2556f14..e9a9a66 100644 --- a/app/src/main/java/com/example/slowclock/data/model/Recommendation.kt +++ b/core/model/src/main/java/com/example/slowclock/data/model/Recommendation.kt @@ -3,5 +3,5 @@ package com.example.slowclock.data.model @kotlinx.serialization.Serializable data class Recommendation( val title: String, - val type: String = "일반" -) \ No newline at end of file + val type: String = "일반", +) diff --git a/app/src/main/java/com/example/slowclock/data/model/Schedule.kt b/core/model/src/main/java/com/example/slowclock/data/model/Schedule.kt similarity index 92% rename from app/src/main/java/com/example/slowclock/data/model/Schedule.kt rename to core/model/src/main/java/com/example/slowclock/data/model/Schedule.kt index 0eee885..8390210 100644 --- a/app/src/main/java/com/example/slowclock/data/model/Schedule.kt +++ b/core/model/src/main/java/com/example/slowclock/data/model/Schedule.kt @@ -18,5 +18,5 @@ data class Schedule( val recurring: Boolean = false, val recurringType: String? = null, val createdAt: Timestamp = Timestamp.now(), - val updatedAt: Timestamp = Timestamp.now() -) \ No newline at end of file + val updatedAt: Timestamp = Timestamp.now(), +) diff --git a/app/src/main/java/com/example/slowclock/data/model/User.kt b/core/model/src/main/java/com/example/slowclock/data/model/User.kt similarity index 90% rename from app/src/main/java/com/example/slowclock/data/model/User.kt rename to core/model/src/main/java/com/example/slowclock/data/model/User.kt index b1e9d71..49af763 100644 --- a/app/src/main/java/com/example/slowclock/data/model/User.kt +++ b/core/model/src/main/java/com/example/slowclock/data/model/User.kt @@ -12,5 +12,5 @@ data class User( val shareCode: String = "", // 6-character sharing code val fcmToken: String = "", // FCM 토큰 필드 추가 val createdAt: Timestamp = Timestamp.now(), - val updatedAt: Timestamp = Timestamp.now() -) \ No newline at end of file + val updatedAt: Timestamp = Timestamp.now(), +) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 0000000..a82b548 --- /dev/null +++ b/core/ui/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.example.slowclock.core.ui" + compileSdk = 35 + + defaultConfig { + minSdk = 32 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + api(project(":core:model")) + api(project(":core:common")) + + // Compose 를 api 로 노출 → :feature 모듈은 :core:ui 만 의존해도 Compose 사용 가능 + api(platform(libs.androidx.compose.bom)) + api(libs.androidx.ui) + api(libs.androidx.ui.graphics) + api(libs.androidx.ui.tooling.preview) + api(libs.androidx.material3) + api(libs.androidx.material.icons.extended) + debugImplementation(libs.androidx.ui.tooling) +} diff --git a/app/src/main/java/com/example/slowclock/ui/common/components/BottomNavigationBar.kt b/core/ui/src/main/java/com/example/slowclock/ui/common/components/BottomNavigationBar.kt similarity index 65% rename from app/src/main/java/com/example/slowclock/ui/common/components/BottomNavigationBar.kt rename to core/ui/src/main/java/com/example/slowclock/ui/common/components/BottomNavigationBar.kt index da708d5..fd8dc1e 100644 --- a/app/src/main/java/com/example/slowclock/ui/common/components/BottomNavigationBar.kt +++ b/core/ui/src/main/java/com/example/slowclock/ui/common/components/BottomNavigationBar.kt @@ -9,20 +9,28 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -data class NavItem(val route: String, val icon: ImageVector, val label: String) +data class NavItem( + val route: String, + val icon: ImageVector, + val label: String, +) @Composable -fun BottomNavigationBar(currentRoute: String, onNavigate: (String) -> Unit) { - val items = listOf( - NavItem("main", Icons.Default.Home, "메인"), - NavItem("done", Icons.Default.Check, "완료"), - NavItem("timeline", Icons.Default.DateRange, "타임라인"), - NavItem("settings", Icons.AutoMirrored.Filled.Article, "정보") - ) +fun BottomNavigationBar( + currentRoute: String, + onNavigate: (String) -> Unit, +) { + val items = + listOf( + NavItem("main", Icons.Default.Home, "메인"), + NavItem("done", Icons.Default.Check, "완료"), + NavItem("timeline", Icons.Default.DateRange, "타임라인"), + NavItem("settings", Icons.AutoMirrored.Filled.Article, "정보"), + ) NavigationBar( containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.primary + contentColor = MaterialTheme.colorScheme.primary, ) { items.forEach { item -> val selected = currentRoute == item.route @@ -33,15 +41,15 @@ fun BottomNavigationBar(currentRoute: String, onNavigate: (String) -> Unit) { Icon( item.icon, contentDescription = item.label, - tint = if (selected) MaterialTheme.colorScheme.primary else Color.Gray + tint = if (selected) MaterialTheme.colorScheme.primary else Color.Gray, ) }, label = { Text( item.label, - color = if (selected) MaterialTheme.colorScheme.primary else Color.Gray + color = if (selected) MaterialTheme.colorScheme.primary else Color.Gray, ) - } + }, ) } } diff --git a/app/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt b/core/ui/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt similarity index 63% rename from app/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt rename to core/ui/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt index 8c72a8a..9f21d47 100644 --- a/app/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt +++ b/core/ui/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt @@ -32,52 +32,55 @@ fun ErrorCard( error: AppError, canRetry: Boolean = false, onRetry: (() -> Unit)? = null, - onDismiss: (() -> Unit)? = null + onDismiss: (() -> Unit)? = null, ) { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), elevation = CardDefaults.cardElevation(defaultElevation = 3.dp), - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(16.dp), ) { Column( modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { // 에러 타입별 이모지 Text( - text = when (error) { - is AppError.NetworkError -> "📶" - is AppError.TimeoutError -> "⏱️" - is AppError.AuthError -> "🔒" - is AppError.PermissionError -> "⛔" - is AppError.InvalidDataError -> "📝" - is AppError.NotFoundError -> "🔍" - is AppError.SaveError -> "💾" - is AppError.StorageFullError -> "💽" - is AppError.GeneralError -> "⚠️" - }, - fontSize = 48.sp + text = + when (error) { + is AppError.NetworkError -> "📶" + is AppError.TimeoutError -> "⏱️" + is AppError.AuthError -> "🔒" + is AppError.PermissionError -> "⛔" + is AppError.InvalidDataError -> "📝" + is AppError.NotFoundError -> "🔍" + is AppError.SaveError -> "💾" + is AppError.StorageFullError -> "💽" + is AppError.GeneralError -> "⚠️" + }, + fontSize = 48.sp, ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = when (error) { - is AppError.NetworkError -> "연결 문제" - is AppError.TimeoutError -> "시간 초과" - is AppError.AuthError -> "로그인 필요" - is AppError.PermissionError -> "권한 없음" - is AppError.InvalidDataError -> "입력 오류" - is AppError.NotFoundError -> "찾을 수 없음" - is AppError.SaveError -> "저장 실패" - is AppError.StorageFullError -> "저장공간 부족" - is AppError.GeneralError -> "알 수 없는 오류" - }, + text = + when (error) { + is AppError.NetworkError -> "연결 문제" + is AppError.TimeoutError -> "시간 초과" + is AppError.AuthError -> "로그인 필요" + is AppError.PermissionError -> "권한 없음" + is AppError.InvalidDataError -> "입력 오류" + is AppError.NotFoundError -> "찾을 수 없음" + is AppError.SaveError -> "저장 실패" + is AppError.StorageFullError -> "저장공간 부족" + is AppError.GeneralError -> "알 수 없는 오류" + }, style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onErrorContainer + color = MaterialTheme.colorScheme.onErrorContainer, ) Spacer(modifier = Modifier.height(12.dp)) @@ -85,23 +88,23 @@ fun ErrorCard( Text( text = error.message, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onErrorContainer + color = MaterialTheme.colorScheme.onErrorContainer, ) if (canRetry && onRetry != null) { Spacer(modifier = Modifier.height(20.dp)) Row( - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { if (onDismiss != null) { OutlinedButton( onClick = onDismiss, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text( "닫기", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } } @@ -109,24 +112,25 @@ fun ErrorCard( Button( onClick = onRetry, modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), ) { Icon( Icons.Default.Refresh, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary + tint = MaterialTheme.colorScheme.onPrimary, ) Spacer(modifier = Modifier.width(8.dp)) Text( "다시 시도", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary + color = MaterialTheme.colorScheme.onPrimary, ) } } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/common/dialog/DeleteConfirmDialog.kt b/core/ui/src/main/java/com/example/slowclock/ui/common/dialog/DeleteConfirmDialog.kt similarity index 85% rename from app/src/main/java/com/example/slowclock/ui/common/dialog/DeleteConfirmDialog.kt rename to core/ui/src/main/java/com/example/slowclock/ui/common/dialog/DeleteConfirmDialog.kt index 181483c..bb0c414 100644 --- a/app/src/main/java/com/example/slowclock/ui/common/dialog/DeleteConfirmDialog.kt +++ b/core/ui/src/main/java/com/example/slowclock/ui/common/dialog/DeleteConfirmDialog.kt @@ -31,26 +31,27 @@ import com.example.slowclock.data.model.Schedule fun DeleteConfirmDialog( schedule: Schedule, onConfirm: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, ) { Dialog(onDismissRequest = onDismiss) { Card( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(20.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), ) { Column( modifier = Modifier.padding(28.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( Icons.Default.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(48.dp) + modifier = Modifier.size(48.dp), ) Spacer(modifier = Modifier.height(16.dp)) @@ -58,7 +59,7 @@ fun DeleteConfirmDialog( Text( text = "일정 삭제", style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(12.dp)) @@ -66,7 +67,7 @@ fun DeleteConfirmDialog( Text( text = "\"${schedule.title}\"를 삭제하시겠어요?", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(8.dp)) @@ -74,41 +75,42 @@ fun DeleteConfirmDialog( Text( text = "삭제한 일정은 복구할 수 없습니다.", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error + color = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.height(24.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedButton( onClick = onDismiss, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text( "취소", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } Button( onClick = onConfirm, modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), ) { Text( "삭제", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onError + color = MaterialTheme.colorScheme.onError, ) } } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/common/dialog/TimePickerDialog.kt b/core/ui/src/main/java/com/example/slowclock/ui/common/dialog/TimePickerDialog.kt similarity index 70% rename from app/src/main/java/com/example/slowclock/ui/common/dialog/TimePickerDialog.kt rename to core/ui/src/main/java/com/example/slowclock/ui/common/dialog/TimePickerDialog.kt index 7d22f05..e5ef3ef 100644 --- a/app/src/main/java/com/example/slowclock/ui/common/dialog/TimePickerDialog.kt +++ b/core/ui/src/main/java/com/example/slowclock/ui/common/dialog/TimePickerDialog.kt @@ -31,76 +31,80 @@ fun TimePickerDialog( title: String, initialTime: Calendar, onTimeSelected: (Calendar) -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, ) { - val timePickerState = rememberTimePickerState( - initialHour = initialTime.get(Calendar.HOUR_OF_DAY), - initialMinute = initialTime.get(Calendar.MINUTE) - ) + val timePickerState = + rememberTimePickerState( + initialHour = initialTime.get(Calendar.HOUR_OF_DAY), + initialMinute = initialTime.get(Calendar.MINUTE), + ) Dialog(onDismissRequest = onDismiss) { Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - shape = RoundedCornerShape(20.dp) + shape = RoundedCornerShape(20.dp), ) { Column( modifier = Modifier.padding(28.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = title, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 20.dp) + modifier = Modifier.padding(bottom = 20.dp), ) TimePicker( state = timePickerState, - colors = TimePickerDefaults.colors( - clockDialColor = MaterialTheme.colorScheme.primaryContainer, - selectorColor = MaterialTheme.colorScheme.primary - ) + colors = + TimePickerDefaults.colors( + clockDialColor = MaterialTheme.colorScheme.primaryContainer, + selectorColor = MaterialTheme.colorScheme.primary, + ), ) Spacer(modifier = Modifier.height(28.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(20.dp) + horizontalArrangement = Arrangement.spacedBy(20.dp), ) { TextButton( onClick = onDismiss, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text( "취소", color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) } TextButton( onClick = { - val selectedCalendar = Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, timePickerState.hour) - set(Calendar.MINUTE, timePickerState.minute) - set(Calendar.SECOND, 0) - } + val selectedCalendar = + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, timePickerState.hour) + set(Calendar.MINUTE, timePickerState.minute) + set(Calendar.SECOND, 0) + } onTimeSelected(selectedCalendar) }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text( "확인", color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) } } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/theme/Color.kt b/core/ui/src/main/java/com/example/slowclock/ui/theme/Color.kt similarity index 67% rename from app/src/main/java/com/example/slowclock/ui/theme/Color.kt rename to core/ui/src/main/java/com/example/slowclock/ui/theme/Color.kt index ce34a80..657ac75 100644 --- a/app/src/main/java/com/example/slowclock/ui/theme/Color.kt +++ b/core/ui/src/main/java/com/example/slowclock/ui/theme/Color.kt @@ -14,9 +14,9 @@ val Pink40 = Color(0xFF7D5260) // ============== 접근성 강화 색상 시스템 ============== // 텍스트 색상 (고대비) -val TextPrimary = Color(0xFF212121) // 검은색에 가까운 회색 -val TextSecondary = Color(0xFF424242) // 진한 회색 -val TextDisabled = Color(0xFF757575) // 비활성 텍스트 +val TextPrimary = Color(0xFF212121) // 검은색에 가까운 회색 +val TextSecondary = Color(0xFF424242) // 진한 회색 +val TextDisabled = Color(0xFF757575) // 비활성 텍스트 // 배경 색상 -val Surface = Color(0xFFFFFFFF) // 순백색 \ No newline at end of file +val Surface = Color(0xFFFFFFFF) // 순백색 diff --git a/app/src/main/java/com/example/slowclock/ui/theme/Theme.kt b/core/ui/src/main/java/com/example/slowclock/ui/theme/Theme.kt similarity index 53% rename from app/src/main/java/com/example/slowclock/ui/theme/Theme.kt rename to core/ui/src/main/java/com/example/slowclock/ui/theme/Theme.kt index bf53ce4..cd5cde6 100644 --- a/app/src/main/java/com/example/slowclock/ui/theme/Theme.kt +++ b/core/ui/src/main/java/com/example/slowclock/ui/theme/Theme.kt @@ -11,37 +11,46 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext // 간단한 다크 테마 -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) +private val DarkColorScheme = + darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + ) // 간단한 라이트 테마 -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 -) +private val LightColorScheme = + lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, + ) @Composable fun SlowClockTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = false, // 접근성을 위해 false로 설정 - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + val colorScheme = + when { + dynamicColor -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> { + DarkColorScheme + } + + else -> { + LightColorScheme + } } - darkTheme -> DarkColorScheme - else -> LightColorScheme - } MaterialTheme( colorScheme = colorScheme, typography = Typography, - content = content + content = content, ) -} \ No newline at end of file +} diff --git a/core/ui/src/main/java/com/example/slowclock/ui/theme/Type.kt b/core/ui/src/main/java/com/example/slowclock/ui/theme/Type.kt new file mode 100644 index 0000000..60125a3 --- /dev/null +++ b/core/ui/src/main/java/com/example/slowclock/ui/theme/Type.kt @@ -0,0 +1,104 @@ +// app/src/main/java/com/example/slowclock/ui/theme/Type.kt +package com.example.slowclock.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// ============== 접근성 강화 타이포그래피 ============== + +val Typography = + Typography( + // 대형 제목 (앱 이름, 주요 헤더) + headlineLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 32.sp, // 28sp → 32sp + lineHeight = 40.sp, + letterSpacing = 0.sp, + color = TextPrimary, + ), + // 중형 제목 (화면 제목, 섹션 헤더) + headlineMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, // 20sp → 24sp + lineHeight = 32.sp, + letterSpacing = 0.sp, + color = TextPrimary, + ), + // 소형 제목 (카드 제목, 그룹 헤더) + headlineSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 20.sp, // 18sp → 20sp + lineHeight = 28.sp, + letterSpacing = 0.sp, + color = TextPrimary, + ), + // 큰 본문 (주요 내용, 일정 제목) + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 20.sp, // 16sp → 20sp (크게 증가) + lineHeight = 28.sp, // 24sp → 28sp + letterSpacing = 0.5.sp, + color = TextPrimary, + ), + // 중간 본문 (설명 텍스트, 부가 정보) + bodyMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 18.sp, // 14sp → 18sp (크게 증가) + lineHeight = 26.sp, // 20sp → 26sp + letterSpacing = 0.25.sp, + color = TextSecondary, + ), + // 작은 본문 (시간, 메타데이터) + bodySmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, // 12sp → 16sp (크게 증가) + lineHeight = 24.sp, // 16sp → 24sp + letterSpacing = 0.4.sp, + color = TextSecondary, + ), + // 버튼 텍스트 (모든 버튼) + labelLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, // 14sp → 18sp + lineHeight = 24.sp, // 20sp → 24sp + letterSpacing = 0.1.sp, + color = Surface, + ), + // 작은 버튼 텍스트 + labelMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, // 12sp → 16sp + lineHeight = 20.sp, // 16sp → 20sp + letterSpacing = 0.5.sp, + color = Surface, + ), + // 아주 작은 라벨 (태그, 배지) + labelSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, // 11sp → 14sp + lineHeight = 18.sp, // 16sp → 18sp + letterSpacing = 0.5.sp, + color = TextDisabled, + ), + ) diff --git a/feature/addschedule/build.gradle.kts b/feature/addschedule/build.gradle.kts new file mode 100644 index 0000000..4bc48aa --- /dev/null +++ b/feature/addschedule/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.example.slowclock.feature.addschedule" + compileSdk = 35 + + defaultConfig { + minSdk = 32 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":core:ui")) + implementation(project(":core:data")) + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:alarm")) + + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(libs.firebase.auth) +} diff --git a/app/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleScreen.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleScreen.kt similarity index 80% rename from app/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleScreen.kt rename to feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleScreen.kt index c679052..32ffdaa 100644 --- a/app/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleScreen.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleScreen.kt @@ -33,7 +33,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.example.slowclock.ui.addschedule.components.RecommendationPlaceholder import com.example.slowclock.ui.addschedule.components.RecurringSection import com.example.slowclock.ui.addschedule.components.TimePickerSection @@ -45,8 +45,8 @@ fun AddScheduleScreen( scheduleId: String? = null, initialTitle: String? = null, onNavigateBack: (Boolean) -> Unit, - viewModel: AddScheduleViewModel = viewModel(), - onNavigateToRecommendation: () -> Unit + viewModel: AddScheduleViewModel = hiltViewModel(), + onNavigateToRecommendation: () -> Unit, ) { val scrollState = remember { ScrollState(0) } val uiState by viewModel.uiState.collectAsState() @@ -80,7 +80,7 @@ fun AddScheduleScreen( Text( text = if (isEditMode) "일정 수정" else "일정 추가", style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) }, navigationIcon = { @@ -89,50 +89,56 @@ fun AddScheduleScreen( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로가기", tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(28.dp) + modifier = Modifier.size(28.dp), ) } }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = + TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), ) }, floatingActionButton = { FloatingActionButton( onClick = { viewModel.saveSchedule(context) }, - containerColor = if (uiState.canSave) - MaterialTheme.colorScheme.secondary - else - MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.size(64.dp) + containerColor = + if (uiState.canSave) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + modifier = Modifier.size(64.dp), ) { Icon( Icons.Default.Check, contentDescription = "저장", - tint = if (uiState.canSave) - MaterialTheme.colorScheme.onSecondary - else - MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(28.dp) + tint = + if (uiState.canSave) { + MaterialTheme.colorScheme.onSecondary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(28.dp), ) } - } + }, ) { paddingValues -> Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(scrollState) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(28.dp) + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(scrollState) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(28.dp), ) { // 일정 제목 입력 (분리된 컴포넌트) TitleInputSection( title = uiState.title, description = uiState.description, onTitleChange = viewModel::updateTitle, - onDescriptionChange = viewModel::updateDescription + onDescriptionChange = viewModel::updateDescription, ) // 시간 선택 @@ -141,49 +147,50 @@ fun AddScheduleScreen( endTime = uiState.endTime, onTimeSelected = viewModel::updateTime, onEndTimeSelected = viewModel::updateEndTime, - ) + ) // 반복 일정 설정 (분리된 컴포넌트) RecurringSection( recurring = uiState.recurring, recurringType = uiState.recurringType, onRecurringChange = viewModel::updateRecurring, - onRecurringTypeChange = viewModel::updateRecurringType + onRecurringTypeChange = viewModel::updateRecurringType, ) // 추천 기능 영역 RecommendationPlaceholder( - onNavigateToRecommendation = onNavigateToRecommendation + onNavigateToRecommendation = onNavigateToRecommendation, ) // 에러 메시지 if (uiState.error != null) { Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), ) { Column(modifier = Modifier.padding(20.dp)) { Text( text = uiState.error!!.message, color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) if (uiState.canRetry) { Row( - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedButton( onClick = { viewModel.clearError() }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text("닫기") } Button( onClick = { viewModel.retryLastAction(context) }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text("다시 시도") } @@ -196,26 +203,27 @@ fun AddScheduleScreen( // 로딩 상태 if (uiState.isLoading) { Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), ) { Row( modifier = Modifier.padding(20.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { CircularProgressIndicator( modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) Text( text = "일정을 저장하는 중...", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer + color = MaterialTheme.colorScheme.onPrimaryContainer, ) } } } } } -} \ No newline at end of file +} diff --git a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt new file mode 100644 index 0000000..3e12b5d --- /dev/null +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt @@ -0,0 +1,268 @@ +package com.example.slowclock.ui.addschedule + +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.slowclock.core.alarm.ScheduleAlarmHelper +import com.example.slowclock.data.model.Schedule +import com.example.slowclock.data.remote.repository.ScheduleRepository +import com.example.slowclock.notification.requestExactAlarmPermissionIfNeeded +import com.example.slowclock.util.AppError +import com.google.firebase.Timestamp +import com.google.firebase.auth.FirebaseAuth +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Calendar +import javax.inject.Inject + +data class AddScheduleUiState( + val title: String = "", + val description: String = "", + val selectedTime: Calendar = Calendar.getInstance(), + val endTime: Calendar? = null, + val recurring: Boolean = false, + val recurringType: String = "daily", + val showTimePicker: Boolean = false, + val showEndTimePicker: Boolean = false, + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + val error: AppError? = null, + val canSave: Boolean = false, + val canRetry: Boolean = false, + val isEditMode: Boolean = false, + val editingScheduleId: String = "", + val editingSchedule: Schedule? = null, // <-- add this +) + +@HiltViewModel +class AddScheduleViewModel + @Inject + constructor( + private val scheduleRepository: ScheduleRepository, + ) : ViewModel() { + private val _uiState = MutableStateFlow(AddScheduleUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updateTitle(title: String) { + _uiState.value = + _uiState.value.copy( + title = title, + canSave = title.trim().isNotBlank(), + error = null, + ) + } + + fun updateDescription(description: String) { + _uiState.value = _uiState.value.copy(description = description) + } + + fun updateTime(time: Calendar) { + val newTime = + Calendar.getInstance().apply { + timeInMillis = time.timeInMillis + } + _uiState.value = _uiState.value.copy(selectedTime = newTime) + } + + fun updateEndTime(time: Calendar?) { + val newTime = + time?.let { + Calendar.getInstance().apply { timeInMillis = it.timeInMillis } + } + _uiState.value = _uiState.value.copy(endTime = newTime) + } + + fun updateRecurring(recurring: Boolean) { + _uiState.value = _uiState.value.copy(recurring = recurring) + } + + fun updateRecurringType(type: String) { + _uiState.value = _uiState.value.copy(recurringType = type) + } + + fun showTimePicker(show: Boolean) { + _uiState.value = _uiState.value.copy(showTimePicker = show) + } + + fun showEndTimePicker(show: Boolean) { + _uiState.value = _uiState.value.copy(showEndTimePicker = show) + } + + fun loadScheduleForEdit(scheduleId: String) { + viewModelScope.launch { + _uiState.value = + _uiState.value.copy( + isLoading = true, + error = null, + isEditMode = true, + editingScheduleId = scheduleId, + ) + + when (val result = scheduleRepository.getScheduleById(scheduleId)) { + is ScheduleRepository.ScheduleResult.Success -> { + val schedule = result.data + val startCal = + Calendar.getInstance().apply { + time = schedule.startTime.toDate() + } + val endCal = + schedule.endTime?.let { endTime -> + Calendar.getInstance().apply { + time = endTime.toDate() + } + } + + _uiState.value = + _uiState.value.copy( + title = schedule.title, + description = schedule.description, + selectedTime = startCal, + endTime = endCal, + recurring = schedule.recurring, + recurringType = schedule.recurringType ?: "daily", + isLoading = false, + canSave = schedule.title.isNotBlank(), + editingSchedule = schedule, // <-- store full schedule + ) + } + + is ScheduleRepository.ScheduleResult.Error -> { + _uiState.value = + _uiState.value.copy( + isLoading = false, + error = result.error, + canRetry = true, + ) + } + } + } + } + + fun saveSchedule(context: Context) { + val currentTitle = _uiState.value.title.trim() + + // Validation (outside coroutine) + when { + currentTitle.isBlank() -> { + _uiState.value = + _uiState.value.copy( + error = AppError.GeneralError("할 일을 입력해주세요"), + ) + return + } + + currentTitle.length > 100 -> { + _uiState.value = + _uiState.value.copy( + error = AppError.GeneralError("제목이 너무 깁니다 (최대 100자)"), + ) + return + } + + _uiState.value.endTime?.let { end -> + end.timeInMillis <= _uiState.value.selectedTime.timeInMillis + } == true -> { + _uiState.value = + _uiState.value.copy( + error = AppError.GeneralError("종료 시간은 시작 시간보다 늦어야 합니다"), + ) + return + } + } + + viewModelScope.launch { + _uiState.value = + _uiState.value.copy( + isLoading = true, + error = null, + canRetry = false, + ) + + try { + Log.d("AddSchedule", "일정 저장 시작: $currentTitle") + Log.d("AddSchedule", "현재 사용자: ${FirebaseAuth.getInstance().currentUser?.uid}") + + val schedule = + if (_uiState.value.isEditMode) { + // Use original schedule as base, update only changed fields + val original = _uiState.value.editingSchedule!! + original.copy( + title = currentTitle, + description = _uiState.value.description.trim(), + startTime = Timestamp(_uiState.value.selectedTime.time), + endTime = _uiState.value.endTime?.let { Timestamp(it.time) }, + recurring = _uiState.value.recurring, + recurringType = if (_uiState.value.recurring) _uiState.value.recurringType else null, + ) + } else { + Schedule( + title = currentTitle, + description = _uiState.value.description.trim(), + startTime = Timestamp(_uiState.value.selectedTime.time), + endTime = _uiState.value.endTime?.let { Timestamp(it.time) }, + recurring = _uiState.value.recurring, + recurringType = if (_uiState.value.recurring) _uiState.value.recurringType else null, + ) + } + + val result = + if (_uiState.value.isEditMode) { + Log.d("AddSchedule", "일정 수정 모드") + scheduleRepository.updateSchedule(schedule) + } else { + Log.d("AddSchedule", "일정 추가 모드") + scheduleRepository.addSchedule(schedule) + } + + when (result) { + is ScheduleRepository.ScheduleResult.Success -> { + Log.d("AddSchedule", "저장 성공") + requestExactAlarmPermissionIfNeeded(context) + try { + ScheduleAlarmHelper.scheduleAlarm(context, schedule) + Log.d("AddSchedule", "알람 예약 성공") + } catch (e: Exception) { + Log.e("AddSchedule", "알람 예약 실패", e) + } + _uiState.value = + _uiState.value.copy( + isLoading = false, + isSuccess = true, + ) + } + + is ScheduleRepository.ScheduleResult.Error -> { + Log.e("AddSchedule", "저장 실패: ${result.error.message}") + _uiState.value = + _uiState.value.copy( + isLoading = false, + error = result.error, + canRetry = true, + ) + } + } + } catch (e: Exception) { + Log.e("AddSchedule", "예상치 못한 저장 실패", e) + _uiState.value = + _uiState.value.copy( + isLoading = false, + error = AppError.GeneralError("일정 저장 중 문제가 발생했습니다"), + canRetry = true, + ) + } + } + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null, canRetry = false) + } + + fun retryLastAction(context: Context) { + clearError() + saveSchedule(context) + } + } diff --git a/app/src/main/java/com/example/slowclock/ui/addschedule/components/QuickTimeSelector.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/QuickTimeSelector.kt similarity index 64% rename from app/src/main/java/com/example/slowclock/ui/addschedule/components/QuickTimeSelector.kt rename to feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/QuickTimeSelector.kt index f86bcd5..5350b22 100644 --- a/app/src/main/java/com/example/slowclock/ui/addschedule/components/QuickTimeSelector.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/QuickTimeSelector.kt @@ -18,9 +18,7 @@ import androidx.compose.ui.unit.dp import java.util.Calendar @Composable -fun QuickTimeSelector( - onTimeSelected: (Calendar) -> Unit -) { +fun QuickTimeSelector(onTimeSelected: (Calendar) -> Unit) { val quickTimes = remember { getQuickTimeOptions() } Column { @@ -28,22 +26,22 @@ fun QuickTimeSelector( text = "빠른 선택", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 12.dp) + modifier = Modifier.padding(bottom = 12.dp), ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { quickTimes.take(3).forEach { (label, calendar) -> OutlinedButton( onClick = { onTimeSelected(calendar) }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text( label, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } } @@ -53,17 +51,17 @@ fun QuickTimeSelector( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { quickTimes.drop(3).forEach { (label, calendar) -> OutlinedButton( onClick = { onTimeSelected(calendar) }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text( label, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } } @@ -71,29 +69,33 @@ fun QuickTimeSelector( } } -private fun getQuickTimeOptions(): List> { - return listOf( +private fun getQuickTimeOptions(): List> = + listOf( "지금" to Calendar.getInstance(), - "30분 후" to Calendar.getInstance().apply { - add(Calendar.MINUTE, 30) - }, - "1시간 후" to Calendar.getInstance().apply { - add(Calendar.HOUR, 1) - }, - "오후 2시" to Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, 14) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - }, - "오후 6시" to Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, 18) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - }, - "오후 8시" to Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, 20) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - } + "30분 후" to + Calendar.getInstance().apply { + add(Calendar.MINUTE, 30) + }, + "1시간 후" to + Calendar.getInstance().apply { + add(Calendar.HOUR, 1) + }, + "오후 2시" to + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 14) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + }, + "오후 6시" to + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 18) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + }, + "오후 8시" to + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 20) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + }, ) -} \ No newline at end of file diff --git a/app/src/main/java/com/example/slowclock/ui/addschedule/components/RecommendationPlaceholder.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/RecommendationPlaceholder.kt similarity index 68% rename from app/src/main/java/com/example/slowclock/ui/addschedule/components/RecommendationPlaceholder.kt rename to feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/RecommendationPlaceholder.kt index 8792893..04d2187 100644 --- a/app/src/main/java/com/example/slowclock/ui/addschedule/components/RecommendationPlaceholder.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/RecommendationPlaceholder.kt @@ -11,35 +11,36 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable -fun RecommendationPlaceholder( - onNavigateToRecommendation: () -> Unit -) { +fun RecommendationPlaceholder(onNavigateToRecommendation: () -> Unit) { Card( - modifier = Modifier - .fillMaxWidth() - .clickable { onNavigateToRecommendation() }, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + modifier = + Modifier + .fillMaxWidth() + .clickable { onNavigateToRecommendation() }, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), ) { Box( - modifier = Modifier - .fillMaxWidth() - .height(140.dp) - .padding(24.dp), - contentAlignment = Alignment.Center + modifier = + Modifier + .fillMaxWidth() + .height(140.dp) + .padding(24.dp), + contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = "🔮 일정 추천해드릴까요?", style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onTertiaryContainer + color = MaterialTheme.colorScheme.onTertiaryContainer, ) Text( text = "터치하면 추천 기능으로 이동합니다", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onTertiaryContainer + color = MaterialTheme.colorScheme.onTertiaryContainer, ) } } diff --git a/app/src/main/java/com/example/slowclock/ui/addschedule/components/RecurringSection.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/RecurringSection.kt similarity index 81% rename from app/src/main/java/com/example/slowclock/ui/addschedule/components/RecurringSection.kt rename to feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/RecurringSection.kt index cec3a65..9a6d479 100644 --- a/app/src/main/java/com/example/slowclock/ui/addschedule/components/RecurringSection.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/RecurringSection.kt @@ -32,34 +32,34 @@ fun RecurringSection( recurring: Boolean, recurringType: String, onRecurringChange: (Boolean) -> Unit, - onRecurringTypeChange: (String) -> Unit + onRecurringTypeChange: (String) -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 3.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 3.dp), ) { Column(modifier = Modifier.padding(24.dp)) { Text( text = "반복 설정", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(bottom = 16.dp), ) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Checkbox( checked = recurring, - onCheckedChange = onRecurringChange + onCheckedChange = onRecurringChange, ) Text( text = "반복 일정으로 설정", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(start = 12.dp) + modifier = Modifier.padding(start = 12.dp), ) } @@ -67,15 +67,16 @@ fun RecurringSection( Spacer(modifier = Modifier.height(16.dp)) var expanded by remember { mutableStateOf(false) } - val recurringOptions = listOf( - "daily" to "매일", - "weekly" to "매주", - "monthly" to "매월" - ) + val recurringOptions = + listOf( + "daily" to "매일", + "weekly" to "매주", + "monthly" to "매월", + ) ExposedDropdownMenuBox( expanded = expanded, - onExpandedChange = { expanded = !expanded } + onExpandedChange = { expanded = !expanded }, ) { OutlinedTextField( value = recurringOptions.find { it.first == recurringType }?.second ?: "매일", @@ -84,32 +85,33 @@ fun RecurringSection( label = { Text( "반복 주기", - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier - .menuAnchor(MenuAnchorType.PrimaryNotEditable) - .fillMaxWidth(), - textStyle = MaterialTheme.typography.bodyLarge + modifier = + Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + textStyle = MaterialTheme.typography.bodyLarge, ) ExposedDropdownMenu( expanded = expanded, - onDismissRequest = { expanded = false } + onDismissRequest = { expanded = false }, ) { recurringOptions.forEach { (value, label) -> DropdownMenuItem( text = { Text( label, - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) }, onClick = { onRecurringTypeChange(value) expanded = false - } + }, ) } } @@ -117,4 +119,4 @@ fun RecurringSection( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/addschedule/components/TimePickerSection.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TimePickerSection.kt similarity index 92% rename from app/src/main/java/com/example/slowclock/ui/addschedule/components/TimePickerSection.kt rename to feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TimePickerSection.kt index 7a2a276..0c5e641 100644 --- a/app/src/main/java/com/example/slowclock/ui/addschedule/components/TimePickerSection.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TimePickerSection.kt @@ -39,7 +39,7 @@ fun TimePickerSection( selectedTime: Calendar, endTime: Calendar?, onTimeSelected: (Calendar) -> Unit, - onEndTimeSelected: (Calendar?) -> Unit + onEndTimeSelected: (Calendar?) -> Unit, ) { val timeFormat = SimpleDateFormat("a h:mm", Locale.KOREAN) @@ -51,10 +51,11 @@ fun TimePickerSection( var endMinute by remember { mutableStateOf(endTime?.get(Calendar.MINUTE)?.toString() ?: "") } Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { Text("시간 설정", style = MaterialTheme.typography.titleLarge) @@ -62,7 +63,7 @@ fun TimePickerSection( Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Column(modifier = Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -84,7 +85,7 @@ fun TimePickerSection( modifier = Modifier.weight(1f), textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), ) OutlinedTextField( @@ -97,7 +98,7 @@ fun TimePickerSection( modifier = Modifier.weight(1f), textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), ) } } @@ -107,7 +108,7 @@ fun TimePickerSection( Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Column(modifier = Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -129,7 +130,7 @@ fun TimePickerSection( modifier = Modifier.weight(1f), textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), ) OutlinedTextField( @@ -142,17 +143,19 @@ fun TimePickerSection( modifier = Modifier.weight(1f), textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), ) } } } } - } -fun updateCalendarTime(hour: String, minute: String): Calendar? { - return try { +fun updateCalendarTime( + hour: String, + minute: String, +): Calendar? = + try { val h = hour.toInt() val m = minute.toInt() if (h in 0..23 && m in 0..59) { @@ -161,8 +164,9 @@ fun updateCalendarTime(hour: String, minute: String): Calendar? { set(Calendar.MINUTE, m) set(Calendar.SECOND, 0) } - } else null + } else { + null + } } catch (e: NumberFormatException) { null } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/slowclock/ui/addschedule/components/TitleInputSection.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TitleInputSection.kt similarity index 81% rename from app/src/main/java/com/example/slowclock/ui/addschedule/components/TitleInputSection.kt rename to feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TitleInputSection.kt index 40fc17d..6ec684a 100644 --- a/app/src/main/java/com/example/slowclock/ui/addschedule/components/TitleInputSection.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TitleInputSection.kt @@ -19,19 +19,19 @@ fun TitleInputSection( title: String, description: String, onTitleChange: (String) -> Unit, - onDescriptionChange: (String) -> Unit + onDescriptionChange: (String) -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 3.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 3.dp), ) { Column(modifier = Modifier.padding(24.dp)) { Text( text = "할 일", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(bottom = 16.dp), ) OutlinedTextField( value = title, @@ -40,13 +40,14 @@ fun TitleInputSection( Text( "무엇을 할까요?", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) }, modifier = Modifier.fillMaxWidth(), - textStyle = MaterialTheme.typography.bodyLarge.copy( - color = MaterialTheme.colorScheme.onSurface - ) + textStyle = + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), ) Spacer(modifier = Modifier.height(20.dp)) @@ -55,7 +56,7 @@ fun TitleInputSection( text = "상세 내용 (선택사항)", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 12.dp) + modifier = Modifier.padding(bottom = 12.dp), ) OutlinedTextField( value = description, @@ -64,15 +65,16 @@ fun TitleInputSection( Text( "자세한 내용을 입력하세요", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) }, modifier = Modifier.fillMaxWidth(), maxLines = 3, - textStyle = MaterialTheme.typography.bodyLarge.copy( - color = MaterialTheme.colorScheme.onSurface - ) + textStyle = + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), ) } } -} \ No newline at end of file +} diff --git a/feature/familygroup/build.gradle.kts b/feature/familygroup/build.gradle.kts new file mode 100644 index 0000000..eb6e3c1 --- /dev/null +++ b/feature/familygroup/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.example.slowclock.feature.familygroup" + compileSdk = 35 + defaultConfig { minSdk = 32 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { jvmTarget = "11" } + buildFeatures { compose = true } +} + +dependencies { + implementation(project(":core:ui")) + implementation(project(":core:data")) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) +} diff --git a/app/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupManageScreen.kt b/feature/familygroup/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupManageScreen.kt similarity index 92% rename from app/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupManageScreen.kt rename to feature/familygroup/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupManageScreen.kt index 5d19f35..772ac7f 100644 --- a/app/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupManageScreen.kt +++ b/feature/familygroup/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupManageScreen.kt @@ -1,6 +1,5 @@ package com.example.slowclock.ui.familygroup -import androidx.lifecycle.viewmodel.compose.viewModel import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,6 +12,7 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -22,10 +22,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.runtime.collectAsState +import androidx.hilt.navigation.compose.hiltViewModel @Composable -fun FamilyGroupManageScreen(viewModel: FamilyGroupViewModel = viewModel()) { +fun FamilyGroupManageScreen(viewModel: FamilyGroupViewModel = hiltViewModel()) { val context = LocalContext.current var groupId by remember { mutableStateOf("") } var isGuardian by remember { mutableStateOf(false) } @@ -42,7 +42,7 @@ fun FamilyGroupManageScreen(viewModel: FamilyGroupViewModel = viewModel()) { value = groupId, onValueChange = { groupId = it }, label = { Text("그룹 ID 입력") }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Button(onClick = { viewModel.joinGroup(groupId) @@ -60,4 +60,3 @@ fun FamilyGroupManageScreen(viewModel: FamilyGroupViewModel = viewModel()) { Text(text = statusMessage, modifier = Modifier.padding(top = 8.dp)) } } - diff --git a/feature/familygroup/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupViewModel.kt b/feature/familygroup/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupViewModel.kt new file mode 100644 index 0000000..3940f32 --- /dev/null +++ b/feature/familygroup/src/main/java/com/example/slowclock/ui/familygroup/FamilyGroupViewModel.kt @@ -0,0 +1,34 @@ +package com.example.slowclock.ui.familygroup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.slowclock.data.remote.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FamilyGroupViewModel + @Inject + constructor( + private val userRepository: UserRepository, + ) : ViewModel() { + private val _statusMessage = MutableStateFlow("") + val statusMessage: StateFlow = _statusMessage + + fun createGroup() { + viewModelScope.launch { + val result = userRepository.createOrJoinGroup() + _statusMessage.value = if (result) "새 그룹 생성 완료!" else "그룹 생성 실패" + } + } + + fun joinGroup(inputGroupId: String) { + viewModelScope.launch { + val result = userRepository.createOrJoinGroup(inputGroupId) + _statusMessage.value = if (result) "그룹 참가 완료!" else "그룹 참가 실패" + } + } + } diff --git a/feature/information/build.gradle.kts b/feature/information/build.gradle.kts new file mode 100644 index 0000000..3654ce1 --- /dev/null +++ b/feature/information/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.example.slowclock.feature.information" + compileSdk = 35 + defaultConfig { minSdk = 32 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { jvmTarget = "11" } + buildFeatures { compose = true } +} + +dependencies { + implementation(project(":core:ui")) + implementation(project(":core:model")) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation("org.jsoup:jsoup:1.17.2") + implementation(libs.androidx.material) // Material2 pull-refresh (ExperimentalMaterialApi) +} diff --git a/feature/information/src/main/java/com/example/slowclock/ui/information/InfoViewModel.kt b/feature/information/src/main/java/com/example/slowclock/ui/information/InfoViewModel.kt new file mode 100644 index 0000000..0562960 --- /dev/null +++ b/feature/information/src/main/java/com/example/slowclock/ui/information/InfoViewModel.kt @@ -0,0 +1,69 @@ +package com.example.slowclock.ui.information + +import android.util.Log +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.slowclock.data.model.InfoData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jsoup.Jsoup +import javax.inject.Inject + +@HiltViewModel +class InfoViewModel + @Inject + constructor() : ViewModel() { + private val _infoList = mutableStateListOf() + val infoList = _infoList + + private val _isLoading = mutableStateOf(false) + val isLoading = _isLoading + + fun fetchInfo() { + _isLoading.value = true + viewModelScope.launch { + try { + val fetchedInfo = getInfo() + _infoList.clear() + _infoList.addAll(fetchedInfo) + } catch (e: Exception) { + Log.e("Jsoup", "Error in fetch", e) + } finally { + _isLoading.value = false // 이게 반드시 필요함!! + } + } + } + } + +private suspend fun getInfo(): List = + withContext(Dispatchers.IO) { + val baseUrl = "https://www.medicaltimes.com" + val doc = + Jsoup + .connect("$baseUrl/Main/News/List.html?MainCate=6&SubCate=79") + .userAgent( + "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36", + ).referrer("https://www.google.com") + .timeout(10000) + .get() + + val articles = doc.select("article.newsList_cont") + articles.mapNotNull { article -> + val title = article.selectFirst("h4.headLine")?.text()?.trim() + val relativeUrl = article.selectFirst("a")?.attr("href") + val fullUrl = if (!relativeUrl.isNullOrBlank()) "$baseUrl$relativeUrl" else null + + if (!title.isNullOrBlank() && !fullUrl.isNullOrBlank()) { + InfoData( + title = title, + infoUrl = fullUrl, + ) + } else { + null + } + } + } diff --git a/app/src/main/java/com/example/slowclock/ui/information/InformationScreen.kt b/feature/information/src/main/java/com/example/slowclock/ui/information/InformationScreen.kt similarity index 68% rename from app/src/main/java/com/example/slowclock/ui/information/InformationScreen.kt rename to feature/information/src/main/java/com/example/slowclock/ui/information/InformationScreen.kt index 92ac86f..fcabdfe 100644 --- a/app/src/main/java/com/example/slowclock/ui/information/InformationScreen.kt +++ b/feature/information/src/main/java/com/example/slowclock/ui/information/InformationScreen.kt @@ -7,34 +7,35 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel import com.example.slowclock.ui.information.component.InfoList - @OptIn(ExperimentalMaterialApi::class) @Composable -fun InformationScreen(infoViewModel: InfoViewModel = viewModel()) { +fun InformationScreen(infoViewModel: InfoViewModel = hiltViewModel()) { val infoList = infoViewModel.infoList val isLoading = infoViewModel.isLoading.value - val pullRefreshState = rememberPullRefreshState( - refreshing = isLoading, - onRefresh = {infoViewModel.fetchInfo()} - ) + val pullRefreshState = + rememberPullRefreshState( + refreshing = isLoading, + onRefresh = { infoViewModel.fetchInfo() }, + ) LaunchedEffect(Unit) { infoViewModel.fetchInfo() } Box( - modifier = Modifier - .fillMaxWidth() - ){ + modifier = + Modifier + .fillMaxWidth(), + ) { InfoList(list = infoList) PullRefreshIndicator( refreshing = isLoading, state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter) + modifier = Modifier.align(Alignment.TopCenter), ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/information/component/InformationCard.kt b/feature/information/src/main/java/com/example/slowclock/ui/information/component/InformationCard.kt similarity index 75% rename from app/src/main/java/com/example/slowclock/ui/information/component/InformationCard.kt rename to feature/information/src/main/java/com/example/slowclock/ui/information/component/InformationCard.kt index a67ba46..bc45873 100644 --- a/app/src/main/java/com/example/slowclock/ui/information/component/InformationCard.kt +++ b/feature/information/src/main/java/com/example/slowclock/ui/information/component/InformationCard.kt @@ -22,26 +22,28 @@ import androidx.core.net.toUri import com.example.slowclock.data.model.InfoData @Composable -fun InfoItem(info: InfoData){ +fun InfoItem(info: InfoData) { val context = LocalContext.current Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .clickable { - val intent = Intent(Intent.ACTION_VIEW, info.infoUrl.toUri()) - context.startActivity(intent) - }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clickable { + val intent = Intent(Intent.ACTION_VIEW, info.infoUrl.toUri()) + context.startActivity(intent) + }, shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color(0xFF1A73E8)), colors = CardDefaults.cardColors(containerColor = Color.White), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), ) { Text( text = info.title, fontSize = 18.sp, - modifier = Modifier - .padding(16.dp) + modifier = + Modifier + .padding(16.dp), ) } } @@ -50,7 +52,7 @@ fun InfoItem(info: InfoData){ fun InfoList(list: List) { LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp) + contentPadding = PaddingValues(vertical = 8.dp), ) { items(list.size) { index -> InfoItem(info = list[index]) diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts new file mode 100644 index 0000000..2054562 --- /dev/null +++ b/feature/main/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.example.slowclock.feature.main" + compileSdk = 35 + + defaultConfig { + minSdk = 32 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":core:ui")) + implementation(project(":core:data")) + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:alarm")) + + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // MainViewModel(God-VM)이 직접 사용 — #26/#27 에서 정리 예정 + implementation(libs.firebase.auth) + implementation(libs.firebase.messaging) +} diff --git a/app/src/main/java/com/example/slowclock/ui/done/DoneScreen.kt b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneScreen.kt similarity index 78% rename from app/src/main/java/com/example/slowclock/ui/done/DoneScreen.kt rename to feature/main/src/main/java/com/example/slowclock/ui/done/DoneScreen.kt index d2ea712..920e1a9 100644 --- a/app/src/main/java/com/example/slowclock/ui/done/DoneScreen.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Notifications @@ -37,13 +39,9 @@ import com.example.slowclock.ui.main.MainViewModel import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll @Composable -fun DoneScreen( - mainViewModel: MainViewModel -) { +fun DoneScreen(mainViewModel: MainViewModel) { val uiState by mainViewModel.uiState.collectAsState() val completed = uiState.todaySchedules.filter { it.completed } @@ -53,25 +51,27 @@ fun DoneScreen( val timeFormatter = SimpleDateFormat("a h:mm", Locale.KOREAN) Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()) + modifier = + Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), ) { Text( text = "오늘의 일정", style = MaterialTheme.typography.headlineSmall, color = Color(0xFF3A5CCC), - modifier = Modifier.align(Alignment.CenterHorizontally) + modifier = Modifier.align(Alignment.CenterHorizontally), ) Text( text = formatter.format(Date()), fontSize = 16.sp, color = Color.DarkGray, - modifier = Modifier - .padding(bottom = 16.dp) - .align(Alignment.CenterHorizontally), - textAlign = TextAlign.Center + modifier = + Modifier + .padding(bottom = 16.dp) + .align(Alignment.CenterHorizontally), + textAlign = TextAlign.Center, ) if (completed.isNotEmpty()) { @@ -83,7 +83,7 @@ fun DoneScreen( completed = true, onClick = { schedule -> mainViewModel.toggleScheduleComplete(schedule.id) - } + }, ) } } @@ -98,7 +98,7 @@ fun DoneScreen( completed = false, onClick = { schedule -> mainViewModel.toggleScheduleComplete(schedule.id) - } + }, ) } } @@ -109,30 +109,40 @@ fun DoneScreen( text = "오늘 ${completed.size}개의 일정을 완료했어요!", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.align(Alignment.CenterHorizontally), - color = Color.DarkGray + color = Color.DarkGray, ) // 🔥 수정된 LinearProgressIndicator LinearProgressIndicator( progress = { - if ((completed.size + remaining.size) == 0) 0f - else completed.size.toFloat() / (completed.size + remaining.size) + if ((completed.size + remaining.size) == 0) { + 0f + } else { + completed.size.toFloat() / (completed.size + remaining.size) + } }, color = Color(0xFF00A152), - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth() + modifier = + Modifier + .padding(top = 8.dp) + .fillMaxWidth(), ) } } @Composable -fun Section(title: String, icon: ImageVector, color: Color, content: @Composable ColumnScope.() -> Unit) { +fun Section( + title: String, + icon: ImageVector, + color: Color, + content: @Composable ColumnScope.() -> Unit, +) { Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FB)) + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FB)), ) { Column(modifier = Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -151,37 +161,39 @@ fun ScheduleCard( schedule: Schedule, timeFormatter: SimpleDateFormat, completed: Boolean, - onClick: (Schedule) -> Unit + onClick: (Schedule) -> Unit, ) { val cardColor = if (completed) Color(0xFFE0F8E0) else Color(0xFFEAF1FF) Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable { onClick(schedule) }, - colors = CardDefaults.cardColors(containerColor = cardColor) + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { onClick(schedule) }, + colors = CardDefaults.cardColors(containerColor = cardColor), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Checkbox( checked = completed, - onCheckedChange = { onClick(schedule) } + onCheckedChange = { onClick(schedule) }, ) Spacer(modifier = Modifier.width(8.dp)) Column { Text(text = schedule.title, fontWeight = FontWeight.Bold) Text( text = timeFormatter.format(schedule.startTime.toDate()), - fontSize = 12.sp + fontSize = 12.sp, ) } } @@ -190,9 +202,9 @@ fun ScheduleCard( Text( text = "완료", color = Color(0xFF00A152), - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/main/MainScreen.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainScreen.kt similarity index 87% rename from app/src/main/java/com/example/slowclock/ui/main/MainScreen.kt rename to feature/main/src/main/java/com/example/slowclock/ui/main/MainScreen.kt index 0116af8..1db8d64 100644 --- a/app/src/main/java/com/example/slowclock/ui/main/MainScreen.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainScreen.kt @@ -46,9 +46,9 @@ import com.example.slowclock.ui.main.components.EmptyStateCard import com.example.slowclock.ui.main.components.ScheduleDetailDialog import com.example.slowclock.ui.main.components.SharedRemindersSection import com.example.slowclock.ui.main.components.TodayScheduleSection -import java.util.Calendar import com.google.firebase.auth.FirebaseAuth import java.text.SimpleDateFormat +import java.util.Calendar import java.util.Date import java.util.Locale @@ -61,7 +61,7 @@ fun MainScreen( onEditSchedule: (String) -> Unit = {}, onNavigateToProfile: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, - onRefreshHandled: () -> Unit = {} + onRefreshHandled: () -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsState() val dateFormat = SimpleDateFormat("yyyy년 M월 d일 EEEE", Locale.KOREAN) @@ -97,11 +97,10 @@ fun MainScreen( onDelete = { viewModel.hideScheduleDetail() viewModel.showDeleteConfirmDialog(schedule.id) - } + }, ) } - // 삭제 확인 다이얼로그 (이것도 필요함) if (uiState.showDeleteConfirmDialog && uiState.scheduleToDelete != null) { DeleteConfirmDialog( @@ -109,7 +108,7 @@ fun MainScreen( onConfirm = { viewModel.deleteSchedule(uiState.scheduleToDelete!!.id) }, - onDismiss = { viewModel.hideDeleteConfirmDialog() } + onDismiss = { viewModel.hideDeleteConfirmDialog() }, ) } @@ -125,17 +124,17 @@ fun MainScreen( CenterAlignedTopAppBar( title = { Column( - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = "느린시계", style = MaterialTheme.typography.headlineLarge, // fontSize 대신 style 사용 - color = MaterialTheme.colorScheme.primary // 하드코딩 색상 제거 + color = MaterialTheme.colorScheme.primary, // 하드코딩 색상 제거 ) Text( text = dateFormat.format(Date()), style = MaterialTheme.typography.bodyLarge, // fontSize 대신 style 사용 - color = MaterialTheme.colorScheme.onSurfaceVariant // 하드코딩 색상 제거 + color = MaterialTheme.colorScheme.onSurfaceVariant, // 하드코딩 색상 제거 ) } }, @@ -143,62 +142,65 @@ fun MainScreen( // 프로필 버튼 (더 크게) IconButton( onClick = onNavigateToProfile, - modifier = Modifier.size(56.dp) // 48dp → 56dp + modifier = Modifier.size(56.dp), // 48dp → 56dp ) { Icon( Icons.Default.Person, contentDescription = "내 정보", tint = MaterialTheme.colorScheme.primary, // 하드코딩 색상 제거 - modifier = Modifier.size(32.dp) // 28dp → 32dp + modifier = Modifier.size(32.dp), // 28dp → 32dp ) } // 설정(share code) 버튼 IconButton( onClick = onNavigateToSettings, - modifier = Modifier.size(56.dp) + modifier = Modifier.size(56.dp), ) { Icon( Icons.Default.Settings, contentDescription = "설정", tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) } }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = MaterialTheme.colorScheme.surface // 하드코딩 색상 제거 - ) + colors = + TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, // 하드코딩 색상 제거 + ), ) }, floatingActionButton = { FloatingActionButton( onClick = onAddSchedule, containerColor = MaterialTheme.colorScheme.primary, // 하드코딩 색상 제거 - modifier = Modifier.size(72.dp) // 64dp → 72dp + modifier = Modifier.size(72.dp), // 64dp → 72dp ) { Icon( Icons.Default.Add, contentDescription = "일정 추가", tint = MaterialTheme.colorScheme.onPrimary, // 하드코딩 색상 제거 - modifier = Modifier.size(36.dp) // 32dp → 36dp + modifier = Modifier.size(36.dp), // 32dp → 36dp ) } - } + }, ) { paddingValues -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .background(MaterialTheme.colorScheme.background), // 하드코딩 색상 제거 + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background), + // 하드코딩 색상 제거 contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp), // 더 큰 패딩 - verticalArrangement = Arrangement.spacedBy(24.dp) // 더 큰 간격 + verticalArrangement = Arrangement.spacedBy(24.dp), // 더 큰 간격 ) { // 🟡 지금 할 일 uiState.currentSchedule?.let { schedule -> item { CurrentTaskSection( schedule = schedule, - onShowDetail = { viewModel.showScheduleDetail(schedule.id) } + onShowDetail = { viewModel.showScheduleDetail(schedule.id) }, ) } } @@ -221,7 +223,7 @@ fun MainScreen( timeFormat = timeFormat, onToggleComplete = { scheduleId -> viewModel.toggleSharedReminderComplete(scheduleId, context) - } + }, ) } } @@ -240,10 +242,10 @@ fun MainScreen( error = uiState.error!!, canRetry = uiState.canRetry, onRetry = { viewModel.retryLastAction() }, - onDismiss = { viewModel.clearError() } + onDismiss = { viewModel.clearError() }, ) } } } } -} \ No newline at end of file +} diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt new file mode 100644 index 0000000..9243bda --- /dev/null +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt @@ -0,0 +1,500 @@ +// app/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt +package com.example.slowclock.ui.main + +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.slowclock.data.FirestoreDB +import com.example.slowclock.data.model.Schedule +import com.example.slowclock.data.model.User +import com.example.slowclock.data.remote.repository.ScheduleRepository +import com.example.slowclock.util.AppError +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import java.util.Calendar +import javax.inject.Inject + +data class MainUiState( + val todaySchedules: List = emptyList(), + val sharedReminders: List = emptyList(), // 공유 일정 + val currentSchedule: Schedule? = null, + val completedCount: Int = 0, + val totalCount: Int = 0, + val isLoading: Boolean = false, + val error: AppError? = null, // String → AppError 변경 + val selectedScheduleForDetail: Schedule? = null, + val canRetry: Boolean = false, // 재시도 가능 여부 추가 + val showDeleteConfirmDialog: Boolean = false, + val scheduleToDelete: Schedule? = null, + val sharedReminderOwners: Map = emptyMap(), // userId -> name +) + +@HiltViewModel +class MainViewModel + @Inject + constructor( + private val scheduleRepository: ScheduleRepository, + ) : ViewModel() { + private var sharedRemindersJob: Job? = null + + private val _uiState = MutableStateFlow(MainUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadSchedules(Calendar.getInstance()) + } + + fun loadSchedules(calendar: Calendar) { + val scheduleDate = calendar.clone() as Calendar + scheduleDate.set(Calendar.HOUR_OF_DAY, 0) + scheduleDate.set(Calendar.MINUTE, 0) + scheduleDate.set(Calendar.SECOND, 0) + scheduleDate.set(Calendar.MILLISECOND, 0) + viewModelScope.launch { + _uiState.value = + _uiState.value.copy( + isLoading = true, + error = null, + canRetry = false, + ) + + try { + when (val result = scheduleRepository.getSchedulesForDate(scheduleDate)) { + is ScheduleRepository.ScheduleResult.Success -> { + val schedules = result.data + val currentTime = System.currentTimeMillis() + + val currentSchedule = + schedules + .filter { !it.completed } + .let { incompleteSchedules -> + // 1단계: 현재 진행 중인 일정들 + val ongoingSchedules = + incompleteSchedules.filter { schedule -> + val startTime = schedule.startTime.toDate().time + val endTime = + schedule.endTime?.toDate()?.time + ?: (startTime + 60 * 60 * 1000) + currentTime >= startTime && currentTime <= endTime + } + + if (ongoingSchedules.isNotEmpty()) { + // 진행 중: 끝나는 시간 빠른 순 + ongoingSchedules.minByOrNull { schedule -> + schedule.endTime?.toDate()?.time + ?: (schedule.startTime.toDate().time + 60 * 60 * 1000) + } + } else { + // 진행 중 없음: 시작 시간 빠른 순 → 끝나는 시간 빠른 순 + incompleteSchedules + .filter { it.startTime.toDate().time > currentTime } + .sortedWith( + compareBy { it.startTime.toDate().time } + .thenBy { schedule -> + schedule.endTime?.toDate()?.time + ?: (schedule.startTime.toDate().time + 60 * 60 * 1000) + }, + ).firstOrNull() + } + } + + _uiState.value = + MainUiState( + todaySchedules = schedules, + currentSchedule = currentSchedule, + completedCount = schedules.count { it.completed }, + totalCount = schedules.size, + isLoading = false, + ) + + Log.d("MainViewModel", "일정 로드 성공: ${schedules.size}개") + } + + is ScheduleRepository.ScheduleResult.Error -> { + Log.e("MainViewModel", "일정 로드 실패: ${result.error.message}") + _uiState.value = + _uiState.value.copy( + isLoading = false, + error = result.error, + canRetry = true, // 재시도 가능 + ) + } + } + } catch (e: Exception) { + Log.e("MainViewModel", "예상치 못한 에러", e) + _uiState.value = + _uiState.value.copy( + isLoading = false, + error = AppError.GeneralError("일정을 불러오는 중 문제가 발생했습니다"), + canRetry = true, + ) + } + } + } + + fun toggleScheduleComplete(scheduleId: String) { + viewModelScope.launch { + val schedule = _uiState.value.todaySchedules.find { it.id == scheduleId } + schedule?.let { + // 낙관적 업데이트 (즉시 UI 변경) + val updatedSchedules = + _uiState.value.todaySchedules.map { s -> + if (s.id == scheduleId) s.copy(completed = !s.completed) else s + } + + val currentTime = System.currentTimeMillis() + val currentSchedule = + updatedSchedules.firstOrNull { schedule -> + !schedule.completed && + schedule.startTime.toDate().time <= currentTime && + (schedule.endTime?.toDate()?.time ?: Long.MAX_VALUE) > currentTime + } + + _uiState.value = + _uiState.value.copy( + todaySchedules = updatedSchedules, + currentSchedule = currentSchedule, + completedCount = updatedSchedules.count { it.completed }, + ) + + // 서버 업데이트 + when ( + val result = + scheduleRepository.markScheduleAsCompleted(scheduleId, !it.completed) + ) { + is ScheduleRepository.ScheduleResult.Success -> { + Log.d("MainViewModel", "완료 상태 변경 성공") + } + + is ScheduleRepository.ScheduleResult.Error -> { + Log.e("MainViewModel", "완료 상태 변경 실패: ${result.error.message}") + // 실패 시 원래 상태로 복구 + loadSchedules(Calendar.getInstance()) + + _uiState.value = + _uiState.value.copy( + error = result.error, + canRetry = false, + ) + } + } + } + } + } + + fun showScheduleDetail(scheduleId: String) { + val schedule = _uiState.value.todaySchedules.find { it.id == scheduleId } + _uiState.value = _uiState.value.copy(selectedScheduleForDetail = schedule) + } + + fun hideScheduleDetail() { + _uiState.value = _uiState.value.copy(selectedScheduleForDetail = null) + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null, canRetry = false) + } + + fun retryLastAction() { + clearError() + loadSchedules(Calendar.getInstance()) + } + + fun showDeleteConfirmDialog(scheduleId: String) { + val schedule = _uiState.value.todaySchedules.find { it.id == scheduleId } + _uiState.value = + _uiState.value.copy( + showDeleteConfirmDialog = true, + scheduleToDelete = schedule, + ) + } + + fun hideDeleteConfirmDialog() { + _uiState.value = + _uiState.value.copy( + showDeleteConfirmDialog = false, + scheduleToDelete = null, + ) + } + + fun deleteSchedule(scheduleId: String) { + viewModelScope.launch { + _uiState.value = + _uiState.value.copy( + isLoading = true, + error = null, + showDeleteConfirmDialog = false, + scheduleToDelete = null, + ) + + when (val result = scheduleRepository.deleteSchedule(scheduleId)) { + is ScheduleRepository.ScheduleResult.Success -> { + Log.d("MainViewModel", "일정 삭제 성공") + // 목록에서 제거 + val updatedSchedules = + _uiState.value.todaySchedules.filter { it.id != scheduleId } + _uiState.value = + _uiState.value.copy( + todaySchedules = updatedSchedules, + totalCount = updatedSchedules.size, + completedCount = updatedSchedules.count { it.completed }, + isLoading = false, + ) + } + + is ScheduleRepository.ScheduleResult.Error -> { + Log.e("MainViewModel", "일정 삭제 실패: ${result.error.message}") + _uiState.value = + _uiState.value.copy( + isLoading = false, + error = result.error, + canRetry = true, + ) + } + } + } + } + + fun observeSharedReminders(shareCode: String?) { + if (shareCode.isNullOrBlank()) return + sharedRemindersJob?.cancel() + sharedRemindersJob = + viewModelScope.launch { + scheduleRepository.observeSchedulesBySharedCode(shareCode).collectLatest { reminders -> + // Filter reminders to only include those with startTime on today's date + val today = java.util.Calendar.getInstance() + val filteredReminders = + reminders.filter { schedule -> + val cal = + java.util.Calendar + .getInstance() + .apply { time = schedule.startTime.toDate() } + cal.get(java.util.Calendar.YEAR) == today.get(java.util.Calendar.YEAR) && + cal.get(java.util.Calendar.DAY_OF_YEAR) == today.get(java.util.Calendar.DAY_OF_YEAR) + } + _uiState.value = _uiState.value.copy(sharedReminders = filteredReminders) + // Optionally update owner names if needed + val userIds = filteredReminders.map { it.userId }.filter { it.isNotBlank() }.distinct() + val ownerMap = fetchUserNames(userIds) + _uiState.value = _uiState.value.copy(sharedReminderOwners = ownerMap) + } + } + } + + private suspend fun fetchUserNames(userIds: List): Map = + withContext(Dispatchers.IO) { + if (userIds.isEmpty()) return@withContext emptyMap() + try { + val usersCollection = FirestoreDB.users + val chunks = userIds.chunked(10) // Firestore whereIn max 10 + val result = mutableMapOf() + for (chunk in chunks) { + val docs = usersCollection.whereIn("id", chunk).get().await() + for (doc in docs.documents) { + val user = doc.toObject(User::class.java) + if (user != null) { + result[user.id] = user.name + } + } + } + result + } catch (_: Exception) { + emptyMap() + } + } + + fun toggleSharedReminderComplete( + scheduleId: String, + context: Context, + ) { + viewModelScope.launch { + val schedule = _uiState.value.sharedReminders.find { it.id == scheduleId } + schedule?.let { + // Optimistic update + val updatedReminders = + _uiState.value.sharedReminders.map { s -> + if (s.id == scheduleId) s.copy(completed = !s.completed) else s + } + _uiState.value = _uiState.value.copy(sharedReminders = updatedReminders) + + // Update in Firestore + when ( + val result = + scheduleRepository.markScheduleAsCompleted(scheduleId, !it.completed) + ) { + is ScheduleRepository.ScheduleResult.Success -> { + // Send FCM notification to shareCode members + if (it.sharedCode.isNotBlank()) { + val title = if (!it.completed) "일정이 완료됨" else "일정이 미완료로 변경됨" + val message = + "${it.title} 일정이 ${if (!it.completed) "완료" else "미완료"} 처리되었습니다." + scheduleRepository.sendNotificationToShareCodeMembers( + context, + it.sharedCode, + title, + message, + ) + } + } + + is ScheduleRepository.ScheduleResult.Error -> { + // On error, reload shared reminders to revert + val shareCode = it.sharedCode + if (shareCode.isNotBlank()) { + observeSharedReminders(shareCode) + } + _uiState.value = + _uiState.value.copy( + error = result.error, + canRetry = false, + ) + } + } + } + } + } + + suspend fun notifyShareCodeMembersForSchedule( + context: Context, + schedule: Schedule, + type: String, + ) { + if (schedule.sharedCode.isBlank()) return + val (title, message) = + when (type) { + "create" -> "새 일정이 추가되었습니다" to "${schedule.title} 일정이 추가되었습니다." + "edit" -> "일정이 수정되었습니다" to "${schedule.title} 일정이 수정되었습니다." + "delete" -> "일정이 삭제되었습니다" to "${schedule.title} 일정이 삭제되었습니다." + else -> return + } + scheduleRepository.sendNotificationToShareCodeMembers(context, schedule.sharedCode, title, message) + } + + fun addSharedSchedule( + schedule: Schedule, + context: Context, + ) = viewModelScope.launch { + val result = scheduleRepository.addSchedule(schedule) + if (result is ScheduleRepository.ScheduleResult.Success) { + notifyShareCodeMembersForSchedule(context, schedule, "create") + } + } + + fun updateSharedSchedule( + schedule: Schedule, + context: Context, + ) = viewModelScope.launch { + val result = scheduleRepository.updateSchedule(schedule) + if (result is ScheduleRepository.ScheduleResult.Success) { + notifyShareCodeMembersForSchedule(context, schedule, "edit") + } + } + + fun deleteSharedSchedule( + schedule: Schedule, + context: Context, + ) = viewModelScope.launch { + val result = scheduleRepository.deleteSchedule(schedule.id) + if (result is ScheduleRepository.ScheduleResult.Success) { + notifyShareCodeMembersForSchedule(context, schedule, "delete") + } + } + + fun sendTestFcm(context: Context) { + viewModelScope.launch { + try { + val user = + com.google.firebase.auth.FirebaseAuth + .getInstance() + .currentUser + if (user != null) { + val db = + com.google.firebase.firestore.FirebaseFirestore + .getInstance() + val userDoc = + db + .collection("users") + .document(user.uid) + .get() + .await() + val fcmToken = userDoc.getString("fcmToken") + if (!fcmToken.isNullOrBlank()) { + com.example.slowclock.notification.GuardianNotifier.sendReminderToUser( + context, + fcmToken, + "FCM 테스트", + "이것은 테스트 메시지입니다.", + ) + } else { + Log.e("TestFCM", "FCM 토큰이 없습니다.") + } + } + } catch (e: Exception) { + Log.e("TestFCM", "테스트 FCM 전송 실패", e) + } + } + } + + // --- ShareCode Watcher Logic --- + fun addShareCodeWatcher( + context: Context, + shareCode: String, + ) { + Log.d("ShareCodeWatcher", "addShareCodeWatcher called with shareCode=$shareCode") + android.widget.Toast + .makeText(context, "addShareCodeWatcher called", android.widget.Toast.LENGTH_SHORT) + .show() + val user = + com.google.firebase.auth.FirebaseAuth + .getInstance() + .currentUser ?: return + com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> + val db = + com.google.firebase.firestore.FirebaseFirestore + .getInstance() + db + .collection("shareCodeWatchers") + .document(shareCode) + .collection("tokens") + .document(user.uid) + .set(mapOf("fcmToken" to token)) + .addOnSuccessListener { + Log.d("ShareCodeWatcher", "Watcher added for shareCode=$shareCode, userId=${user.uid}, token=$token") + android.widget.Toast + .makeText(context, "Watcher added!", android.widget.Toast.LENGTH_SHORT) + .show() + }.addOnFailureListener { e -> + Log.e("ShareCodeWatcher", "Failed to add watcher for shareCode=$shareCode, userId=${user.uid}", e) + android.widget.Toast + .makeText(context, "Failed to add watcher: ${e.message}", android.widget.Toast.LENGTH_SHORT) + .show() + } + } + } + + fun removeShareCodeWatcher(shareCode: String) { + val user = + com.google.firebase.auth.FirebaseAuth + .getInstance() + .currentUser ?: return + val db = + com.google.firebase.firestore.FirebaseFirestore + .getInstance() + db + .collection("shareCodeWatchers") + .document(shareCode) + .collection("tokens") + .document(user.uid) + .delete() + } + // --- End ShareCode Watcher Logic --- + } diff --git a/app/src/main/java/com/example/slowclock/ui/main/components/CurrentTaskSection.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/components/CurrentTaskSection.kt similarity index 73% rename from app/src/main/java/com/example/slowclock/ui/main/components/CurrentTaskSection.kt rename to feature/main/src/main/java/com/example/slowclock/ui/main/components/CurrentTaskSection.kt index c56d4a3..9282575 100644 --- a/app/src/main/java/com/example/slowclock/ui/main/components/CurrentTaskSection.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/components/CurrentTaskSection.kt @@ -36,38 +36,39 @@ import java.util.Locale @Composable fun CurrentTaskSection( schedule: Schedule, - onShowDetail: () -> Unit + onShowDetail: () -> Unit, ) { val timeFormat = SimpleDateFormat("a h:mm", Locale.KOREAN) val currentTime = System.currentTimeMillis() - val displayTime = if (isOngoing(schedule, currentTime)) { - "~${ - timeFormat.format( - schedule.endTime?.toDate() ?: Date(schedule.startTime.toDate().time + 60 * 60 * 1000) - ) - }" - } else { - timeFormat.format(schedule.startTime.toDate()) - } + val displayTime = + if (isOngoing(schedule, currentTime)) { + "~${ + timeFormat.format( + schedule.endTime?.toDate() ?: Date(schedule.startTime.toDate().time + 60 * 60 * 1000), + ) + }" + } else { + timeFormat.format(schedule.startTime.toDate()) + } Column { // 섹션 헤더 Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp), ) { Icon( Icons.Outlined.AccessTime, contentDescription = null, tint = MaterialTheme.colorScheme.primary, // 하드코딩 색상 제거 - modifier = Modifier.size(24.dp) // 20dp → 24dp + modifier = Modifier.size(24.dp), // 20dp → 24dp ) Spacer(modifier = Modifier.width(8.dp)) // 4dp → 8dp Text( text = "지금 할 일", style = MaterialTheme.typography.titleLarge, // fontSize 대신 style 사용 - color = MaterialTheme.colorScheme.onSurface // 하드코딩 색상 제거 + color = MaterialTheme.colorScheme.onSurface, // 하드코딩 색상 제거 ) Spacer(modifier = Modifier.weight(1f)) @@ -75,45 +76,50 @@ fun CurrentTaskSection( Text( text = displayTime, style = MaterialTheme.typography.bodyLarge, // fontSize 대신 style 사용 - color = MaterialTheme.colorScheme.onSurfaceVariant // 하드코딩 색상 제거 + color = MaterialTheme.colorScheme.onSurfaceVariant, // 하드코딩 색상 제거 ) } // 현재 할 일 카드 (노란색 → tertiary 색상으로) Card( - modifier = Modifier - .fillMaxWidth() - .clickable { onShowDetail() }, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer // 하드코딩 색상 제거 - ), + modifier = + Modifier + .fillMaxWidth() + .clickable { onShowDetail() }, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, // 하드코딩 색상 제거 + ), shape = RoundedCornerShape(12.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Box( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { // 왼쪽 강조 바 (더 두껍게) Box( - modifier = Modifier - .width(6.dp) // 4dp → 6dp - .height(80.dp) // 60dp → 80dp - .background(MaterialTheme.colorScheme.tertiary) // 하드코딩 색상 제거 - .align(Alignment.CenterStart) + modifier = + Modifier + .width(6.dp) // 4dp → 6dp + .height(80.dp) // 60dp → 80dp + .background(MaterialTheme.colorScheme.tertiary) // 하드코딩 색상 제거 + .align(Alignment.CenterStart), ) Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) // 16dp → 20dp - .padding(start = 12.dp), // 8dp → 12dp - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp) // 16dp → 20dp + .padding(start = 12.dp), + // 8dp → 12dp + verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Default.Notifications, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary, // 하드코딩 색상 제거 - modifier = Modifier.size(28.dp) // 24dp → 28dp + modifier = Modifier.size(28.dp), // 24dp → 28dp ) Spacer(modifier = Modifier.width(16.dp)) // 12dp → 16dp @@ -121,25 +127,25 @@ fun CurrentTaskSection( Text( text = schedule.title, style = MaterialTheme.typography.bodyLarge, // fontSize 대신 style 사용 - color = MaterialTheme.colorScheme.onTertiaryContainer // 하드코딩 색상 제거 + color = MaterialTheme.colorScheme.onTertiaryContainer, // 하드코딩 색상 제거 ) // 힌트 표시 if (hasExtraInfo(schedule)) { Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Default.MoreHoriz, contentDescription = "상세정보 있음", tint = MaterialTheme.colorScheme.tertiary, // 하드코딩 색상 제거 - modifier = Modifier.size(20.dp) // 16dp → 20dp + modifier = Modifier.size(20.dp), // 16dp → 20dp ) Spacer(modifier = Modifier.width(4.dp)) Text( text = "터치하여 상세보기", style = MaterialTheme.typography.bodySmall, // fontSize 대신 style 사용 - color = MaterialTheme.colorScheme.tertiary // 하드코딩 색상 제거 + color = MaterialTheme.colorScheme.tertiary, // 하드코딩 색상 제거 ) } } @@ -150,10 +156,10 @@ fun CurrentTaskSection( text = if (isOngoing(schedule, currentTime)) "진행중" else "예정", style = MaterialTheme.typography.bodyMedium, // fontSize 대신 style 사용 color = MaterialTheme.colorScheme.tertiary, // 하드코딩 색상 제거 - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), ) } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/main/components/EmptyStateCard.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/components/EmptyStateCard.kt similarity index 80% rename from app/src/main/java/com/example/slowclock/ui/main/components/EmptyStateCard.kt rename to feature/main/src/main/java/com/example/slowclock/ui/main/components/EmptyStateCard.kt index d8bbe9e..f37ae86 100644 --- a/app/src/main/java/com/example/slowclock/ui/main/components/EmptyStateCard.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/components/EmptyStateCard.kt @@ -21,32 +21,33 @@ import androidx.compose.ui.unit.sp fun EmptyStateCard() { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer // 하드코딩 색상 제거 - ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, // 하드코딩 색상 제거 + ), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(16.dp), ) { Column( modifier = Modifier.padding(40.dp), // 32dp → 40dp - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = "📅", - fontSize = 72.sp // 64sp → 72sp (더 큰 이모지) + fontSize = 72.sp, // 64sp → 72sp (더 큰 이모지) ) Spacer(modifier = Modifier.height(20.dp)) // 16dp → 20dp Text( text = "오늘 등록된 일정이 없습니다", style = MaterialTheme.typography.headlineSmall, // fontSize 대신 style 사용 - color = MaterialTheme.colorScheme.onTertiaryContainer // 하드코딩 색상 제거 + color = MaterialTheme.colorScheme.onTertiaryContainer, // 하드코딩 색상 제거 ) Spacer(modifier = Modifier.height(12.dp)) // 8dp → 12dp Text( text = "아래 + 버튼을 눌러 일정을 추가해보세요", style = MaterialTheme.typography.bodyLarge, // fontSize 대신 style 사용 - color = MaterialTheme.colorScheme.onTertiaryContainer // 하드코딩 색상 제거 + color = MaterialTheme.colorScheme.onTertiaryContainer, // 하드코딩 색상 제거 ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/main/components/ScheduleCard.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/components/ScheduleCard.kt similarity index 78% rename from app/src/main/java/com/example/slowclock/ui/main/components/ScheduleCard.kt rename to feature/main/src/main/java/com/example/slowclock/ui/main/components/ScheduleCard.kt index 2fa2adf..f2a4020 100644 --- a/app/src/main/java/com/example/slowclock/ui/main/components/ScheduleCard.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/components/ScheduleCard.kt @@ -31,25 +31,28 @@ fun Date.toTimeString(): String { val format = SimpleDateFormat("a h:mm", Locale.KOREAN) return format.format(this) } + // ✅ 최종 ScheduleCard @Composable fun ScheduleCard( schedule: Schedule, onToggleComplete: () -> Unit, // 아직 사용 중이라면 그대로 둬도 됨 onShowDetail: () -> Unit, - completed: Boolean + completed: Boolean, ) { - val backgroundColor = if (completed) { - Color(0xFFE6F4EA) // 연한 초록 - } else { - Color.White - } + val backgroundColor = + if (completed) { + Color(0xFFE6F4EA) // 연한 초록 + } else { + Color.White + } - val borderColor = if (completed) { - Color.Transparent - } else { - Color(0xFF1A73E8) // 파란색 테두리 - } + val borderColor = + if (completed) { + Color.Transparent + } else { + Color(0xFF1A73E8) // 파란색 테두리 + } Card( onClick = onShowDetail, @@ -57,13 +60,14 @@ fun ScheduleCard( shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, borderColor), colors = CardDefaults.cardColors(containerColor = backgroundColor), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), ) { Row( - modifier = Modifier - .padding(horizontal = 20.dp, vertical = 16.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .padding(horizontal = 20.dp, vertical = 16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { // ✅ 체크박스와 간격 제거 @@ -71,12 +75,12 @@ fun ScheduleCard( Text( text = schedule.title, style = MaterialTheme.typography.bodyLarge, - color = Color.Black + color = Color.Black, ) Text( text = schedule.startTime.toDate().toTimeString(), style = MaterialTheme.typography.bodyMedium, - color = Color.Gray + color = Color.Gray, ) } @@ -84,7 +88,7 @@ fun ScheduleCard( Text( text = "완료", color = Color(0xFF1A73E8), - style = MaterialTheme.typography.labelLarge + style = MaterialTheme.typography.labelLarge, ) } } diff --git a/app/src/main/java/com/example/slowclock/ui/main/components/ScheduleDetailDialog.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/components/ScheduleDetailDialog.kt similarity index 80% rename from app/src/main/java/com/example/slowclock/ui/main/components/ScheduleDetailDialog.kt rename to feature/main/src/main/java/com/example/slowclock/ui/main/components/ScheduleDetailDialog.kt index f71ac6f..a43091f 100644 --- a/app/src/main/java/com/example/slowclock/ui/main/components/ScheduleDetailDialog.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/components/ScheduleDetailDialog.kt @@ -40,40 +40,41 @@ fun ScheduleDetailDialog( schedule: Schedule, onDismiss: () -> Unit, onEdit: () -> Unit = {}, // 새로 추가 - onDelete: () -> Unit = {} // 새로 추가 + onDelete: () -> Unit = {}, // 새로 추가 ) { val timeFormat = SimpleDateFormat("a h:mm", Locale.KOREAN) val dateFormat = SimpleDateFormat("M월 d일", Locale.KOREAN) Dialog(onDismissRequest = onDismiss) { Card( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(24.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), ) { Column( - modifier = Modifier.padding(28.dp) + modifier = Modifier.padding(28.dp), ) { // 헤더 (제목 + 닫기 버튼) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "일정 상세", style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) IconButton(onClick = onDismiss) { Icon( Icons.Default.Close, contentDescription = "닫기", tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) } } @@ -84,37 +85,37 @@ fun ScheduleDetailDialog( Text( text = schedule.title, style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(24.dp)) // 시간 정보 Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Default.AccessTime, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(12.dp)) Column { Text( text = "${dateFormat.format(schedule.startTime.toDate())} ${ timeFormat.format( - schedule.startTime.toDate() + schedule.startTime.toDate(), ) }", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) schedule.endTime?.let { endTime -> Text( text = "~ ${timeFormat.format(endTime.toDate())}", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -124,27 +125,27 @@ fun ScheduleDetailDialog( if (schedule.description.isNotBlank()) { Spacer(modifier = Modifier.height(20.dp)) Row( - verticalAlignment = Alignment.Top + verticalAlignment = Alignment.Top, ) { Icon( Icons.AutoMirrored.Filled.Notes, contentDescription = null, tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(12.dp)) Column { Text( text = "상세 내용", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = schedule.description, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight, ) } } @@ -154,24 +155,25 @@ fun ScheduleDetailDialog( if (schedule.recurring) { Spacer(modifier = Modifier.height(20.dp)) Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Default.Loop, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(12.dp)) Text( - text = when (schedule.recurringType) { - "daily" -> "매일 반복" - "weekly" -> "매주 반복" - "monthly" -> "매월 반복" - else -> "반복 일정" - }, + text = + when (schedule.recurringType) { + "daily" -> "매일 반복" + "weekly" -> "매주 반복" + "monthly" -> "매월 반복" + else -> "반복 일정" + }, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.tertiary + color = MaterialTheme.colorScheme.tertiary, ) } } @@ -179,25 +181,29 @@ fun ScheduleDetailDialog( // 완료 상태 Spacer(modifier = Modifier.height(20.dp)) Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Default.CheckCircle, contentDescription = null, - tint = if (schedule.completed) - MaterialTheme.colorScheme.secondary - else - MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp) + tint = + if (schedule.completed) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(12.dp)) Text( text = if (schedule.completed) "✅ 완료됨" else "⏳ 미완료", style = MaterialTheme.typography.bodyLarge, - color = if (schedule.completed) - MaterialTheme.colorScheme.secondary - else - MaterialTheme.colorScheme.onSurfaceVariant + color = + if (schedule.completed) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) } @@ -205,7 +211,7 @@ fun ScheduleDetailDialog( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { // 수정 버튼 Button( @@ -214,14 +220,15 @@ fun ScheduleDetailDialog( onDismiss() }, modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), ) { Text( "수정", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onPrimary + color = MaterialTheme.colorScheme.onPrimary, ) } @@ -232,14 +239,15 @@ fun ScheduleDetailDialog( onDismiss() }, modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), ) { Text( "삭제", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onError + color = MaterialTheme.colorScheme.onError, ) } } @@ -249,15 +257,15 @@ fun ScheduleDetailDialog( // 닫기 버튼 TextButton( onClick = onDismiss, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Text( text = "닫기", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface // 색상 명시 + color = MaterialTheme.colorScheme.onSurface, // 색상 명시 ) } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/main/components/SharedRemindersSection.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/components/SharedRemindersSection.kt similarity index 81% rename from app/src/main/java/com/example/slowclock/ui/main/components/SharedRemindersSection.kt rename to feature/main/src/main/java/com/example/slowclock/ui/main/components/SharedRemindersSection.kt index 72110a4..b30f168 100644 --- a/app/src/main/java/com/example/slowclock/ui/main/components/SharedRemindersSection.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/components/SharedRemindersSection.kt @@ -29,24 +29,28 @@ fun SharedRemindersSection( sharedReminders: List, currentUserUid: String?, timeFormat: SimpleDateFormat, - onToggleComplete: (String) -> Unit + onToggleComplete: (String) -> Unit, ) { if (sharedReminders.isEmpty()) return Card( - shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxSize() - .padding(vertical = 8.dp), - colors = CardDefaults.cardColors( - containerColor = Color(0xFFE3F2FD) - ) + shape = + androidx.compose.foundation.shape + .RoundedCornerShape(16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(vertical = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = Color(0xFFE3F2FD), + ), ) { Column(modifier = Modifier.padding(16.dp)) { Text( text = "공유 일정", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.height(8.dp)) sharedReminders.forEach { schedule -> @@ -55,13 +59,13 @@ fun SharedRemindersSection( if (schedule.userId == currentUserUid) { IconButton( onClick = { onToggleComplete(schedule.id) }, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) { Icon( imageVector = Icons.Default.Check, contentDescription = if (schedule.completed) "완료됨" else "미완료", tint = if (schedule.completed) Color(0xFF388E3C) else Color.Red, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(18.dp), ) } } else { @@ -69,44 +73,44 @@ fun SharedRemindersSection( imageVector = Icons.Default.Check, contentDescription = if (schedule.completed) "완료됨" else "미완료", tint = if (schedule.completed) Color(0xFF388E3C) else Color.Red, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(18.dp), ) } Text( text = if (schedule.completed) "완료됨" else "미완료", style = MaterialTheme.typography.labelSmall, color = if (schedule.completed) Color(0xFF388E3C) else Color.Red, - modifier = Modifier.padding(start = 4.dp, end = 8.dp) + modifier = Modifier.padding(start = 4.dp, end = 8.dp), ) Text( text = schedule.title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) } if (schedule.description.isNotBlank()) { Text( text = schedule.description, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } val start = schedule.startTime.toDate() val end = schedule.endTime?.toDate() - val timeText = if (end != null) { - "${timeFormat.format(start)} ~ ${timeFormat.format(end)}" - } else { - timeFormat.format(start) - } + val timeText = + if (end != null) { + "${timeFormat.format(start)} ~ ${timeFormat.format(end)}" + } else { + timeFormat.format(start) + } Text( text = timeText, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } } } } } - diff --git a/app/src/main/java/com/example/slowclock/ui/main/components/TodayScheduleSection.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/components/TodayScheduleSection.kt similarity index 90% rename from app/src/main/java/com/example/slowclock/ui/main/components/TodayScheduleSection.kt rename to feature/main/src/main/java/com/example/slowclock/ui/main/components/TodayScheduleSection.kt index 8ceadcc..fd3c8d3 100644 --- a/app/src/main/java/com/example/slowclock/ui/main/components/TodayScheduleSection.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/components/TodayScheduleSection.kt @@ -31,19 +31,19 @@ fun TodayScheduleSection( // 섹션 제목 Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 12.dp) + modifier = Modifier.padding(bottom = 12.dp), ) { Icon( Icons.Outlined.CalendarToday, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text( text = "오늘의 일정", style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } @@ -56,7 +56,7 @@ fun TodayScheduleSection( schedule = schedule, onToggleComplete = { onToggleComplete(schedule.id) }, onShowDetail = { onShowDetail(schedule.id) }, - completed = schedule.completed + completed = schedule.completed, ) } } @@ -71,11 +71,10 @@ fun TodayScheduleSection( schedule = schedule, onToggleComplete = { onToggleComplete(schedule.id) }, onShowDetail = { onShowDetail(schedule.id) }, - completed = schedule.completed + completed = schedule.completed, ) } } } } } - diff --git a/app/src/main/java/com/example/slowclock/ui/settings/SettingsScreen.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreen.kt similarity index 76% rename from app/src/main/java/com/example/slowclock/ui/settings/SettingsScreen.kt rename to feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreen.kt index c94d6c0..e48d294 100644 --- a/app/src/main/java/com/example/slowclock/ui/settings/SettingsScreen.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreen.kt @@ -19,22 +19,24 @@ import androidx.navigation.NavController @Composable fun SettingsScreen(navController: NavController) { Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .clickable { - navController.navigate("information") - }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clickable { + navController.navigate("information") + }, shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color(0xFF1A73E8)), colors = CardDefaults.cardColors(containerColor = Color.White), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), ) { Text( text = "의료 정보 - 메디컬타임즈", fontSize = 18.sp, - modifier = Modifier - .padding(16.dp) + modifier = + Modifier + .padding(16.dp), ) } } diff --git a/app/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt similarity index 75% rename from app/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt rename to feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt index 1e5d3b2..bca7926 100644 --- a/app/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt @@ -10,20 +10,26 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.slowclock.ui.main.MainViewModel @Composable -fun SettingsScreenShareCode(onReturn: () -> Unit) { +fun SettingsScreenShareCode( + onReturn: () -> Unit, + viewModel: MainViewModel = hiltViewModel(), +) { val context = LocalContext.current val prefs = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) } var shareCode by remember { mutableStateOf(prefs.getString("share_code", "") ?: "") } var inputValue by remember { mutableStateOf(TextFieldValue(shareCode)) } Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), + modifier = + Modifier + .fillMaxSize() + .padding(24.dp), verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text("Enter Share Code to View Shared Reminders", style = MaterialTheme.typography.titleLarge) Spacer(modifier = Modifier.height(24.dp)) @@ -31,14 +37,14 @@ fun SettingsScreenShareCode(onReturn: () -> Unit) { value = inputValue, onValueChange = { inputValue = it }, label = { Text("Share Code") }, - singleLine = true + singleLine = true, ) Spacer(modifier = Modifier.height(24.dp)) Button(onClick = { prefs.edit().putString("share_code", inputValue.text).apply() shareCode = inputValue.text // Register watcher after saving share code - com.example.slowclock.ui.main.MainViewModel().addShareCodeWatcher(context, inputValue.text) + viewModel.addShareCodeWatcher(context, inputValue.text) onReturn() }) { Text("Save and Return") diff --git a/app/src/main/java/com/example/slowclock/ui/timeline/Timeline.kt b/feature/main/src/main/java/com/example/slowclock/ui/timeline/Timeline.kt similarity index 68% rename from app/src/main/java/com/example/slowclock/ui/timeline/Timeline.kt rename to feature/main/src/main/java/com/example/slowclock/ui/timeline/Timeline.kt index d39d4bc..e33c0b7 100644 --- a/app/src/main/java/com/example/slowclock/ui/timeline/Timeline.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/timeline/Timeline.kt @@ -37,66 +37,72 @@ import java.util.Locale @Composable fun Timeline( height: Dp, - items: List + items: List, ) { val sortedItems = items.sortedBy { it.startTime.seconds } val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) Box( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp) + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), ) { // 전체 세로선: Box 전체 높이를 따라 고정 Divider( color = Color(0xFF3A5CCC), - modifier = Modifier - .width(2.dp) - .fillMaxHeight() - .align(Alignment.Center) + modifier = + Modifier + .width(2.dp) + .fillMaxHeight() + .align(Alignment.Center), ) LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(60.dp), - contentPadding = PaddingValues(vertical = 24.dp) + contentPadding = PaddingValues(vertical = 24.dp), ) { itemsIndexed(sortedItems) { index, item -> Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), ) { // 점 Box( - modifier = Modifier - .size(10.dp) - .align(Alignment.Center) - .background(Color(0xFF3A5CCC), shape = CircleShape) + modifier = + Modifier + .size(10.dp) + .align(Alignment.Center) + .background(Color(0xFF3A5CCC), shape = CircleShape), ) val alignStart = index % 2 == 0 Card( - modifier = Modifier - .align(if (alignStart) Alignment.CenterStart else Alignment.CenterEnd) - .padding(horizontal = 40.dp), + modifier = + Modifier + .align(if (alignStart) Alignment.CenterStart else Alignment.CenterEnd) + .padding(horizontal = 40.dp), shape = RoundedCornerShape(12.dp), border = BorderStroke(1.dp, if (item.completed) Color.Transparent else Color(0xFF1A73E8)), - colors = CardDefaults.cardColors( - containerColor = if (item.completed) Color(0xFFE6F4EA) else Color.White - ), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + colors = + CardDefaults.cardColors( + containerColor = if (item.completed) Color(0xFFE6F4EA) else Color.White, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), ) { Column(modifier = Modifier.padding(12.dp)) { Text( text = item.title, fontSize = 16.sp, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Text( text = timeFormat.format(item.startTime.toDate()), fontSize = 12.sp, - color = Color.Gray + color = Color.Gray, ) } } @@ -104,5 +110,4 @@ fun Timeline( } } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/timeline/TimelineScreen.kt b/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineScreen.kt similarity index 51% rename from app/src/main/java/com/example/slowclock/ui/timeline/TimelineScreen.kt rename to feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineScreen.kt index 6dd84ea..b9fcaef 100644 --- a/app/src/main/java/com/example/slowclock/ui/timeline/TimelineScreen.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineScreen.kt @@ -30,63 +30,66 @@ import java.util.Calendar import java.util.Locale @Composable -fun TimelineScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { - +fun TimelineScreen( + viewModel: MainViewModel, + modifier: Modifier = Modifier, +) { val context = LocalContext.current // Calendar 및 날짜 수정 Method // 0000년 00월 00일 Format - val formatter = remember{ SimpleDateFormat("yyyy년 MM월 dd일", Locale.KOREA)} + val formatter = remember { SimpleDateFormat("yyyy년 MM월 dd일", Locale.KOREA) } // 드래그 중첩 방지 var hasSwiped by remember { mutableStateOf(false) } // Timeline 날짜 선택 및 이동 - var calendar = remember{Calendar.getInstance()} - val Date = remember { mutableStateOf(formatter.format(calendar.time))} + var calendar = remember { Calendar.getInstance() } + val date = remember { mutableStateOf(formatter.format(calendar.time)) } val datePickerDialog = DatePickerDialog( context, { _: DatePicker, year: Int, month: Int, dayOfMonth: Int -> - calendar.set(year,month,dayOfMonth) - Date.value = formatter.format(calendar.time) + calendar.set(year, month, dayOfMonth) + date.value = formatter.format(calendar.time) }, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH), - - ) + ) val uiState by viewModel.uiState.collectAsState() - val filteredSchedules = uiState.todaySchedules.filter { schedule -> - val scheduleDate = formatter.format(schedule.startTime.toDate()) - scheduleDate == Date.value - } + val filteredSchedules = + uiState.todaySchedules.filter { schedule -> + val scheduleDate = formatter.format(schedule.startTime.toDate()) + scheduleDate == date.value + } // Timeline Screen 컨텐츠 - BoxWithConstraints(modifier= Modifier.fillMaxSize().padding(top=50.dp)) { + BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(top = 50.dp)) { Column( - modifier = Modifier - .fillMaxWidth() - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { - hasSwiped = false - } - ) { _, dragAmount -> - if(!hasSwiped) { - if (dragAmount > 100) { - calendar.add(Calendar.DAY_OF_MONTH, -1) - Date.value = formatter.format(calendar.time) - hasSwiped = true - } else if (dragAmount < -100) { - calendar.add(Calendar.DAY_OF_MONTH, 1) - Date.value = formatter.format(calendar.time) - hasSwiped = true + modifier = + Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { + hasSwiped = false + }, + ) { _, dragAmount -> + if (!hasSwiped) { + if (dragAmount > 100) { + calendar.add(Calendar.DAY_OF_MONTH, -1) + date.value = formatter.format(calendar.time) + hasSwiped = true + } else if (dragAmount < -100) { + calendar.add(Calendar.DAY_OF_MONTH, 1) + date.value = formatter.format(calendar.time) + hasSwiped = true + } } } - } - } + }, ) { // 제목 Text Text( @@ -94,33 +97,31 @@ fun TimelineScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { fontSize = 20.sp, color = Color.Blue, fontWeight = Bold, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .clickable { - datePickerDialog.show() - } - + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .clickable { + datePickerDialog.show() + }, ) // Timeline 날짜 Text Text( - text = Date.value, + text = date.value, fontSize = 15.sp, color = Color.Gray, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .clickable { - datePickerDialog.show() - } + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .clickable { + datePickerDialog.show() + }, ) // Default : 오늘 날짜, 이후 캘린더 조작 혹은 버튼 클릭으로 날짜 변경 가능 Timeline( - items= filteredSchedules, // Items : DB에서 사용자의 해당 날짜에 존재하는 일정들을 가져와서 사용 - height= this@BoxWithConstraints.maxHeight + items = filteredSchedules, // Items : DB에서 사용자의 해당 날짜에 존재하는 일정들을 가져와서 사용 + height = this@BoxWithConstraints.maxHeight, ) - } - } - -} \ No newline at end of file +} diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts new file mode 100644 index 0000000..ac49a6c --- /dev/null +++ b/feature/profile/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.example.slowclock.feature.profile" + compileSdk = 35 + defaultConfig { minSdk = 32 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { jvmTarget = "11" } + buildFeatures { compose = true } +} + +dependencies { + implementation(project(":core:ui")) + implementation(project(":core:data")) // ProfileScreen 이 FirestoreDB 직접 사용 — #29 에서 ViewModel 화 예정 + implementation(libs.firebase.auth) +} diff --git a/app/src/main/java/com/example/slowclock/ui/profile/ProfileScreen.kt b/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileScreen.kt similarity index 83% rename from app/src/main/java/com/example/slowclock/ui/profile/ProfileScreen.kt rename to feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileScreen.kt index b3c4d19..22fae35 100644 --- a/app/src/main/java/com/example/slowclock/ui/profile/ProfileScreen.kt +++ b/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileScreen.kt @@ -38,9 +38,7 @@ import kotlinx.coroutines.tasks.await @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ProfileScreen( - onNavigateBack: () -> Unit -) { +fun ProfileScreen(onNavigateBack: () -> Unit) { val currentUser = FirebaseAuth.getInstance().currentUser var userModel by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } @@ -49,7 +47,11 @@ fun ProfileScreen( LaunchedEffect(currentUser?.uid) { if (currentUser?.uid != null) { try { - val doc = FirestoreDB.users.document(currentUser.uid).get().await() + val doc = + FirestoreDB.users + .document(currentUser.uid) + .get() + .await() userModel = doc.toObject() isLoading = false } catch (e: Exception) { @@ -68,7 +70,7 @@ fun ProfileScreen( Text( text = "내 정보", style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) }, navigationIcon = { @@ -77,23 +79,25 @@ fun ProfileScreen( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로가기", tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(28.dp) + modifier = Modifier.size(28.dp), ) } }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = + TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), ) - } + }, ) { paddingValues -> Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(24.dp), + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(24.dp), verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { if (isLoading) { Text("로딩 중...") @@ -105,7 +109,7 @@ fun ProfileScreen( Icons.Default.Person, contentDescription = "프로필", modifier = Modifier.size(120.dp), - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(28.dp)) @@ -114,7 +118,7 @@ fun ProfileScreen( Text( text = userModel?.name ?: currentUser?.displayName ?: "이름 없음", style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(12.dp)) @@ -123,7 +127,7 @@ fun ProfileScreen( Text( text = userModel?.email ?: currentUser?.email ?: "이메일 없음", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(24.dp)) @@ -131,7 +135,7 @@ fun ProfileScreen( // 공유 코드 Text( text = "내 공유 코드:", - style = MaterialTheme.typography.labelLarge + style = MaterialTheme.typography.labelLarge, ) Spacer(modifier = Modifier.height(4.dp)) @@ -139,7 +143,7 @@ fun ProfileScreen( Text( text = userModel?.shareCode ?: "-", style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(56.dp)) @@ -150,18 +154,19 @@ fun ProfileScreen( FirebaseAuth.getInstance().signOut() onNavigateBack() }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ), - modifier = Modifier.size(width = 200.dp, height = 56.dp) + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + modifier = Modifier.size(width = 200.dp, height = 56.dp), ) { Text( text = "로그아웃", color = MaterialTheme.colorScheme.onError, - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) } } } } -} \ No newline at end of file +} diff --git a/feature/recommendation/build.gradle.kts b/feature/recommendation/build.gradle.kts new file mode 100644 index 0000000..36bbdea --- /dev/null +++ b/feature/recommendation/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.example.slowclock.feature.recommendation" + compileSdk = 35 + defaultConfig { minSdk = 32 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { jvmTarget = "11" } + buildFeatures { compose = true } +} + +dependencies { + implementation(project(":core:ui")) + implementation(project(":core:model")) + implementation(libs.androidx.navigation.compose) +} diff --git a/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/RecommendationScreen.kt b/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/RecommendationScreen.kt new file mode 100644 index 0000000..53fcf3d --- /dev/null +++ b/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/RecommendationScreen.kt @@ -0,0 +1,98 @@ +package com.example.slowclock.ui.recommendation + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.example.slowclock.data.model.Recommendation +import com.example.slowclock.ui.recommendation.components.ADHDRecommendation +import com.example.slowclock.ui.recommendation.components.ElderlyRecommendation +import com.example.slowclock.ui.recommendation.components.StudentRecommendation + +@Composable +fun RecommendationScreen( + navController: NavController, + modifier: Modifier = Modifier, +) { + val userInfo = remember { mutableStateOf("Elderly") } + val allRecommendation = + listOf( + Recommendation("밥 먹기"), + Recommendation("잠 자기"), + Recommendation("약 먹기"), + Recommendation("병원 예약하기"), + Recommendation("운동하기"), + Recommendation("감정 일기 쓰기", "ADHD"), + Recommendation("회피 행동 돌아보기", "ADHD"), + Recommendation("명상하기", "ADHD"), + Recommendation("자습", "학생"), + Recommendation("공부량 확인", "학생"), + Recommendation("휴식하기", "학생"), + Recommendation("복습하기", "학생"), + Recommendation("햇빛 쬐기", "노인"), + Recommendation("일기 쓰기", "노인"), + Recommendation("노인정에서 교류하기", "노인"), + Recommendation("음악 감상하기", "노인"), + Recommendation("새로운 공부하기", "노인"), + Recommendation("사회봉사하기", "노인"), + ) + Column(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.padding(8.dp)) { + Button(onClick = { userInfo.value = "Elderly" }, modifier = Modifier.padding(4.dp)) { + Text(text = "노인") + } + Button(onClick = { userInfo.value = "ADHD" }, modifier = Modifier.padding(4.dp)) { + Text(text = "ADHD") + } + Button(onClick = { userInfo.value = "Student" }, modifier = Modifier.padding(4.dp)) { + Text(text = "학생") + } + } + + when (userInfo.value) { + "Elderly" -> { + ElderlyRecommendation( + list = allRecommendation, + onSelectRecommendation = { selectedTitle -> + navController.previousBackStackEntry + ?.savedStateHandle + ?.set("initial_title", selectedTitle) + navController.popBackStack() + }, + ) + } + + "ADHD" -> { + ADHDRecommendation( + list = allRecommendation, + onSelectRecommendation = { selectedTitle -> + navController.previousBackStackEntry + ?.savedStateHandle + ?.set("initial_title", selectedTitle) + navController.popBackStack() + }, + ) + } + + else -> { + StudentRecommendation( + list = allRecommendation, + onSelectRecommendation = { selectedTitle -> + navController.previousBackStackEntry + ?.savedStateHandle + ?.set("initial_title", selectedTitle) + navController.popBackStack() + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/example/slowclock/ui/recommendation/components/ADHDRecommendation.kt b/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/ADHDRecommendation.kt similarity index 76% rename from app/src/main/java/com/example/slowclock/ui/recommendation/components/ADHDRecommendation.kt rename to feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/ADHDRecommendation.kt index 45b01bc..d912db8 100644 --- a/app/src/main/java/com/example/slowclock/ui/recommendation/components/ADHDRecommendation.kt +++ b/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/ADHDRecommendation.kt @@ -24,49 +24,57 @@ import androidx.compose.ui.unit.sp import com.example.slowclock.data.model.Recommendation @Composable -fun ADHDRecommendation(list : List, onSelectRecommendation : (String) -> Unit, modifier: Modifier = Modifier) { +fun ADHDRecommendation( + list: List, + onSelectRecommendation: (String) -> Unit, + modifier: Modifier = Modifier, +) { val recommendations = list.filter { it.type == "ADHD" || it.type == "일반" } Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), ) { Text("추천 일정", style = MaterialTheme.typography.titleMedium) LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(count = recommendations.size) { index -> val item = recommendations[index] Card( shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), colors = CardDefaults.cardColors(containerColor = Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - border = BorderStroke(1.dp, Color(0xFF1A73E8)) + border = BorderStroke(1.dp, Color(0xFF1A73E8)), ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp) + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 12.dp), ) { Column(modifier = Modifier.weight(1f)) { Text( text = item.title, style = MaterialTheme.typography.bodyLarge, - color = Color.Black + color = Color.Black, ) Text( text = item.type, // 필요한 경우 실제 시간 추가 가능 style = MaterialTheme.typography.bodyLarge, color = Color.Gray, - fontSize = 12.sp + fontSize = 12.sp, ) } @@ -78,4 +86,4 @@ fun ADHDRecommendation(list : List, onSelectRecommendation : (St } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/recommendation/components/ElderlyRecommendation.kt b/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/ElderlyRecommendation.kt similarity index 76% rename from app/src/main/java/com/example/slowclock/ui/recommendation/components/ElderlyRecommendation.kt rename to feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/ElderlyRecommendation.kt index eba66b0..a8f2173 100644 --- a/app/src/main/java/com/example/slowclock/ui/recommendation/components/ElderlyRecommendation.kt +++ b/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/ElderlyRecommendation.kt @@ -24,49 +24,57 @@ import androidx.compose.ui.unit.sp import com.example.slowclock.data.model.Recommendation @Composable -fun ElderlyRecommendation(list : List, onSelectRecommendation : (String) -> Unit, modifier: Modifier = Modifier) { +fun ElderlyRecommendation( + list: List, + onSelectRecommendation: (String) -> Unit, + modifier: Modifier = Modifier, +) { val recommendations = list.filter { it.type == "노인" || it.type == "일반" } Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), ) { Text("추천 일정", style = MaterialTheme.typography.titleMedium) LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(count = recommendations.size) { index -> val item = recommendations[index] Card( shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), colors = CardDefaults.cardColors(containerColor = Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - border = BorderStroke(1.dp, Color(0xFF1A73E8)) + border = BorderStroke(1.dp, Color(0xFF1A73E8)), ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp) + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 12.dp), ) { Column(modifier = Modifier.weight(1f)) { Text( text = item.title, style = MaterialTheme.typography.bodyLarge, - color = Color.Black + color = Color.Black, ) Text( text = item.type, // 필요한 경우 실제 시간 추가 가능 style = MaterialTheme.typography.bodyLarge, color = Color.Gray, - fontSize = 12.sp + fontSize = 12.sp, ) } @@ -78,4 +86,4 @@ fun ElderlyRecommendation(list : List, onSelectRecommendation : } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/slowclock/ui/recommendation/components/StudentRecommendation.kt b/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/StudentRecommendation.kt similarity index 76% rename from app/src/main/java/com/example/slowclock/ui/recommendation/components/StudentRecommendation.kt rename to feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/StudentRecommendation.kt index 85f0996..84f4b6e 100644 --- a/app/src/main/java/com/example/slowclock/ui/recommendation/components/StudentRecommendation.kt +++ b/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/StudentRecommendation.kt @@ -24,49 +24,57 @@ import androidx.compose.ui.unit.sp import com.example.slowclock.data.model.Recommendation @Composable -fun StudentRecommendation(list : List, onSelectRecommendation : (String) -> Unit, modifier: Modifier = Modifier) { +fun StudentRecommendation( + list: List, + onSelectRecommendation: (String) -> Unit, + modifier: Modifier = Modifier, +) { val recommendations = list.filter { it.type == "학생" || it.type == "일반" } Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), ) { Text("추천 일정", style = MaterialTheme.typography.titleMedium) LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(count = recommendations.size) { index -> val item = recommendations[index] Card( shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), colors = CardDefaults.cardColors(containerColor = Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - border = BorderStroke(1.dp, Color(0xFF1A73E8)) + border = BorderStroke(1.dp, Color(0xFF1A73E8)), ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp) + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 12.dp), ) { Column(modifier = Modifier.weight(1f)) { Text( text = item.title, style = MaterialTheme.typography.bodyLarge, - color = Color.Black + color = Color.Black, ) Text( text = item.type, // 필요한 경우 실제 시간 추가 가능 style = MaterialTheme.typography.bodyLarge, color = Color.Gray, - fontSize = 12.sp + fontSize = 12.sp, ) } @@ -78,4 +86,4 @@ fun StudentRecommendation(list : List, onSelectRecommendation : } } } -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8c86d5..01ffb65 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.10.1" +agp = "8.13.2" kotlin = "2.0.21" coreKtx = "1.16.0" junit = "4.13.2" @@ -28,6 +28,11 @@ ktlintGradle = "12.1.1" composeRules = "0.5.6" screenshot = "0.0.1-alpha15" firebaseAppDistribution = "5.2.1" +# --- 멀티모듈 DI --- +hilt = "2.52" +ksp = "2.0.21-1.0.28" +hiltNavigationCompose = "1.2.0" +kotlinxSerialization = "1.7.3" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -35,6 +40,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version = "2025.05.01" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } @@ -67,12 +73,26 @@ androidx-material = { group = "androidx.compose.material", name = "material", ve # 운영 툴링 compose-rules = { group = "io.nlopez.compose.rules", name = "ktlint", version.ref = "composeRules" } screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" } +# 멀티모듈 DI (Hilt) +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" } +# build-logic 컨벤션 플러그인이 classpath 로 쓰는 gradle 플러그인 좌표 +android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } +kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +compose-compiler-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } +ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +hilt-gradlePlugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ktlint-gradle = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } compose-screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot" } firebase-app-distribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 029e5c6..b48ccb3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sat May 17 16:03:37 KST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 6b26778..0818ade 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { @@ -21,3 +24,14 @@ dependencyResolutionManagement { rootProject.name = "SlowClock" include(":app") +include(":core:model") +include(":core:common") +include(":core:ui") +include(":core:alarm") +include(":core:data") +include(":feature:main") +include(":feature:addschedule") +include(":feature:recommendation") +include(":feature:information") +include(":feature:profile") +include(":feature:familygroup") diff --git a/wiki/Architecture.md b/wiki/Architecture.md new file mode 100644 index 0000000..f03e34e --- /dev/null +++ b/wiki/Architecture.md @@ -0,0 +1,65 @@ +# 아키텍처 + +## 모듈 구조 (11개) + +`:app` + `:core`(5) + `:feature`(6) + +```mermaid +graph TD + app[":app"] + subgraph feature + fmain[":feature:main"] + fadd[":feature:addschedule"] + frec[":feature:recommendation"] + finfo[":feature:information"] + fprof[":feature:profile"] + ffam[":feature:familygroup"] + end + subgraph core + cui[":core:ui"] + cdata[":core:data"] + calarm[":core:alarm"] + cmodel[":core:model"] + ccommon[":core:common"] + end + app --> feature + feature --> cui + fmain --> cdata + fadd --> cdata + fprof --> cdata + ffam --> cdata + cdata --> calarm + cui --> cmodel + cdata --> cmodel + calarm --> cmodel + cui --> ccommon + cdata --> ccommon + calarm --> ccommon +``` + +| 모듈 | 내용 | +|---|---| +| `:app` | MainActivity · Application · AppNavigation · auth · debug | +| `:feature:main` | main · done · timeline · settings (MainViewModel 공유) | +| `:feature:addschedule` | 일정 추가/편집 | +| `:feature:recommendation` / `:information` / `:profile` / `:familygroup` | 각 화면 | +| `:core:ui` | 테마 · 공용 컴포넌트(Compose api 노출) | +| `:core:data` | Repository · FirestoreDB · VertexAI (Hilt) | +| `:core:alarm` | notification · receiver · 알람 UI · FCM | +| `:core:model` | 엔티티(Schedule · User · …) | +| `:core:common` | util · constants | + +## 의존 규칙 +- `:app` → feature → core **(단방향 DAG, 순환 없음)** +- `:core:data` → `:core:alarm` : Repository가 `GuardianNotifier`(FCM)를 호출 → 추후 인터페이스로 역결합 제거 예정(#28) +- 모든 core → `:core:model` / `:core:common` +- (과거 `util ↔ data` 순환은 `:core:common` 추출로 해소) + +## DI (Hilt) +- `@HiltAndroidApp` (Application) · `@AndroidEntryPoint` (MainActivity) +- `@HiltViewModel` + `@Inject constructor` (전 ViewModel) · Repository `@Inject constructor` +- 화면은 `hiltViewModel()`로 주입 +- ⚠ JavaPoet 충돌은 root `build.gradle.kts`에서 `javapoet:1.13.0` 강제로 해결 + +## 레이어 (Google 앱 아키텍처) +UI(Compose) → Domain(선택) → Data. **Repository가 데이터 레이어 유일 진입점.** diff --git a/wiki/Conventions.md b/wiki/Conventions.md new file mode 100644 index 0000000..58e0502 --- /dev/null +++ b/wiki/Conventions.md @@ -0,0 +1,30 @@ +# 작업 규약 + +## 이슈 우선 (issue-first) +1. GitHub 이슈 작성 (`.github/ISSUE_TEMPLATE/custom.md` 양식) +2. **Type 라벨**(Task/Bug/Feature) + Assignee 지정 +3. `feat/<이슈번호>` 브랜치 생성 (예: `feat/21`) +4. 이슈에 브랜치 링크 + +> `git checkout -b`로 임의 이름 브랜치 직행은 `git-branch-guard` hook이 **차단** — 반드시 `feat/<번호>` + 해당 이슈가 실제 존재해야 함. + +## 브랜치 / 커밋 / PR +- **자율 커밋 금지** — Android Studio 커밋 탭에서 직접 검토·커밋 (`git-commit-guard` hook) +- `git push --force` · `reset --hard` · `rebase` 등 상태 변경은 명시 지시 시에만 (`git-state-guard`) +- PR 제목 prefix → 라벨: `feat:`→Feature · `fix:`→Bug · `chore:`/`refactor:`→Task +- PR 본문에 `closed #N` → 머지 시 이슈 자동 close +- 머지는 리뷰 승인 후 (self-merge 지양) + +## Type 라벨 +`Task`(작업/잡무) · `Bug`(fix) · `Feature`(feat) — 개인 레포라 GitHub 네이티브 Issue Type 대신 라벨로 운영. + +## 자동화 (.claude/ · .github/) +- **hook:** git-branch/commit/state-guard · lsp-enforce · issue-type-guard · pending-work-audit · restore-claude-md +- **CI workflows:** lint · unit-test · screenshot · PRassign · auto-close-issue · mock-cleanup-check · release-distribution +- **보안:** Secret scanning + Push protection · Dependabot(alerts + security updates) · CodeQL + +## 아키텍처 규칙 (요약) +- UI 상태 = 화면당 단일 객체 + `StateFlow` + `collectAsStateWithLifecycle()` +- Repository 우회해 DataSource 직접 의존 금지 +- 색상 하드코딩 금지 → `ui/theme` 토큰 +- 자세한 건 `CLAUDE.md`(개인 컨벤션, gitignore) 참고 diff --git a/wiki/Dev-Setup.md b/wiki/Dev-Setup.md new file mode 100644 index 0000000..d244cec --- /dev/null +++ b/wiki/Dev-Setup.md @@ -0,0 +1,30 @@ +# 개발 환경 셋업 + +> ⚠ 아래 키 파일들은 모두 **gitignore** 대상 — 절대 커밋 금지. (secret scanning + push protection이 차단함) + +## 1. google-services.json (Firebase 기본) +- **위치:** `app/google-services.json` +- Firebase 콘솔 → `slow-clock-scheduler` → 프로젝트 설정 → Android 앱(`com.example.slowclock`) → `google-services.json` 다운로드 + +## 2. service_account (Vertex AI 전용) +- **위치:** `core/data/src/main/res/raw/service_account` *(멀티모듈화로 `:app` → `:core:data`로 이동됨)* +- Firebase 콘솔 → 서비스 계정 → 새 비공개 키 생성 → 다운로드 파일명을 `service_account`로 + +## 3. 디버그 SHA-1 등록 (Google 로그인) +```bash +./gradlew signingReport # 디버그 SHA-1 확인 +``` +출력된 SHA-1을 Firebase 콘솔 Android 앱 설정에 추가. + +## 빌드 / 테스트 명령 +```bash +./gradlew :app:assembleDebug +./gradlew :app:testDebugUnitTest +./gradlew :app:lintDebug +./gradlew ktlintCheck # ktlintFormat 으로 자동수정 +./gradlew :app:validateScreenshotTest # baseline 갱신: updateScreenshotTest +``` + +## CI 시크릿 (GitHub Actions) +- `GOOGLE_SERVICES_JSON_B64` — lint/unit-test/screenshot/release 공통 +- release 배포 시: `RELEASE_STORE_FILE_B64` · `RELEASE_STORE_PASSWORD` · `RELEASE_KEY_ALIAS` · `RELEASE_KEY_PASSWORD` · `FIREBASE_SERVICE_ACCOUNT_JSON` diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 0000000..bfb8e96 --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,17 @@ +# SlowClock 위키 + +고령자 접근성 중심 **일정·알람 Android 앱**. 멀티모듈 + Hilt + Firebase/Vertex AI. + +## 📚 문서 +- **[아키텍처](Architecture)** — 11모듈 구조 · Hilt DI · 의존 규칙 +- **[개발 환경 셋업](Dev-Setup)** — google-services.json · service_account · SHA-1 · 빌드 명령 +- **[작업 규약](Conventions)** — 이슈 우선 · `feat/` 브랜치 · hook · PR + +## 🧱 스택 +- Kotlin · Jetpack Compose · Material3 +- Firebase(Firestore/Auth/FCM/Analytics) · Vertex AI · Google Calendar +- Hilt DI · Gradle 버전 카탈로그(`gradle/libs.versions.toml`) +- compileSdk 35 / minSdk 32 / JVM 11 · AGP 8.10.1 · Kotlin 2.0.21 + +## 🗺 진행 +이슈/로드맵은 [Projects 보드](https://github.com/users/1hyok/projects) · [Issues](https://github.com/1hyok/SlowClock/issues) 참고.