diff --git a/.editorconfig b/.editorconfig
index 1ed859d4..1b764e08 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -15,6 +15,9 @@ ktlint_code_style = android_studio
# 한줄 최대 길이를 넘지 않는 선에서 함수 시그니처 작성 필수 여부
ktlint_standard_function-signature = disabled
+# 한 줄의 최대 길이 제한 여부
+ktlint_standard_max-line-length = disabled
+
# import 순서 필수 여부
ktlint_standard_import-ordering = disabled
@@ -30,11 +33,12 @@ ktlint_standard_no-blank-line-before-rbrace = disabled
# 들여쓰기와 KDoc(코틀린 문서 주석)을 제외하고, 연속된 여러 개의 공백 제한 여부
ktlint_no-multi-spaces = disabled
+## Intellij 속성 사용 사유 : https://slack-chats.kotlinlang.org/t/22321401/i-am-trying-to-reconfigure-out-ktlint-settings-for-a-project
# 호출부 Trailing comma 사용 필수 여부
-ktlint_standard_trailing-comma-on-call-site = disabled
+ij_kotlin_allow_trailing_comma_on_call_site = true
# 정의부 Trailing comma 사용 필수 여부
-ktlint_standard_trailing-comma-on-declaration-site = enabled
+ij_kotlin_allow_trailing_comma = true
# = 이후의 표현식이 한 줄에 맞지 않을 때, 줄바꿈 사용 필수 여부
ktlint_standard_multiline_expression_wrapping = disabled
@@ -46,4 +50,7 @@ ktlint_standard_parameter-list-wrapping = disabled
ktlint_standard_no-wildcard-imports = disabled
# 파일의 마지막 줄에 개행문자 삽입 필수 여부
-ktlint_standard_final-newline = disabled
\ No newline at end of file
+ktlint_standard_final-newline = disabled
+
+# Property 이름 컨벤션 제한 여부, 첫글자 소문자, camelCase
+ktlint_standard_property-naming = disabled
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/issue-form.yml b/.github/ISSUE_TEMPLATE/issue-form.yml
new file mode 100644
index 00000000..8e0b06b9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/issue-form.yml
@@ -0,0 +1,49 @@
+name: '이슈 생성'
+description: 'Repo에 이슈를 생성하며, 생성된 이슈는 Jira와 연동됩니다.'
+title: '이슈 이름을 작성해주세요'
+body:
+ - type: input
+ id: parentKey
+ attributes:
+ label: '🎟️ 상위 작업 (Ticket Number)'
+ description: '상위 작업의 Ticket Number를 기입해주세요'
+ placeholder: 'BP-00'
+ validations:
+ required: true
+
+ - type: input
+ id: description
+ attributes:
+ label: '📝 상세 내용(Description)'
+ description: '이슈에 대해서 간략히 설명해주세요'
+ validations:
+ required: true
+
+ - type: dropdown
+ id: issueLabel
+ attributes:
+ label: '🏷️ label'
+ description: '이슈의 유형을 선택해주세요'
+ multiple: true
+ options:
+ - feat
+ - fix
+ - bug
+ - docs
+ - refactor
+ - chore
+ - HOTFIX
+ validations:
+ required: true
+
+ - type: textarea
+ id: tasks
+ attributes:
+ label: '✅ 체크리스트(Tasks)'
+ description: '해당 이슈에 대해 필요한 작업목록을 작성해주세요'
+ value: |
+ - [ ] Task1
+ - [ ] Task2
+ - [ ] Task3
+ validations:
+ required: true
diff --git a/.github/workflows/close-jira-issue.yml b/.github/workflows/close-jira-issue.yml
new file mode 100644
index 00000000..43e7ac3b
--- /dev/null
+++ b/.github/workflows/close-jira-issue.yml
@@ -0,0 +1,32 @@
+name: Close Jira issue
+on:
+ issues:
+ types:
+ - closed
+
+jobs:
+ close-issue:
+ name: Close Jira issue
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Login to Jira
+ uses: atlassian/gajira-login@v3
+ env:
+ JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
+ JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
+
+ - name: Extract Jira issue key from GitHub issue title
+ id: extract-key
+ run: |
+ ISSUE_TITLE="${{ github.event.issue.title }}"
+ JIRA_KEY=$(echo "$ISSUE_TITLE" | grep -oE '[A-Z]+-[0-9]+')
+ echo "JIRA_KEY=$JIRA_KEY" >> $GITHUB_ENV
+
+ - name: Close Jira issue
+ if: env.JIRA_KEY != ''
+ uses: atlassian/gajira-transition@v3
+ with:
+ issue: ${{ env.JIRA_KEY }}
+ transition: 완료
diff --git a/.github/workflows/create-jira-issue.yml b/.github/workflows/create-jira-issue.yml
new file mode 100644
index 00000000..616fa322
--- /dev/null
+++ b/.github/workflows/create-jira-issue.yml
@@ -0,0 +1,81 @@
+name: Create Jira issue
+on:
+ issues:
+ types:
+ - opened
+jobs:
+ create-issue:
+ name: Create Jira issue
+ runs-on: ubuntu-latest
+ steps:
+ - name: Login
+ uses: atlassian/gajira-login@v3
+ env:
+ JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
+ JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
+
+ - name: Checkout dev code
+ uses: actions/checkout@v4
+ with:
+ ref: dev
+
+ - name: Issue Parser
+ uses: stefanbuck/github-issue-praser@v3
+ id: issue-parser
+ with:
+ template-path: .github/ISSUE_TEMPLATE/issue-form.yml
+
+ - name: Convert markdown to Jira Syntax
+ uses: peter-evans/jira2md@v1
+ id: md2jira
+ with:
+ input-text: |
+ ### Github Issue Link
+ - ${{ github.event.issue.html_url }}
+
+ ${{ github.event.issue.body }}
+ mode: md2jira
+
+ - name: Create Issue
+ id: create
+ uses: atlassian/gajira-create@v3
+ with:
+ project: BP
+ issuetype: 하위 작업
+ summary: '${{ github.event.issue.title }}'
+ description: '${{ steps.md2jira.outputs.output-text }}'
+ fields: |
+ {
+ "parent": {
+ "key": "${{ steps.issue-parser.outputs.issueparser_parentKey }}"
+ }
+ }
+
+ - name: Log created issue
+ run: echo "Jira Issue ${{ steps.issue-parser.outputs.issueparser_parentKey }}/${{ steps.create.outputs.issue }} was created"
+
+ - name: Create branch with Ticket number
+ run: |
+ LABELS="${{ steps.issue-parser.outputs.issueparser_issueLabel }}"
+ BRANCH_PREFIX="${LABELS%%,*}"
+ ISSUE_NUMBER="${{ steps.create.outputs.issue }}"
+ BRANCH_NAME="${BRANCH_PREFIX}/${ISSUE_NUMBER}"
+ git checkout -b "${BRANCH_NAME}"
+ git push origin "${BRANCH_NAME}"
+
+ - name: Update issue title
+ uses: actions-cool/issues-helper@v3
+ with:
+ actions: "update-issue"
+ token: ${{ secrets.GITHUB_TOKEN }}
+ title: "[${{ steps.create.outputs.issue }}] ${{ github.event.issue.title }}"
+
+ - name: Add comment with Jira issue link
+ uses: actions-cool/issues-helper@v3
+ with:
+ actions: 'create-comment'
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ github.event.issue.number }}
+ body: 'Jira Issue Created: [${{ steps.create.outputs.issue }}](${{ secrets.JIRA_BASE_URL }}/browse/${{ steps.create.outputs.issue }})'
+
diff --git a/.github/workflows/pull-request-ci.yaml b/.github/workflows/pull-request-ci.yaml
index a54ed940..351b7fc3 100644
--- a/.github/workflows/pull-request-ci.yaml
+++ b/.github/workflows/pull-request-ci.yaml
@@ -1,43 +1,63 @@
name: Pull Request CI
on:
- pull_request:
- branches: [ "main", "develop" ]
+ pull_request:
+ branches: [ "main", "develop" ]
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
jobs:
- build:
- name: Check Code Quality and Build
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Cache Gradle packages
- uses: actions/cache@v4
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/buildSrc/**/*.kt') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
-
- - name: set up JDK 17
- uses: actions/setup-java@v4
- with:
- java-version: '17'
- distribution: 'temurin'
- cache: gradle
-
- - name: Grant execute permission for gradlew
- run: chmod +x gradlew
-
- - name: Ktlint Check
- run: ./gradlew ktlintCheck
-
- - name: Detekt Check
- run: ./gradlew detekt
-
- - name: Build Check
- run: ./gradlew build
+ build:
+ if: >
+ !contains(github.event.head_commit.message, 'ci 자동 ktlintFormat 적용')
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Cache Gradle packages
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/buildSrc/**/*.kt') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Restore google-services.json
+ env:
+ GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
+ run: echo $GOOGLE_SERVICES_JSON | base64 --decode > ./app/google-services.json
+
+ - name: Add Local Properties
+ env:
+ KAKAO_REST_API_KEY_RELEASE: ${{ secrets.KAKAO_REST_API_KEY_RELEASE }}
+ KAKAO_REST_API_KEY_DEBUG: ${{ secrets.KAKAO_REST_API_KEY_DEBUG }}
+ KAKAO_JS_KEY_RELEASE: ${{ secrets.KAKAO_JS_KEY_RELEASE }}
+ KAKAO_JS_KEY_DEBUG: ${{ secrets.KAKAO_JS_KEY_DEBUG }}
+ run: |
+ echo KAKAO_REST_API_KEY_RELEASE=KAKAO_REST_API_KEY_RELEASE > ./local.properties
+ echo KAKAO_REST_API_KEY_DEBUG=KAKAO_REST_API_KEY_DEBUG >> ./local.properties
+ echo KAKAO_JS_KEY_RELEASE=KAKAO_JS_KEY_RELEASE >> ./local.properties
+ echo KAKAO_JS_KEY_DEBUG=KAKAO_JS_KEY_DEBUG >> ./local.properties
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Ktlint Check
+ run: ./gradlew ktlintCheck
+
+ - name: Detekt Check
+ run: ./gradlew detekt
diff --git a/.github/workflows/push-ci.yaml b/.github/workflows/push-ci.yaml
new file mode 100644
index 00000000..eb99dd30
--- /dev/null
+++ b/.github/workflows/push-ci.yaml
@@ -0,0 +1,84 @@
+name: Push CI
+
+on:
+ push:
+
+jobs:
+ build:
+ if: >
+ !contains(github.event.head_commit.message, 'ci 자동 ktlintFormat 적용')
+
+ permissions:
+ contents: write
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Cache Gradle packages
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/buildSrc/**/*.kt') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Restore google-services.json
+ env:
+ GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
+ run: echo $GOOGLE_SERVICES_JSON | base64 --decode > ./app/google-services.json
+
+ - name: Add Local Properties
+ env:
+ KAKAO_REST_API_KEY_RELEASE: ${{ secrets.KAKAO_REST_API_KEY_RELEASE }}
+ KAKAO_REST_API_KEY_DEBUG: ${{ secrets.KAKAO_REST_API_KEY_DEBUG }}
+ KAKAO_JS_KEY_RELEASE: ${{ secrets.KAKAO_JS_KEY_RELEASE }}
+ KAKAO_JS_KEY_DEBUG: ${{ secrets.KAKAO_JS_KEY_DEBUG }}
+ run: |
+ echo KAKAO_REST_API_KEY_RELEASE=KAKAO_REST_API_KEY_RELEASE > ./local.properties
+ echo KAKAO_REST_API_KEY_DEBUG=KAKAO_REST_API_KEY_DEBUG >> ./local.properties
+ echo KAKAO_JS_KEY_RELEASE=KAKAO_JS_KEY_RELEASE >> ./local.properties
+ echo KAKAO_JS_KEY_DEBUG=KAKAO_JS_KEY_DEBUG >> ./local.properties
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Build Check
+ run: ./gradlew build
+
+ - name: Commit & Push ktlintFormat changes
+ run: |
+ mkdir -p ~/.ssh
+ echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_rsa
+ chmod 600 ~/.ssh/id_rsa
+ ssh-keyscan github.com >> ~/.ssh/known_hosts
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ ISSUE_REF=$(git log -1 --pretty=%B | grep -oE '#[0-9]+' | head -n 1)
+
+ git add .
+
+ if ! git diff --cached --quiet; then
+ COMMIT_MSG="style: ci 자동 ktlintFormat 적용"
+ if [ -n "$ISSUE_REF" ]; then
+ COMMIT_MSG="$COMMIT_MSG ($ISSUE_REF)"
+ fi
+ git commit -m "$COMMIT_MSG"
+ git remote set-url origin git@github.com:${{ github.repository }}.git
+ git push origin HEAD:${GITHUB_REF_NAME}
+ else
+ echo "✅ 변경사항이 없어 커밋하지 않음."
+ fi
diff --git a/.gitignore b/.gitignore
index 67e8b52f..3b16a998 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,6 +44,7 @@ output.json
# Google Services (e.g. APIs or Firebase)
# google-services.json
+**/google-services.json
# Freeline
freeline.py
@@ -156,4 +157,4 @@ fabric.properties
!/gradle/wrapper/gradle-wrapper.jar
-# End of https://www.toptal.com/developers/gitignore/api/androidstudio,android
\ No newline at end of file
+# End of https://www.toptal.com/developers/gitignore/api/androidstudio,android
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..4cd5495a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,103 @@
+## Brake!
+
+
+
+
+## Download
+
+
+
+
+
+
+
+
+
+## Overview
+
+
+
+ | 구글 로그인 |
+ 카카오 로그인 (REST API) |
+ 카카오 간편 로그인 (JS) |
+
+
+  |
+  |
+  |
+
+
+
+
+
+
+
+ | 고지창 |
+ 온보딩 |
+ 권한허용 |
+
+
+  |
+  |
+  |
+
+
+
+
+
+
+
+ | 그룹 생성 |
+ 사용 시간 설정 |
+ 그룹 추가 및 수정 |
+
+
+  |
+  |
+  |
+
+
+
+
+
+
+
+ | 사용 완료 후 연장 |
+ 사용 완료 후 금지 |
+ 사용중 조기 금지 |
+
+
+  |
+  |
+  |
+
+
+
+
+
+
+
+ | 리포트 화면 |
+ 설정 화면 리디렉팅 버튼 |
+ 닉네임 변경 |
+
+
+  |
+  |
+  |
+
+
+
+
+
+
+
+ | 로그아웃 |
+ 회원탈퇴 |
+
+
+  |
+  |
+
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a6701636..598f7494 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,42 +1,80 @@
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.TimeZone
+
plugins {
- alias(libs.plugins.breake.android.application)
+ alias(libs.plugins.brake.android.application)
+ alias(libs.plugins.brake.work.hilt)
id("com.google.android.gms.oss-licenses-plugin")
+ alias(libs.plugins.google.services)
+ alias(libs.plugins.firebase.crashlytics)
alias(libs.plugins.baselineprofile)
alias(libs.plugins.roborazzi.plugin)
}
android {
- namespace = "com.yapp.breake"
+ namespace = "com.teambrake.brake"
defaultConfig {
- applicationId = "com.yapp.breake"
+ applicationId = "com.teambrake.brake"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "META-INF/LICENSE.md"
+ excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
}
}
buildTypes {
release {
- isMinifyEnabled = false
+ isMinifyEnabled = true
+ isShrinkResources = true
+ isDebuggable = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("debug")
}
}
+
+ applicationVariants.all {
+ val variant = this
+ variant.outputs
+ .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
+ .forEach { output ->
+ val currentTime = SimpleDateFormat("yyyy.MM.dd HH-mm")
+ currentTime.timeZone = TimeZone.getTimeZone("Asia/Seoul")
+ val buildType = variant.buildType.name
+ output.outputFileName = "[Brake_${buildType}_v${variant.versionName}]_${currentTime.format(Date())}.apk"
+ }
+ }
+
buildFeatures {
buildConfig = true
}
}
dependencies {
+ implementation(projects.core.auth)
+ implementation(projects.core.alarm)
+ implementation(projects.core.detection)
implementation(projects.core.navigation)
+ implementation(projects.core.designsystem)
+ implementation(projects.data)
+ implementation(projects.dataTest)
+ implementation(projects.domain)
implementation(projects.presentation.main)
implementation(projects.presentation.home)
+ implementation(projects.overlay.main)
- implementation(projects.core.designsystem)
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.analytics)
+ implementation(libs.firebase.crashlytics)
implementation(libs.androidx.profileinstaller)
-
testImplementation(projects.core.testing)
-}
\ No newline at end of file
+}
+
+tasks.register("printVersionName") {
+ doLast {
+ println(android.defaultConfig.versionName)
+ }
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 481bb434..3ae1ff80 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -18,4 +18,34 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+
+## ------------------ kakao -------------------
+-keep class com.kakao.sdk.**.model.* { ; }
+
+# https://github.com/square/okhttp/pull/6792
+-dontwarn org.bouncycastle.jsse.**
+-dontwarn org.conscrypt.*
+-dontwarn org.openjsse.**
+
+# refrofit2 (with r8 full mode)
+-if interface * { @retrofit2.http.* ; }
+-keep,allowobfuscation interface <1>
+-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
+-if interface * { @retrofit2.http.* public *** *(...); }
+-keep,allowoptimization,allowshrinking,allowobfuscation class <3>
+-keep,allowobfuscation,allowshrinking class retrofit2.Response
+## ------------------ kakao -------------------
+
+# navigation.route 패키지 전체 보호
+-keep class com.teambrake.brake.core.navigation.route.** { *; }
+
+# Parcelize 및 관련된 클래스 보존
+-keep @kotlinx.parcelize.Parcelize class * { *; }
+-keepclassmembers class * implements android.os.Parcelable {
+ public static final android.os.Parcelable$Creator CREATOR;
+}
+-keep class com.teambrake.brake.core.util.OverlayData { *; }
+-keep class com.teambrake.brake.core.util.OverlayData$* { *; }
+-keep enum com.teambrake.brake.core.model.app.AppGroupState { *; }
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4621b88b..1aa12b59 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,15 +2,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:theme="@style/Theme.Brake.Splash">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/ic_brake_launcher-playstore.png b/app/src/main/ic_brake_launcher-playstore.png
new file mode 100644
index 00000000..82539485
Binary files /dev/null and b/app/src/main/ic_brake_launcher-playstore.png differ
diff --git a/app/src/main/java/com/teambrake/brake/BrakeApplication.kt b/app/src/main/java/com/teambrake/brake/BrakeApplication.kt
new file mode 100644
index 00000000..7a2a5b8a
--- /dev/null
+++ b/app/src/main/java/com/teambrake/brake/BrakeApplication.kt
@@ -0,0 +1,60 @@
+package com.teambrake.brake
+
+import android.app.Application
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
+import androidx.work.WorkManager
+import com.teambrake.brake.core.auth.google.GoogleAuthManager
+import dagger.hilt.android.HiltAndroidApp
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltAndroidApp
+class BrakeApplication :
+ Application(),
+ Configuration.Provider {
+
+ @Inject lateinit var googleAuthManager: GoogleAuthManager
+
+ @Inject lateinit var workerFactory: HiltWorkerFactory
+
+ /**
+ * WorkManager 설정을 제공하는 프로퍼티
+ *
+ * HiltWorker 2.1.0 이상 버전의 WorkManager 초기화 공식 방식
+ */
+ override val workManagerConfiguration: Configuration
+ get() = Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .build()
+
+ override fun onCreate() {
+ super.onCreate()
+
+ // GoogleAuthManager 초기화
+ googleAuthManager.initializeAuthorizationRequest(
+ context = this,
+ // 컴파일 타임 때 google-services.json 에서 web client id 의 string resource 생성
+ serverClientId = getString(R.string.default_web_client_id),
+ )
+
+ // WorkManager 초기화
+ WorkManager.initialize(this, workManagerConfiguration)
+
+ initTimber()
+ }
+
+ private fun initTimber() {
+ if (BuildConfig.DEBUG) {
+ Timber.plant(
+ object : Timber.DebugTree() {
+ override fun createStackElementTag(element: StackTraceElement): String {
+ val fullClassName = element.className
+ val className = fullClassName.substringAfterLast('.')
+ return "BRAKE/$className"
+ }
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/teambrake/brake/di/FirebaseModule.kt b/app/src/main/java/com/teambrake/brake/di/FirebaseModule.kt
new file mode 100644
index 00000000..0f102162
--- /dev/null
+++ b/app/src/main/java/com/teambrake/brake/di/FirebaseModule.kt
@@ -0,0 +1,19 @@
+package com.teambrake.brake.di
+
+import android.content.Context
+import com.google.firebase.analytics.FirebaseAnalytics
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object FirebaseModule {
+
+ @Provides
+ @Singleton
+ fun provideFirebaseAnalytics(@ApplicationContext context: Context): FirebaseAnalytics = FirebaseAnalytics.getInstance(context)
+}
diff --git a/app/src/main/java/com/teambrake/brake/di/UseCaseModule.kt b/app/src/main/java/com/teambrake/brake/di/UseCaseModule.kt
new file mode 100644
index 00000000..3582cf19
--- /dev/null
+++ b/app/src/main/java/com/teambrake/brake/di/UseCaseModule.kt
@@ -0,0 +1,123 @@
+package com.teambrake.brake.di
+
+import com.teambrake.brake.domain.usecase.CreateNewGroupUseCase
+import com.teambrake.brake.domain.usecase.DecideNextDestinationFromPermissionUseCase
+import com.teambrake.brake.domain.usecase.DecideStartDestinationUseCase
+import com.teambrake.brake.domain.usecase.DeleteAccountUseCase
+import com.teambrake.brake.domain.usecase.DeleteGroupUseCase
+import com.teambrake.brake.domain.usecase.FindAppGroupUseCase
+import com.teambrake.brake.domain.usecase.GetNicknameUseCase
+import com.teambrake.brake.domain.usecase.GrantNewGroupIdUseCase
+import com.teambrake.brake.domain.usecase.LoginUseCase
+import com.teambrake.brake.domain.usecase.LogoutUseCase
+import com.teambrake.brake.domain.usecase.ResetAppGroupUsecase
+import com.teambrake.brake.domain.usecase.SetAlarmUseCase
+import com.teambrake.brake.domain.usecase.SetBlockingAlarmUseCase
+import com.teambrake.brake.domain.usecase.SetSnoozeAlarmUseCase
+import com.teambrake.brake.domain.usecase.StoreOnboardingCompletionUseCase
+import com.teambrake.brake.domain.usecase.UpdateNicknameUseCase
+import com.teambrake.brake.domain.usecaseImpl.CreateNewGroupUseCaseImpl
+import com.teambrake.brake.domain.usecaseImpl.DecideNextDestinationFromPermissionUseCaseImpl
+import com.teambrake.brake.domain.usecaseImpl.DecideStartDestinationUseCaseImpl
+import com.teambrake.brake.domain.usecaseImpl.DeleteAccountUseCaseImpl
+import com.teambrake.brake.domain.usecaseImpl.DeleteGroupUseCaseImpl
+import com.teambrake.brake.domain.usecaseImpl.FindAppGroupUsecaseImpl
+import com.teambrake.brake.domain.usecaseImpl.GetNicknameUseCaseImpl
+import com.teambrake.brake.domain.usecaseImpl.GrantNewGroupIdUseCaseImpl
+import com.teambrake.brake.domain.usecaseImpl.LoginUseCaseImpl
+import com.teambrake.brake.domain.usecaseImpl.LogoutUseCaseImpl
+import com.teambrake.brake.domain.usecaseImpl.ResetAppGroupUsecaseImpl
+import com.teambrake.brake.domain.usecaseImpl.SetAlarmUsecaseImpl
+import com.teambrake.brake.domain.usecaseImpl.SetBlockingAlarmUseCaseImpl
+import com.teambrake.brake.domain.usecaseImpl.SetSnoozeAlarmUsecaseImpl
+import com.teambrake.brake.domain.usecaseImpl.StoreOnboardingCompletionUseCaseImpl
+import com.teambrake.brake.domain.usecaseImpl.UpdateNicknameUseCaseImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class UseCaseModule {
+
+ @Binds
+ abstract fun bindLoginUseCase(
+ loginUseCase: LoginUseCaseImpl,
+ ): LoginUseCase
+
+ @Binds
+ abstract fun bindUpdateNicknameUseCase(
+ updateNicknameUseCase: UpdateNicknameUseCaseImpl,
+ ): UpdateNicknameUseCase
+
+ @Binds
+ abstract fun bindGetNicknameUseCase(
+ getNicknameUseCase: GetNicknameUseCaseImpl,
+ ): GetNicknameUseCase
+
+ @Binds
+ abstract fun bindStoreOnboardingCompletionUseCase(
+ storeOnboardingCompletionUseCase: StoreOnboardingCompletionUseCaseImpl,
+ ): StoreOnboardingCompletionUseCase
+
+ @Binds
+ abstract fun bindDecideStartDestinationUseCase(
+ decideStartDestinationUseCase: DecideStartDestinationUseCaseImpl,
+ ): DecideStartDestinationUseCase
+
+ @Binds
+ abstract fun bindDecideNextDestinationFromPermissionUseCase(
+ decideNextDestinationFromPermissionUseCase: DecideNextDestinationFromPermissionUseCaseImpl,
+ ): DecideNextDestinationFromPermissionUseCase
+
+ @Binds
+ abstract fun bindLogoutUseCase(
+ logoutUseCase: LogoutUseCaseImpl,
+ ): LogoutUseCase
+
+ @Binds
+ abstract fun bindDeleteAccountUseCase(
+ deleteAccountUseCase: DeleteAccountUseCaseImpl,
+ ): DeleteAccountUseCase
+
+ @Binds
+ abstract fun bindFindAppGroupUseCase(
+ findAppGroupUsecase: FindAppGroupUsecaseImpl,
+ ): FindAppGroupUseCase
+
+ @Binds
+ abstract fun bindSetAlarmUseCase(
+ setAlarmUsecase: SetAlarmUsecaseImpl,
+ ): SetAlarmUseCase
+
+ @Binds
+ abstract fun bindSetSnoozeAlarmUseCase(
+ setSnoozeAlarmUsecase: SetSnoozeAlarmUsecaseImpl,
+ ): SetSnoozeAlarmUseCase
+
+ @Binds
+ abstract fun bindSetBlockingAlarmUseCase(
+ setBlockingAlarmUsecase: SetBlockingAlarmUseCaseImpl,
+ ): SetBlockingAlarmUseCase
+
+ @Binds
+ abstract fun bindResetAppGroupUsecase(
+ resetAppGroupUsecase: ResetAppGroupUsecaseImpl,
+ ): ResetAppGroupUsecase
+
+ @Binds
+ abstract fun bindCreateNewGroupUseCase(
+ createNewGroupUseCase: CreateNewGroupUseCaseImpl,
+ ): CreateNewGroupUseCase
+
+ @Binds
+ abstract fun bindDeleteGroupUseCase(
+ deleteGroupUseCase: DeleteGroupUseCaseImpl,
+ ): DeleteGroupUseCase
+
+ @Binds
+ abstract fun bindGrantNewGroupIdUseCase(
+ grantNewGroupIdUseCase: GrantNewGroupIdUseCaseImpl,
+ ): GrantNewGroupIdUseCase
+}
diff --git a/app/src/main/java/com/yapp/breake/BreakeApplication.kt b/app/src/main/java/com/yapp/breake/BreakeApplication.kt
deleted file mode 100644
index ebe70385..00000000
--- a/app/src/main/java/com/yapp/breake/BreakeApplication.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.yapp.breake
-
-import android.app.Application
-import dagger.hilt.android.HiltAndroidApp
-import timber.log.Timber
-
-@HiltAndroidApp
-class BreakeApplication : Application() {
- override fun onCreate() {
- super.onCreate()
- initTimber()
- }
-
- private fun initTimber() {
- if (BuildConfig.DEBUG) {
- Timber.plant(Timber.DebugTree())
- }
- }
-}
diff --git a/app/src/main/res/drawable/ic_brake_launcher_foreground.webp b/app/src/main/res/drawable/ic_brake_launcher_foreground.webp
new file mode 100644
index 00000000..99b7d41a
Binary files /dev/null and b/app/src/main/res/drawable/ic_brake_launcher_foreground.webp differ
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
deleted file mode 100644
index 07d5da9c..00000000
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ /dev/null
@@ -1,170 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
deleted file mode 100644
index 2b068d11..00000000
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml
new file mode 100644
index 00000000..526ea374
--- /dev/null
+++ b/app/src/main/res/drawable/splash_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ -
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_brake_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_brake_launcher.xml
new file mode 100644
index 00000000..f29f906b
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_brake_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_brake_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_brake_launcher_round.xml
new file mode 100644
index 00000000..f29f906b
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_brake_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
deleted file mode 100644
index 6f3b755b..00000000
--- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
deleted file mode 100644
index 6f3b755b..00000000
--- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_brake_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_brake_launcher.webp
new file mode 100644
index 00000000..76198f3a
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_brake_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_brake_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_brake_launcher_foreground.webp
new file mode 100644
index 00000000..f5624115
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_brake_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_brake_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_brake_launcher_round.webp
new file mode 100644
index 00000000..cb71d60b
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_brake_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
deleted file mode 100644
index c209e78e..00000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
deleted file mode 100644
index b2dfe3d1..00000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_brake_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_brake_launcher.webp
new file mode 100644
index 00000000..8bf84f1e
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_brake_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_brake_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_brake_launcher_foreground.webp
new file mode 100644
index 00000000..56d6f892
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_brake_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_brake_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_brake_launcher_round.webp
new file mode 100644
index 00000000..2de5699a
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_brake_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
deleted file mode 100644
index 4f0f1d64..00000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
deleted file mode 100644
index 62b611da..00000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_brake_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_brake_launcher.webp
new file mode 100644
index 00000000..818a9a5d
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_brake_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_brake_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_brake_launcher_foreground.webp
new file mode 100644
index 00000000..65da3477
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_brake_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_brake_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_brake_launcher_round.webp
new file mode 100644
index 00000000..e24f1b34
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_brake_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
deleted file mode 100644
index 948a3070..00000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
deleted file mode 100644
index 1b9a6956..00000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_brake_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_brake_launcher.webp
new file mode 100644
index 00000000..e6d4051d
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_brake_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_brake_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_brake_launcher_foreground.webp
new file mode 100644
index 00000000..bfc07901
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_brake_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_brake_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_brake_launcher_round.webp
new file mode 100644
index 00000000..6b2e021c
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_brake_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
deleted file mode 100644
index 28d4b77f..00000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9287f508..00000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_brake_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_brake_launcher.webp
new file mode 100644
index 00000000..5c61d227
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_brake_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_brake_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_brake_launcher_foreground.webp
new file mode 100644
index 00000000..99b7d41a
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_brake_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_brake_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_brake_launcher_round.webp
new file mode 100644
index 00000000..ea9fe5fc
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_brake_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
deleted file mode 100644
index aa7d6427..00000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9126ae37..00000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/values/ic_brake_launcher_background.xml b/app/src/main/res/values/ic_brake_launcher_background.xml
new file mode 100644
index 00000000..bb5b3ce0
--- /dev/null
+++ b/app/src/main/res/values/ic_brake_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #1E2023
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5fc638f0..49b1f76d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,4 @@
- Breake
-
\ No newline at end of file
+ Brake!
+ 다른 앱 실행을 감지하여 오버레이를 표시합니다. 앱 사용 시간 관리 기능을 위해 필요합니다.
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..1ab7211d
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml
new file mode 100644
index 00000000..8ef345f5
--- /dev/null
+++ b/app/src/main/res/xml/accessibility_service_config.xml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/core/database/src/main/java/com/yapp/breake/core/database/.gitkeep b/app/src/test/java/com/teambrake/brake/.gitkeep
similarity index 100%
rename from core/database/src/main/java/com/yapp/breake/core/database/.gitkeep
rename to app/src/test/java/com/teambrake/brake/.gitkeep
diff --git a/app/src/test/java/com/yapp/breake/ExampleUnitTest.kt b/app/src/test/java/com/yapp/breake/ExampleUnitTest.kt
deleted file mode 100644
index 0711c3f6..00000000
--- a/app/src/test/java/com/yapp/breake/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.yapp.breake
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts
index f74e409f..80b82253 100644
--- a/build-logic/build.gradle.kts
+++ b/build-logic/build.gradle.kts
@@ -13,16 +13,20 @@ dependencies {
gradlePlugin {
plugins {
register("androidHilt") {
- id = "breake.android.hilt"
- implementationClass = "com.yapp.breake.HiltAndroidPlugin"
+ id = "brake.android.hilt"
+ implementationClass = "com.teambrake.brake.HiltAndroidPlugin"
+ }
+ register("workHilt") {
+ id = "brake.work.hilt"
+ implementationClass = "com.teambrake.brake.HiltWorkPlugin"
}
register("kotlinHilt") {
- id = "breake.kotlin.hilt"
- implementationClass = "com.yapp.breake.HiltKotlinPlugin"
+ id = "brake.kotlin.hilt"
+ implementationClass = "com.teambrake.brake.HiltKotlinPlugin"
}
register("androidRoom") {
- id = "breake.android.room"
- implementationClass = "com.yapp.breake.AndroidRoomPlugin"
+ id = "brake.android.room"
+ implementationClass = "com.teambrake.brake.AndroidRoomPlugin"
}
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/src/main/kotlin/brake.android.application.gradle.kts b/build-logic/src/main/kotlin/brake.android.application.gradle.kts
new file mode 100644
index 00000000..56b80914
--- /dev/null
+++ b/build-logic/src/main/kotlin/brake.android.application.gradle.kts
@@ -0,0 +1,15 @@
+import com.teambrake.brake.configureHiltAndroid
+import com.teambrake.brake.configureKotestAndroid
+import com.teambrake.brake.configureKotlinAndroid
+import com.teambrake.brake.configureRoborazzi
+import com.teambrake.brake.configureTimber
+
+plugins {
+ id("com.android.application")
+}
+
+configureKotlinAndroid()
+configureHiltAndroid()
+configureKotestAndroid()
+configureRoborazzi()
+configureTimber()
diff --git a/build-logic/src/main/kotlin/brake.android.compose.gradle.kts b/build-logic/src/main/kotlin/brake.android.compose.gradle.kts
new file mode 100644
index 00000000..a22d22f7
--- /dev/null
+++ b/build-logic/src/main/kotlin/brake.android.compose.gradle.kts
@@ -0,0 +1,3 @@
+import com.teambrake.brake.configureComposeAndroid
+
+configureComposeAndroid()
diff --git a/build-logic/src/main/kotlin/breake.android.feature.gradle.kts b/build-logic/src/main/kotlin/brake.android.feature.gradle.kts
similarity index 77%
rename from build-logic/src/main/kotlin/breake.android.feature.gradle.kts
rename to build-logic/src/main/kotlin/brake.android.feature.gradle.kts
index 2513c3e6..3795600d 100644
--- a/build-logic/src/main/kotlin/breake.android.feature.gradle.kts
+++ b/build-logic/src/main/kotlin/brake.android.feature.gradle.kts
@@ -1,10 +1,11 @@
-import com.yapp.breake.libs
-import com.yapp.breake.configureHiltAndroid
-import com.yapp.breake.configureRoborazzi
+import com.teambrake.brake.configureFirebase
+import com.teambrake.brake.libs
+import com.teambrake.brake.configureHiltAndroid
+import com.teambrake.brake.configureRoborazzi
plugins {
- id("breake.android.library")
- id("breake.android.compose")
+ id("brake.android.library")
+ id("brake.android.compose")
}
android {
@@ -15,12 +16,14 @@ android {
configureHiltAndroid()
configureRoborazzi()
+configureFirebase()
dependencies {
implementation(project(":domain"))
implementation(project(":core:model"))
implementation(project(":core:designsystem"))
implementation(project(":core:navigation"))
+ implementation(project(":core:ui"))
testImplementation(project(":core:testing"))
diff --git a/build-logic/src/main/kotlin/brake.android.library.gradle.kts b/build-logic/src/main/kotlin/brake.android.library.gradle.kts
new file mode 100644
index 00000000..694d77ca
--- /dev/null
+++ b/build-logic/src/main/kotlin/brake.android.library.gradle.kts
@@ -0,0 +1,16 @@
+import com.teambrake.brake.configureCoroutineAndroid
+import com.teambrake.brake.configureHiltAndroid
+import com.teambrake.brake.configureKotest
+import com.teambrake.brake.configureKotlinAndroid
+import com.teambrake.brake.configureTimber
+
+plugins {
+ id("com.android.library")
+ id("brake.verify.detekt")
+}
+
+configureKotlinAndroid()
+configureKotest()
+configureCoroutineAndroid()
+configureHiltAndroid()
+configureTimber()
diff --git a/build-logic/src/main/kotlin/brake.kotlin.library.gradle.kts b/build-logic/src/main/kotlin/brake.kotlin.library.gradle.kts
new file mode 100644
index 00000000..13f2be3d
--- /dev/null
+++ b/build-logic/src/main/kotlin/brake.kotlin.library.gradle.kts
@@ -0,0 +1,13 @@
+import com.teambrake.brake.configureCoroutineKotlin
+import com.teambrake.brake.configureKotest
+import com.teambrake.brake.configureKotlin
+
+plugins {
+ kotlin("jvm")
+ id("brake.verify.detekt")
+}
+
+configureKotlin()
+configureCoroutineKotlin()
+configureKotest()
+configureCoroutineKotlin()
diff --git a/build-logic/src/main/kotlin/breake.verify.detekt.gradle.kts b/build-logic/src/main/kotlin/brake.verify.detekt.gradle.kts
similarity index 95%
rename from build-logic/src/main/kotlin/breake.verify.detekt.gradle.kts
rename to build-logic/src/main/kotlin/brake.verify.detekt.gradle.kts
index e716b89c..b7057f15 100644
--- a/build-logic/src/main/kotlin/breake.verify.detekt.gradle.kts
+++ b/build-logic/src/main/kotlin/brake.verify.detekt.gradle.kts
@@ -1,4 +1,4 @@
-import com.yapp.breake.configureVerifyDetekt
+import com.teambrake.brake.configureVerifyDetekt
configureVerifyDetekt()
@@ -18,4 +18,4 @@ tasks.withType().configureEach {
xml.required.set(true) // checkstyle like format mainly for integrations like Jenkins
xml.outputLocation.set(file("$rootDir/build/reports/detekt/${project.name}.xml"))
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/src/main/kotlin/breake.android.application.gradle.kts b/build-logic/src/main/kotlin/breake.android.application.gradle.kts
deleted file mode 100644
index a5c06f9e..00000000
--- a/build-logic/src/main/kotlin/breake.android.application.gradle.kts
+++ /dev/null
@@ -1,15 +0,0 @@
-import com.yapp.breake.configureHiltAndroid
-import com.yapp.breake.configureKotestAndroid
-import com.yapp.breake.configureKotlinAndroid
-import com.yapp.breake.configureRoborazzi
-import com.yapp.breake.configureTimber
-
-plugins {
- id("com.android.application")
-}
-
-configureKotlinAndroid()
-configureHiltAndroid()
-configureKotestAndroid()
-configureRoborazzi()
-configureTimber()
diff --git a/build-logic/src/main/kotlin/breake.android.compose.gradle.kts b/build-logic/src/main/kotlin/breake.android.compose.gradle.kts
deleted file mode 100644
index fe62aa59..00000000
--- a/build-logic/src/main/kotlin/breake.android.compose.gradle.kts
+++ /dev/null
@@ -1,3 +0,0 @@
-import com.yapp.breake.configureComposeAndroid
-
-configureComposeAndroid()
\ No newline at end of file
diff --git a/build-logic/src/main/kotlin/breake.android.library.gradle.kts b/build-logic/src/main/kotlin/breake.android.library.gradle.kts
deleted file mode 100644
index e81bd742..00000000
--- a/build-logic/src/main/kotlin/breake.android.library.gradle.kts
+++ /dev/null
@@ -1,16 +0,0 @@
-import com.yapp.breake.configureCoroutineAndroid
-import com.yapp.breake.configureHiltAndroid
-import com.yapp.breake.configureKotest
-import com.yapp.breake.configureKotlinAndroid
-import com.yapp.breake.configureTimber
-
-plugins {
- id("com.android.library")
- id("breake.verify.detekt")
-}
-
-configureKotlinAndroid()
-configureKotest()
-configureCoroutineAndroid()
-configureHiltAndroid()
-configureTimber()
diff --git a/build-logic/src/main/kotlin/breake.kotlin.library.gradle.kts b/build-logic/src/main/kotlin/breake.kotlin.library.gradle.kts
deleted file mode 100644
index 5ad5740c..00000000
--- a/build-logic/src/main/kotlin/breake.kotlin.library.gradle.kts
+++ /dev/null
@@ -1,10 +0,0 @@
-import com.yapp.breake.configureKotest
-import com.yapp.breake.configureKotlin
-
-plugins {
- kotlin("jvm")
- id("breake.verify.detekt")
-}
-
-configureKotlin()
-configureKotest()
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/AndroidRoomPlugin.kt b/build-logic/src/main/kotlin/com/teambrake/brake/AndroidRoomPlugin.kt
similarity index 95%
rename from build-logic/src/main/kotlin/com/yapp/breake/AndroidRoomPlugin.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/AndroidRoomPlugin.kt
index 57c8af53..9cdfcd66 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/AndroidRoomPlugin.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/AndroidRoomPlugin.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake
+package com.teambrake.brake
import org.gradle.api.Plugin
import org.gradle.api.Project
@@ -24,4 +24,4 @@ class AndroidRoomPlugin : Plugin {
configureAndroidRoom()
}
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/AppNameExtension.kt b/build-logic/src/main/kotlin/com/teambrake/brake/AppNameExtension.kt
similarity index 58%
rename from build-logic/src/main/kotlin/com/yapp/breake/AppNameExtension.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/AppNameExtension.kt
index 4d0a506f..ca0d6b22 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/AppNameExtension.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/AppNameExtension.kt
@@ -1,9 +1,9 @@
-package com.yapp.breake
+package com.teambrake.brake
import org.gradle.api.Project
fun Project.setNamespace(name: String) {
androidExtension.apply {
- namespace = "com.yapp.breake.$name"
+ namespace = "com.teambrake.brake.$name"
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/ComposeAndroid.kt b/build-logic/src/main/kotlin/com/teambrake/brake/ComposeAndroid.kt
similarity index 89%
rename from build-logic/src/main/kotlin/com/yapp/breake/ComposeAndroid.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/ComposeAndroid.kt
index 12c44175..b3762cd8 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/ComposeAndroid.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/ComposeAndroid.kt
@@ -1,10 +1,9 @@
-package com.yapp.breake
+package com.teambrake.brake
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
-import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag
internal fun Project.configureComposeAndroid() {
with(plugins) {
@@ -35,7 +34,6 @@ internal fun Project.configureComposeAndroid() {
}
extensions.getByType().apply {
- featureFlags.set(listOf(ComposeFeatureFlag.StrongSkipping))
includeSourceInformation.set(true)
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/CoroutineAndroid.kt b/build-logic/src/main/kotlin/com/teambrake/brake/CoroutineAndroid.kt
similarity index 94%
rename from build-logic/src/main/kotlin/com/yapp/breake/CoroutineAndroid.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/CoroutineAndroid.kt
index c09453ae..097795e2 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/CoroutineAndroid.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/CoroutineAndroid.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake
+package com.teambrake.brake
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@@ -17,4 +17,4 @@ internal fun Project.configureCoroutineKotlin() {
"implementation"(libs.findLibrary("coroutines.core").get())
"testImplementation"(libs.findLibrary("coroutines.test").get())
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/Extension.kt b/build-logic/src/main/kotlin/com/teambrake/brake/Extension.kt
similarity index 91%
rename from build-logic/src/main/kotlin/com/yapp/breake/Extension.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/Extension.kt
index b5a1ce87..ecf9ba5a 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/Extension.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/Extension.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake
+package com.teambrake.brake
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension
@@ -22,4 +22,4 @@ internal val Project.androidExtension: CommonExtension<*, *, *, *, *, *>
.getOrThrow()
internal val ExtensionContainer.libs: VersionCatalog
- get() = getByType().named("libs")
\ No newline at end of file
+ get() = getByType().named("libs")
diff --git a/build-logic/src/main/kotlin/com/teambrake/brake/Firebase.kt b/build-logic/src/main/kotlin/com/teambrake/brake/Firebase.kt
new file mode 100644
index 00000000..2839f5e5
--- /dev/null
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/Firebase.kt
@@ -0,0 +1,13 @@
+package com.teambrake.brake
+
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.dependencies
+
+internal fun Project.configureFirebase() {
+ val libs = extensions.libs
+ dependencies {
+ "implementation"(platform(libs.findLibrary("firebase.bom").get()))
+ "implementation"(libs.findLibrary("firebase.analytics").get())
+ "implementation"(libs.findLibrary("firebase.crashlytics").get())
+ }
+}
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/HiltAndroid.kt b/build-logic/src/main/kotlin/com/teambrake/brake/HiltAndroid.kt
similarity index 95%
rename from build-logic/src/main/kotlin/com/yapp/breake/HiltAndroid.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/HiltAndroid.kt
index 0b3dc343..65d3c120 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/HiltAndroid.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/HiltAndroid.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake
+package com.teambrake.brake
import org.gradle.api.Plugin
import org.gradle.api.Project
@@ -25,4 +25,4 @@ internal class HiltAndroidPlugin : Plugin {
configureHiltAndroid()
}
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/HiltKotlin.kt b/build-logic/src/main/kotlin/com/teambrake/brake/HiltKotlin.kt
similarity index 94%
rename from build-logic/src/main/kotlin/com/yapp/breake/HiltKotlin.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/HiltKotlin.kt
index 0b420cc9..a7072922 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/HiltKotlin.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/HiltKotlin.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake
+package com.teambrake.brake
import org.gradle.api.Plugin
import org.gradle.api.Project
@@ -23,4 +23,4 @@ internal class HiltKotlinPlugin : Plugin {
configureHiltKotlin()
}
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/src/main/kotlin/com/teambrake/brake/HiltWork.kt b/build-logic/src/main/kotlin/com/teambrake/brake/HiltWork.kt
new file mode 100644
index 00000000..01762cbd
--- /dev/null
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/HiltWork.kt
@@ -0,0 +1,28 @@
+package com.teambrake.brake
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.dependencies
+
+internal fun Project.configureHiltWork() {
+ with(pluginManager) {
+ apply("dagger.hilt.android.plugin")
+ apply("com.google.devtools.ksp")
+ }
+
+ val libs = extensions.libs
+ dependencies {
+ "implementation"(libs.findLibrary("work.runtime.ktx").get())
+ "implementation"(libs.findLibrary("work.hilt").get())
+ "ksp"(libs.findLibrary("androidx.hilt.compiler").get())
+ }
+}
+
+internal class HiltWorkPlugin : Plugin {
+
+ override fun apply(target: Project) {
+ with(target) {
+ configureHiltWork()
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/KotestAndroid.kt b/build-logic/src/main/kotlin/com/teambrake/brake/KotestAndroid.kt
similarity index 92%
rename from build-logic/src/main/kotlin/com/yapp/breake/KotestAndroid.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/KotestAndroid.kt
index e7995aa2..b13a5c8f 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/KotestAndroid.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/KotestAndroid.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake
+package com.teambrake.brake
import org.gradle.api.Project
@@ -15,4 +15,4 @@ internal fun Project.configureJUnitAndroid() {
unitTests.isReturnDefaultValues = true
}
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/KotlinAndroid.kt b/build-logic/src/main/kotlin/com/teambrake/brake/KotlinAndroid.kt
similarity index 98%
rename from build-logic/src/main/kotlin/com/yapp/breake/KotlinAndroid.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/KotlinAndroid.kt
index 0be47ebd..af8f0548 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/KotlinAndroid.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/KotlinAndroid.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake
+package com.teambrake.brake
import com.android.build.gradle.BaseExtension
import org.gradle.api.JavaVersion
@@ -61,4 +61,4 @@ internal fun Project.configureKotlin() {
)
}
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/Roborazzi.kt b/build-logic/src/main/kotlin/com/teambrake/brake/Roborazzi.kt
similarity index 91%
rename from build-logic/src/main/kotlin/com/yapp/breake/Roborazzi.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/Roborazzi.kt
index f6a186e0..1ffd8185 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/Roborazzi.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/Roborazzi.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake
+package com.teambrake.brake
import com.android.build.gradle.TestedExtension
import org.gradle.api.Project
@@ -19,7 +19,7 @@ internal fun Project.configureRoborazzi() {
it.useJUnit {
if (project.hasProperty("screenshot")) {
project.logger.lifecycle("Screenshot tests are included")
- includeCategories("com.yapp.breake.core.testing.category.ScreenshotTests")
+ includeCategories("com.teambrake.brake.core.testing.category.ScreenshotTests")
}
}
}
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/Timber.kt b/build-logic/src/main/kotlin/com/teambrake/brake/Timber.kt
similarity index 88%
rename from build-logic/src/main/kotlin/com/yapp/breake/Timber.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/Timber.kt
index ba96a0f2..6fb0f46d 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/Timber.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/Timber.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake
+package com.teambrake.brake
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/VerifyDetekt.kt b/build-logic/src/main/kotlin/com/teambrake/brake/VerifyDetekt.kt
similarity index 91%
rename from build-logic/src/main/kotlin/com/yapp/breake/VerifyDetekt.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/VerifyDetekt.kt
index 77915b47..856131fd 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/VerifyDetekt.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/VerifyDetekt.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake
+package com.teambrake.brake
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@@ -12,4 +12,4 @@ internal fun Project.configureVerifyDetekt() {
dependencies {
"detektPlugins"(libs.findLibrary("verify.detektFormatting").get())
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/src/main/kotlin/com/yapp/breake/kotestKotlin.kt b/build-logic/src/main/kotlin/com/teambrake/brake/kotestKotlin.kt
similarity index 94%
rename from build-logic/src/main/kotlin/com/yapp/breake/kotestKotlin.kt
rename to build-logic/src/main/kotlin/com/teambrake/brake/kotestKotlin.kt
index 87ca69b6..72400181 100644
--- a/build-logic/src/main/kotlin/com/yapp/breake/kotestKotlin.kt
+++ b/build-logic/src/main/kotlin/com/teambrake/brake/kotestKotlin.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake
+package com.teambrake.brake
import org.gradle.api.Project
import org.gradle.api.tasks.testing.Test
@@ -18,4 +18,4 @@ internal fun Project.configureJUnit() {
tasks.withType().configureEach {
useJUnitPlatform()
}
-}
\ No newline at end of file
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index c6e4fd43..2ef34aac 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -23,13 +23,20 @@ plugins {
alias(libs.plugins.verify.detekt) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.android.test) apply false
+ alias(libs.plugins.google.services) apply false
+ alias(libs.plugins.firebase.crashlytics) apply false
alias(libs.plugins.baselineprofile) apply false
alias(libs.plugins.roborazzi.plugin) apply false
}
-allprojects {
- apply {
- plugin(rootProject.libs.plugins.ktlint.get().pluginId)
+subprojects {
+ // 모든 subproject에 대해 ktlint 플러그인 적용
+ apply(plugin = rootProject.libs.plugins.ktlint.get().pluginId)
+
+ // ./gradlew build : 모든 kt 파일에 대해 ktlintFormat 실행
+ // android :app build : 어노테이션 미적용 kt 파일에 대해 ktlintFormat 실행
+ tasks.withType().configureEach {
+ dependsOn("ktlintFormat")
}
}
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index 5deaa498..da6e09e1 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -80,7 +80,7 @@ complexity:
active: true
ComplexCondition:
active: true
- threshold: 5
+ threshold: 10
ComplexInterface:
active: false
threshold: 10
@@ -212,7 +212,7 @@ exceptions:
ObjectExtendsThrowable:
active: false
PrintStackTrace:
- active: true
+ active: false
RethrowCaughtException:
active: true
ReturnFromFinally:
@@ -319,7 +319,7 @@ formatting:
active: true
# autoCorrect: true
NoBlankLineBeforeRbrace:
- active: true
+ active: false
# autoCorrect: true
NoConsecutiveBlankLines:
active: true
@@ -327,8 +327,8 @@ formatting:
NoEmptyClassBody:
active: true
# autoCorrect: true
-# NoEmptyFirstLineInMethodBlock:
-# active: false
+ NoEmptyFirstLineInMethodBlock:
+ active: false
# autoCorrect: true
NoLineBreakAfterElse:
active: true
@@ -461,8 +461,8 @@ naming:
# active: false
# excludes: ['**/*.kts']
# rootPackage: ''
-# MatchingDeclarationName:
-# active: true
+ MatchingDeclarationName:
+ active: false
# mustBeFirst: true
MemberNameEqualsClassName:
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
@@ -683,7 +683,7 @@ style:
# ignoreRanges: false
# ignoreExtensionFunctions: true
BracesOnIfStatements:
- active: true
+ active: false
MandatoryBracesLoops:
active: true
MaxLineLength:
@@ -763,10 +763,14 @@ style:
# active: false
UnusedImports:
active: true
+ UnusedParameter:
+ active: false
# UnusedPrivateClass:
# active: true
UnusedPrivateMember:
active: false
+ UnusedPrivateProperty:
+ active: false
# allowedNames: '(_|ignored|expected|serialVersionUID)'
# UseArrayLiteralsInAnnotations:
# active: false
diff --git a/core/alarm/.gitignore b/core/alarm/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/alarm/.gitignore
@@ -0,0 +1 @@
+/build
\ 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 00000000..c2fd639e
--- /dev/null
+++ b/core/alarm/build.gradle.kts
@@ -0,0 +1,18 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.library)
+ alias(libs.plugins.brake.android.hilt)
+ alias(libs.plugins.brake.work.hilt)
+}
+
+android {
+ setNamespace("core.alarm")
+}
+
+dependencies {
+ implementation(projects.domain)
+ implementation(projects.core.common)
+ implementation(projects.core.model)
+ implementation(projects.core.util)
+}
diff --git a/core/alarm/src/main/AndroidManifest.xml b/core/alarm/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..76073216
--- /dev/null
+++ b/core/alarm/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/core/alarm/src/main/java/com/teambrake/brake/core/alarm/di/AlarmSchedulerModule.kt b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/di/AlarmSchedulerModule.kt
new file mode 100644
index 00000000..448ce275
--- /dev/null
+++ b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/di/AlarmSchedulerModule.kt
@@ -0,0 +1,33 @@
+package com.teambrake.brake.core.alarm.di
+
+import android.app.AlarmManager
+import android.content.Context
+import com.teambrake.brake.core.alarm.scheduler.AlarmSchedulerImpl
+import com.teambrake.brake.domain.repository.AlarmScheduler
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object AlarmSchedulerModule {
+
+ @Provides
+ @Singleton
+ fun provideAlarmManager(
+ @ApplicationContext context: Context,
+ ): AlarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+
+ @Provides
+ @Singleton
+ fun provideAlarmScheduler(
+ alarmManager: AlarmManager,
+ @ApplicationContext context: Context,
+ ): AlarmScheduler = AlarmSchedulerImpl(
+ alarmManager = alarmManager,
+ context = context,
+ )
+}
diff --git a/core/alarm/src/main/java/com/teambrake/brake/core/alarm/notification/NotificationReceiver.kt b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/notification/NotificationReceiver.kt
new file mode 100644
index 00000000..7a680f42
--- /dev/null
+++ b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/notification/NotificationReceiver.kt
@@ -0,0 +1,89 @@
+package com.teambrake.brake.core.alarm.notification
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.teambrake.brake.core.alarm.scheduler.AlarmSchedulerImpl
+import com.teambrake.brake.core.common.AlarmAction
+import com.teambrake.brake.core.model.accessibility.IntentConfig
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.usecase.ResetAppGroupUsecase
+import com.teambrake.brake.domain.usecase.SetAlarmUseCase
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class NotificationReceiver : BroadcastReceiver() {
+
+ @Inject
+ lateinit var appGroupRepository: AppGroupRepository
+
+ @Inject
+ lateinit var setAlarmUsecase: SetAlarmUseCase
+
+ @Inject
+ lateinit var resetAppGroupUsecase: ResetAppGroupUsecase
+
+ private val serviceJob = SupervisorJob()
+ private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
+
+ override fun onReceive(context: Context, intent: Intent) {
+ serviceScope.launch {
+ val groupId = intent.getLongExtra(AlarmSchedulerImpl.Companion.EXTRA_GROUP_ID, 0)
+ val appGroup = appGroupRepository.getAppGroupById(groupId)
+ val intentAction = intent.action
+
+ if (appGroup != null) {
+ when (AlarmAction.Companion.fromString(intentAction)) {
+ AlarmAction.ACTION_USING -> startBlocking(context, appGroup)
+ AlarmAction.ACTION_BLOCKING -> stopBlocking(context, appGroup)
+ }
+ }
+ }
+ }
+
+ /**
+ * 차단 프로세스는 3가지 작업으로 진행
+ * 1. 상태 변경 - 사용 중이면 SNOOZE_BLOCKING, 사용 중이 아니면 BLOCKING 상태로 변경
+ * 2. 오버레이 시작 - 사용 중이면 오버레이 시작
+ * 3. 알람 스케줄러 시작 - 차단이 완료되면 알람 스케줄러를 시작
+ * */
+ private suspend fun startBlocking(context: Context, appGroup: AppGroup) {
+ Timber.i("ID: ${appGroup.id} 차단이 시작되었습니다")
+
+ val broadcastIntent = Intent().apply {
+ action = IntentConfig.RECEIVER_IDENTITY
+ setPackage(context.packageName)
+ putExtra(IntentConfig.EXTRA_GROUP_ID, appGroup.id)
+ putExtra(IntentConfig.EXTRA_GROUP_STATE, AppGroupState.SnoozeBlocking)
+ putExtra(IntentConfig.EXTRA_SNOOZES_COUNT, appGroup.snoozesCount)
+ }
+ context.sendBroadcast(broadcastIntent)
+
+ setAlarmUsecase(
+ groupId = appGroup.id,
+ groupName = appGroup.name,
+ appGroupState = AppGroupState.Blocking,
+ )
+ }
+
+ private suspend fun stopBlocking(context: Context, appGroup: AppGroup) {
+ Timber.i("ID: ${appGroup.id} 차단이 해제되었습니다")
+ resetAppGroupUsecase(appGroup)
+
+ val broadcastIntent = Intent().apply {
+ action = IntentConfig.RECEIVER_IDENTITY
+ putExtra(IntentConfig.EXTRA_GROUP_ID, appGroup.id)
+ putExtra(IntentConfig.EXTRA_GROUP_STATE, AppGroupState.NeedSetting)
+ putExtra(IntentConfig.EXTRA_SNOOZES_COUNT, 0)
+ }
+ context.sendBroadcast(broadcastIntent)
+ }
+}
diff --git a/core/alarm/src/main/java/com/teambrake/brake/core/alarm/scheduler/AlarmSchedulerImpl.kt b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/scheduler/AlarmSchedulerImpl.kt
new file mode 100644
index 00000000..74fb4ae4
--- /dev/null
+++ b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/scheduler/AlarmSchedulerImpl.kt
@@ -0,0 +1,107 @@
+package com.teambrake.brake.core.alarm.scheduler
+
+import android.annotation.SuppressLint
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationManagerCompat
+import com.teambrake.brake.core.alarm.notification.NotificationReceiver
+import com.teambrake.brake.core.alarm.service.AlarmCountdownService
+import com.teambrake.brake.core.common.AlarmAction
+import com.teambrake.brake.domain.repository.AlarmScheduler
+import timber.log.Timber
+import java.time.LocalDateTime
+import java.time.ZoneId
+import javax.inject.Inject
+
+class AlarmSchedulerImpl @Inject constructor(
+ private val alarmManager: AlarmManager,
+ private val context: Context,
+) : AlarmScheduler {
+
+ override fun scheduleAlarm(
+ groupId: Long,
+ groupName: String,
+ triggerTime: LocalDateTime,
+ action: AlarmAction,
+ ): Result {
+ if (!canScheduleExactAlarms()) {
+ val errorMessage = "정확한 알람 권한이 없습니다. ID: $groupId 에 대한 정확한 알람을 예약할 수 없습니다."
+ Timber.w(errorMessage)
+ return Result.failure(SecurityException("정확한 알람 권한이 없습니다."))
+ }
+
+ val notificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
+ Timber.d("Notification permission enabled: $notificationEnabled")
+
+ val intent = getPendingIntent(groupId, action.name)
+ Timber.d("$triggerTime 에 알람을 예약합니다. ID: $groupId, 액션: ${action.name}")
+
+ return try {
+ if (action == AlarmAction.ACTION_USING && !AlarmCountdownService.isRunning(context)) {
+ AlarmCountdownService.startForegroundNotification(context, groupName, triggerTime)
+ }
+ Timber.e("foreground service 실행중인지 ${AlarmCountdownService.isRunning(context)}")
+ scheduleAlarm(triggerTime, intent)
+ Result.success(triggerTime)
+ } catch (se: SecurityException) {
+ Timber.e("SecurityException: ID: $groupId 에 대한 정확한 알람을 예약할 수 없습니다. $se")
+ Result.failure(se)
+ }
+ }
+
+ override fun cancelAlarm(groupId: Long, action: AlarmAction) {
+ val intent = getPendingIntent(groupId, action.name)
+
+ intent.let {
+ alarmManager.cancel(it)
+ it.cancel()
+ }
+
+ AlarmCountdownService.stop(context)
+ }
+
+ private fun canScheduleExactAlarms(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ alarmManager.canScheduleExactAlarms()
+ } else {
+ true
+ }
+
+ private fun getPendingIntent(
+ groupId: Long,
+ intentAction: String,
+ ): PendingIntent {
+ val intent = Intent(context, NotificationReceiver::class.java).apply {
+ action = intentAction
+ putExtra(EXTRA_GROUP_ID, groupId)
+ }
+
+ val pendingIntentFlags = PendingIntent.FLAG_IMMUTABLE
+ return PendingIntent.getBroadcast(
+ context,
+ groupId.toInt(),
+ intent,
+ pendingIntentFlags,
+ )
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun scheduleAlarm(
+ triggerTime: LocalDateTime,
+ pendingIntent: PendingIntent,
+ ) {
+ val triggerAtMillis = triggerTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
+ val alarmInfo = AlarmManager.AlarmClockInfo(triggerAtMillis, pendingIntent)
+
+ alarmManager.setAlarmClock(
+ alarmInfo,
+ pendingIntent,
+ )
+ }
+
+ companion object {
+ const val EXTRA_GROUP_ID = "EXTRA_GROUP_ID"
+ }
+}
diff --git a/core/alarm/src/main/java/com/teambrake/brake/core/alarm/scheduler/ReschedulerBootReceiver.kt b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/scheduler/ReschedulerBootReceiver.kt
new file mode 100644
index 00000000..973c82a5
--- /dev/null
+++ b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/scheduler/ReschedulerBootReceiver.kt
@@ -0,0 +1,38 @@
+package com.teambrake.brake.core.alarm.scheduler
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkManager
+import com.teambrake.brake.core.model.worker.WorkerConfig.RESCHEDULE_ALARM
+import dagger.hilt.android.AndroidEntryPoint
+
+/**
+ * 재부팅 후 부팅 감지 및 workmanager 를 통해 알람 재예약을 위한 BroadcastReceiver
+ * 재부팅 시, WorkManager를 통해 알람 재예약, 즉 Worker 에게 Task 요청
+ * 부팅 직후 메인 쓰레드 사용이 불가하며, IO 쓰레드 또한 그러하므로 이곳에서 즉각 알람 예약을 하지 않음
+ * WorkManager를 통해 task 예약 시, setExpedited(true) 를 사용하여 Doze 모드 등 어떠한 제한에도 영향을 받지 않고 task를 수행할 수 있도록 함
+ */
+@AndroidEntryPoint
+class ReschedulerBootReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.action) {
+ Intent.ACTION_BOOT_COMPLETED, "android.intent.action.QUICKBOOT_POWERON", "com.htc.intent.action.QUICKBOOT_POWERON" -> {
+
+ val workManager = WorkManager.getInstance(context)
+ val request = OneTimeWorkRequestBuilder()
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .build()
+ workManager.enqueueUniqueWork(
+ RESCHEDULE_ALARM,
+ ExistingWorkPolicy.APPEND_OR_REPLACE,
+ request,
+ )
+ }
+ }
+ }
+}
diff --git a/core/alarm/src/main/java/com/teambrake/brake/core/alarm/scheduler/ReschedulerWorker.kt b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/scheduler/ReschedulerWorker.kt
new file mode 100644
index 00000000..bd6320f9
--- /dev/null
+++ b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/scheduler/ReschedulerWorker.kt
@@ -0,0 +1,57 @@
+package com.teambrake.brake.core.alarm.scheduler
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.teambrake.brake.core.common.AlarmAction
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.domain.repository.AlarmScheduler
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.flow.firstOrNull
+import timber.log.Timber
+
+/**
+ * 재부팅 후 ReschedulerBootReceiver 를 통해 알람 재등록 업부 수행
+ * 해당 worker 의 task 예약 시, setExpedited(true) 를 사용하여 Doze 모드 등 어떠한 제한에도 영향을 받지 않고 task를 수행할 수 있도록 함
+ */
+@HiltWorker
+class ReschedulerWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted workerParams: WorkerParameters,
+ private val groupRepository: AppGroupRepository,
+ private val alarmScheduler: AlarmScheduler,
+) : CoroutineWorker(appContext, workerParams) {
+ override suspend fun doWork(): Result {
+ return try {
+ groupRepository.observeAppGroup().firstOrNull()?.let { appGroups ->
+ appGroups.forEach { group ->
+ when (val state = group.appGroupState) {
+ AppGroupState.Using, AppGroupState.Blocking -> {
+ alarmScheduler.scheduleAlarm(
+ groupId = group.id,
+ groupName = group.name,
+ triggerTime = group.endTime ?: return@forEach,
+ action = if (state == AppGroupState.Using) {
+ AlarmAction.ACTION_USING
+ } else {
+ AlarmAction.ACTION_BLOCKING
+ },
+ ).onFailure { exception ->
+ Timber.e("알람 예약 실패: ${exception.message}")
+ }
+ Timber.i("AppGroupState.Off: ${group.name} 그룹은 알람 예약이 필요하지 않습니다.")
+ }
+ else -> {}
+ }
+ }
+ }
+ Result.success()
+ } catch (_: Exception) {
+ Timber.e("ReschedulerWorkManager 작업 실패")
+ Result.failure()
+ }
+ }
+}
diff --git a/core/alarm/src/main/java/com/teambrake/brake/core/alarm/service/AlarmCountdownService.kt b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/service/AlarmCountdownService.kt
new file mode 100644
index 00000000..1fea7d20
--- /dev/null
+++ b/core/alarm/src/main/java/com/teambrake/brake/core/alarm/service/AlarmCountdownService.kt
@@ -0,0 +1,348 @@
+package com.teambrake.brake.core.alarm.service
+
+import android.app.ActivityManager
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.drawable.VectorDrawable
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import androidx.core.content.res.ResourcesCompat
+import androidx.core.graphics.drawable.toBitmap
+import com.teambrake.brake.core.alarm.R
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.seconds
+import timber.log.Timber
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+import kotlin.time.Duration.Companion.milliseconds
+
+class AlarmCountdownService : Service() {
+
+ private var serviceJob: Job? = null
+ private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+ private var notificationManager: NotificationManager? = null
+ private var initialRemainingTime: Long = 0L
+ private var remainingNotificationShown = false
+ private val remainingTimeAlert = 60_000L
+ private var isForegroundStarted = false
+
+ private val largeIconBitmap: Bitmap? by lazy {
+ try {
+ (
+ ResourcesCompat.getDrawable(
+ this.resources,
+ R.drawable.ic_large_alarm,
+ null,
+ ) as VectorDrawable
+ ).toBitmap()
+ } catch (e: Exception) {
+ Timber.w("Failed to decode large icon: ${e.message}")
+ null
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ Timber.d("AlarmCountdownService onCreate")
+ notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ Timber.d("AlarmCountdownService onStartCommand")
+
+ if (intent?.action == ACTION_CANCEL_ALARM) {
+ Timber.d("Cancel alarm action received")
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ val groupName = intent?.getStringExtra(EXTRA_GROUP_NAME) ?: ""
+ val triggerTimeString = intent?.getStringExtra(EXTRA_TRIGGER_TIME) ?: ""
+
+ if (triggerTimeString.isEmpty()) {
+ Timber.w("Invalid parameters, stopping service")
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ val triggerTime = LocalDateTime.parse(
+ triggerTimeString,
+ DateTimeFormatter.ISO_LOCAL_DATE_TIME,
+ )
+
+ startCountdown(groupName, triggerTime)
+
+ return START_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onDestroy() {
+ super.onDestroy()
+ serviceScope.cancel()
+ Timber.d("AlarmCountdownService destroyed")
+ }
+
+ private fun startCountdown(groupName: String, triggerTime: LocalDateTime) {
+ serviceJob?.cancel()
+ remainingNotificationShown = false
+ isForegroundStarted = false
+
+ val triggerTimeMillis =
+ triggerTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
+ initialRemainingTime = triggerTimeMillis - System.currentTimeMillis()
+
+ updateCustomNotification(groupName, initialRemainingTime, triggerTime)
+
+ serviceJob = serviceScope.launch {
+ while (isActive) {
+ val remainingTime = triggerTimeMillis - System.currentTimeMillis()
+
+ if (remainingTime <= 0) {
+ stopSelf()
+ break
+ }
+
+ if (!remainingNotificationShown && remainingTime <= remainingTimeAlert) {
+ showWarningNotification()
+ remainingNotificationShown = true
+ }
+
+ updateCustomNotification(groupName, remainingTime, triggerTime)
+
+ // delay 시간 계산: 1분 이하면 1초, 1분 이상이면 다음 분까지의 시간
+ val now = System.currentTimeMillis()
+ val nextMinuteBoundary = ((now / 60_000) + 1) * 60_000
+ val millisUntilNextMinute = (nextMinuteBoundary - now).coerceAtLeast(0L)
+
+ val delayTime = if (remainingTime <= 60_000) {
+ 1.seconds
+ } else {
+ millisUntilNextMinute.milliseconds + 50.milliseconds
+ }
+ delay(delayTime)
+ }
+ }
+ }
+
+ private fun updateCustomNotification(
+ groupName: String,
+ remainingTimeMillis: Long,
+ triggerTime: LocalDateTime,
+ ) {
+ val targetTime =
+ triggerTime.format(
+ DateTimeFormatter.ofPattern(
+ getString(R.string.time_format_hour_minute),
+ Locale.getDefault(),
+ ),
+ )
+
+ // 1분 이하일 때는 초 단위로 표시
+ val displayText = if (remainingTimeMillis <= 60_060) {
+ val seconds = (remainingTimeMillis / 1000).toInt()
+ try {
+ getString(R.string.alarm_using_format_seconds, groupName, seconds)
+ } catch (_: Exception) {
+ "$groupName 사용 중 (${seconds}초 남음)"
+ }
+ } else {
+ // 1분 이상일 때는 분 단위로 표시
+ val totalMinutes = (remainingTimeMillis / 60_000).toInt()
+ getString(R.string.alarm_using_format, groupName, totalMinutes)
+ }
+
+ val collapsedContent = getString(R.string.alarm_available_until_format, targetTime)
+
+ val progress = if (initialRemainingTime > 0) {
+ val elapsedTime = initialRemainingTime - remainingTimeMillis
+ ((elapsedTime * 100) / initialRemainingTime).coerceIn(0, 100).toInt()
+ } else {
+ 0
+ }
+
+ val contentIntent = createMainActivityPendingIntent()
+
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_alarm)
+ .setColor(ContextCompat.getColor(this, android.R.color.transparent))
+ .setContentTitle(displayText)
+ .setContentText(collapsedContent)
+ .setContentIntent(contentIntent)
+ .setProgress(100, progress, false)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_ALARM)
+ .setOngoing(true)
+ .setAutoCancel(false)
+ .setSilent(true)
+ .setColorized(false)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setShowWhen(false)
+ .setStyle(
+ NotificationCompat.BigPictureStyle()
+ .setBigContentTitle(displayText)
+ .setSummaryText(collapsedContent)
+ .bigLargeIcon(largeIconBitmap),
+ )
+ .build()
+
+ notification.flags = Notification.FLAG_FOREGROUND_SERVICE
+
+ try {
+ if (!isForegroundStarted) {
+ // 최초 1회만 startForeground 호출
+ startForeground(NOTIFICATION_ID, notification)
+ isForegroundStarted = true
+ Timber.d("Foreground service started")
+ }
+
+ // 기존 호출된 notification 을 ID 를 통해 추적하여 notify 로 업데이트
+ notificationManager?.notify(NOTIFICATION_ID, notification)
+ if (remainingTimeMillis <= 60_000) {
+ Timber.d("Notification updated: ${remainingTimeMillis / 1000} seconds remaining")
+ } else {
+ Timber.d("Notification updated: ${remainingTimeMillis / 60_000} minutes remaining")
+ }
+ } catch (e: Exception) {
+ Timber.e("Failed to update notification: $e")
+ }
+ }
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.alarm_countdown_channel_name),
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ description = getString(R.string.alarm_countdown_channel_description)
+ enableLights(false)
+ enableVibration(false)
+ setShowBadge(true)
+ lockscreenVisibility = Notification.VISIBILITY_PUBLIC
+ setSound(null, null)
+ }
+
+ val warningChannel = NotificationChannel(
+ WARNING_CHANNEL_ID,
+ getString(R.string.alarm_warning_channel_name),
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ description = getString(R.string.alarm_warning_channel_description)
+ enableLights(true)
+ enableVibration(true)
+ setShowBadge(true)
+ lockscreenVisibility = Notification.VISIBILITY_PUBLIC
+ }
+
+ notificationManager?.createNotificationChannel(channel)
+ notificationManager?.createNotificationChannel(warningChannel)
+ Timber.d("Notification channels created")
+ }
+
+ private fun showWarningNotification() {
+ val contentIntent = createMainActivityPendingIntent()
+
+ val notification = NotificationCompat.Builder(this, WARNING_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_alarm)
+ .setContentTitle(getString(R.string.alarm_warning_title))
+ .setContentText(getString(R.string.alarm_warning_content))
+ .setContentIntent(contentIntent)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_ALARM)
+ .setAutoCancel(true)
+ .setDefaults(NotificationCompat.DEFAULT_ALL)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setShowWhen(true)
+ .build()
+
+ try {
+ notificationManager?.notify(WARNING_NOTIFICATION_ID, notification)
+ Timber.d("Warning notification shown")
+ } catch (e: Exception) {
+ Timber.e("Warning notification: $e")
+ }
+ }
+
+ private fun createMainActivityPendingIntent(): PendingIntent? = try {
+ val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
+ launchIntent?.let { intent ->
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ PendingIntent.getActivity(
+ this,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+ } catch (e: Exception) {
+ Timber.e("Failed to create main activity pending intent: $e")
+ null
+ }
+
+ companion object {
+ const val CHANNEL_ID = "alarm_countdown_channel"
+ const val WARNING_CHANNEL_ID = "alarm_warning_channel"
+ const val NOTIFICATION_ID = 1001
+ const val WARNING_NOTIFICATION_ID = 1002
+ const val EXTRA_GROUP_NAME = "EXTRA_GROUP_NAME"
+ const val EXTRA_TRIGGER_TIME = "EXTRA_TRIGGER_TIME"
+ const val ACTION_CANCEL_ALARM = "ACTION_CANCEL_ALARM"
+
+ fun startForegroundNotification(
+ context: Context,
+ groupName: String,
+ triggerTime: LocalDateTime,
+ ) {
+ try {
+ Timber.d("Starting AlarmCountdownService")
+ val intent = Intent(context, AlarmCountdownService::class.java).apply {
+ putExtra(EXTRA_GROUP_NAME, groupName)
+ putExtra(
+ EXTRA_TRIGGER_TIME,
+ triggerTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
+ )
+ }
+ context.startForegroundService(intent)
+ Timber.d("AlarmCountdownService started successfully")
+ } catch (e: Exception) {
+ Timber.e("포그라운드 서비스 시작 실패: $e")
+ }
+ }
+
+ fun stop(context: Context) {
+ try {
+ val intent = Intent(context, AlarmCountdownService::class.java)
+ context.stopService(intent)
+ } catch (e: Exception) {
+ Timber.e("포그라운드 서비스 중지 실패: $e")
+ }
+ }
+
+ fun isRunning(context: Context): Boolean {
+ val manager = context.getSystemService(ACTIVITY_SERVICE) as ActivityManager
+ return manager.let { activityManager ->
+ @Suppress("DEPRECATION")
+ activityManager.getRunningServices(Int.MAX_VALUE).any { serviceInfo ->
+ serviceInfo.service.className == AlarmCountdownService::class.java.name
+ }
+ }
+ }
+ }
+}
diff --git a/core/alarm/src/main/res/drawable/ic_alarm.xml b/core/alarm/src/main/res/drawable/ic_alarm.xml
new file mode 100644
index 00000000..37f26eb1
--- /dev/null
+++ b/core/alarm/src/main/res/drawable/ic_alarm.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/alarm/src/main/res/drawable/ic_large_alarm.xml b/core/alarm/src/main/res/drawable/ic_large_alarm.xml
new file mode 100644
index 00000000..2ce93455
--- /dev/null
+++ b/core/alarm/src/main/res/drawable/ic_large_alarm.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
diff --git a/core/alarm/src/main/res/values/strings.xml b/core/alarm/src/main/res/values/strings.xml
new file mode 100644
index 00000000..53fdeab2
--- /dev/null
+++ b/core/alarm/src/main/res/values/strings.xml
@@ -0,0 +1,19 @@
+
+
+ 알람 카운트다운
+ 알람까지의 남은 시간을 실시간으로 표시합니다
+
+ 알람 준비중…
+ 카운트다운을 시작합니다
+ %1$s 사용중 • %2$d분 남음
+ %1$s 사용 중 • %2$d초 남음
+ %1$s까지 사용 가능
+ 취소
+
+ HH시 mm분
+
+ 사용 시간 경고
+ 사용 시간 종료 1분 전 경고 알림
+ 사용 시간 임박 안내
+ 앱 사용 시간이 1분 남았어요!
+
diff --git a/core/appscanner/.gitignore b/core/appscanner/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/appscanner/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/appscanner/build.gradle.kts b/core/appscanner/build.gradle.kts
new file mode 100644
index 00000000..61766d99
--- /dev/null
+++ b/core/appscanner/build.gradle.kts
@@ -0,0 +1,9 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.library)
+}
+
+android {
+ setNamespace("core.appscanner")
+}
diff --git a/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/AppMetaData.kt b/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/AppMetaData.kt
new file mode 100644
index 00000000..f9d4835a
--- /dev/null
+++ b/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/AppMetaData.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.core.appscanner
+
+import android.graphics.drawable.Drawable
+
+data class AppMetaData(
+ val appName: String,
+ val packageName: String,
+ val icon: Drawable? = null,
+)
diff --git a/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/InstalledAppScanner.kt b/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/InstalledAppScanner.kt
new file mode 100644
index 00000000..a56bfa50
--- /dev/null
+++ b/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/InstalledAppScanner.kt
@@ -0,0 +1,8 @@
+package com.teambrake.brake.core.appscanner
+
+import android.graphics.drawable.Drawable
+
+interface InstalledAppScanner {
+ fun getInstalledAppsMetaData(): List
+ fun getIconDrawable(packageName: String): Drawable
+}
diff --git a/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/InstalledAppScannerImpl.kt b/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/InstalledAppScannerImpl.kt
new file mode 100644
index 00000000..bbe7dc68
--- /dev/null
+++ b/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/InstalledAppScannerImpl.kt
@@ -0,0 +1,74 @@
+package com.teambrake.brake.core.appscanner
+
+import android.app.usage.UsageStatsManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Color
+import android.graphics.drawable.Drawable
+import android.os.Build
+import javax.inject.Inject
+import androidx.core.graphics.drawable.toDrawable
+
+class InstalledAppScannerImpl @Inject constructor(
+ private val context: Context,
+) : InstalledAppScanner {
+ override fun getInstalledAppsMetaData(): List {
+ val pm = context.packageManager
+ val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
+ // 일주일 기간의 앱 전체 각각의 사용량
+ val usageList = usm.queryUsageStats(
+ UsageStatsManager.INTERVAL_WEEKLY,
+ System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 7,
+ System.currentTimeMillis(),
+ )
+ val mainIntent = Intent(Intent.ACTION_MAIN, null)
+ mainIntent.addCategory(Intent.CATEGORY_LAUNCHER)
+
+ val resolvedInfos = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ pm.queryIntentActivities(
+ mainIntent,
+ PackageManager.ResolveInfoFlags.of(0L),
+ )
+ } else {
+ pm.queryIntentActivities(mainIntent, 0)
+ }
+
+ return resolvedInfos
+ .mapNotNull { resolveInfo ->
+ val resources =
+ pm.getResourcesForApplication(resolveInfo.activityInfo.applicationInfo)
+ AppMetaData(
+ appName = if (resolveInfo.activityInfo.labelRes != 0) {
+ resources.getString(resolveInfo.activityInfo.labelRes)
+ } else {
+ resolveInfo.activityInfo.applicationInfo.loadLabel(pm).toString()
+ },
+ packageName = resolveInfo.activityInfo.packageName,
+ icon = resolveInfo.activityInfo.loadIcon(pm),
+ )
+ }
+ .sortedByDescending { metaData ->
+ usageList.find { it.packageName == metaData.packageName }?.let {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ it.totalTimeInForeground
+ } else {
+ it.lastTimeUsed
+ }
+ }
+ }
+ .filterNot { metaData ->
+ metaData.packageName == context.packageName
+ }
+ }
+
+ override fun getIconDrawable(packageName: String): Drawable {
+ val pm = context.packageManager
+ return try {
+ val appInfo = pm.getApplicationInfo(packageName, 0)
+ appInfo.loadIcon(pm)
+ } catch (_: PackageManager.NameNotFoundException) {
+ Color.LTGRAY.toDrawable()
+ }
+ }
+}
diff --git a/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/di/AppScannerModule.kt b/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/di/AppScannerModule.kt
new file mode 100644
index 00000000..36b5b2fc
--- /dev/null
+++ b/core/appscanner/src/main/java/com/teambrake/brake/core/appscanner/di/AppScannerModule.kt
@@ -0,0 +1,20 @@
+package com.teambrake.brake.core.appscanner.di
+
+import android.content.Context
+import com.teambrake.brake.core.appscanner.InstalledAppScanner
+import com.teambrake.brake.core.appscanner.InstalledAppScannerImpl
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object AppScannerModule {
+
+ @Provides
+ fun provideInstalledAppScanner(
+ @ApplicationContext context: Context,
+ ): InstalledAppScanner = InstalledAppScannerImpl(context)
+}
diff --git a/core/auth/.gitignore b/core/auth/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/auth/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/auth/build.gradle.kts b/core/auth/build.gradle.kts
new file mode 100644
index 00000000..8e18190f
--- /dev/null
+++ b/core/auth/build.gradle.kts
@@ -0,0 +1,66 @@
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.library)
+ alias(libs.plugins.brake.android.hilt)
+ alias(libs.plugins.brake.android.compose)
+}
+
+android {
+ setNamespace("core.auth")
+
+ buildTypes {
+ debug {
+ val debugKakaoRestApiKey = gradleLocalProperties(rootDir, providers)
+ .getProperty("KAKAO_REST_API_KEY_DEBUG")
+ if (debugKakaoRestApiKey.isNullOrEmpty()) {
+ throw IllegalArgumentException("KAKAO_REST_API_KEY_DEBUG must be set in local.properties")
+ }
+ buildConfigField("String", "KAKAO_REST_API_KEY", "\"$debugKakaoRestApiKey\"")
+
+ val debugKakaoJsKey = gradleLocalProperties(rootDir, providers)
+ .getProperty("KAKAO_JS_KEY_DEBUG")
+ if (debugKakaoJsKey.isNullOrEmpty()) {
+ throw IllegalArgumentException("KAKAO_JS_KEY_DEBUG must be set in local.properties")
+ }
+ buildConfigField("String", "KAKAO_JS_KEY", "\"$debugKakaoJsKey\"")
+ }
+
+ release {
+ val releaseKakaoRestApiKey = gradleLocalProperties(rootDir, providers)
+ .getProperty("KAKAO_REST_API_KEY_RELEASE")
+ if (releaseKakaoRestApiKey.isNullOrEmpty()) {
+ throw IllegalArgumentException("KAKAO_REST_API_KEY_RELEASE must be set in local.properties")
+ }
+ buildConfigField("String", "KAKAO_REST_API_KEY", "\"$releaseKakaoRestApiKey\"")
+
+ val releaseKakaoJsKey = gradleLocalProperties(rootDir, providers)
+ .getProperty("KAKAO_JS_KEY_RELEASE")
+ if (releaseKakaoJsKey.isNullOrEmpty()) {
+ throw IllegalArgumentException("KAKAO_JS_KEY_RELEASE must be set in local.properties")
+ }
+ buildConfigField("String", "KAKAO_JS_KEY", "\"$releaseKakaoJsKey\"")
+ }
+ }
+
+ buildFeatures {
+ buildConfig = true
+ }
+}
+
+dependencies {
+ implementation(libs.kakao.user)
+
+ // BackHandler 사용을 위한 의존성
+ implementation(libs.androidx.activity.compose)
+
+ // Google Authorization Login
+ implementation(libs.google.auth)
+
+ // Credential
+ implementation(libs.androidx.credentials)
+ // Need for API 33 and below
+ // https://developer.android.com/identity/sign-in/credential-manager#add-dependencies
+ implementation(libs.androidx.credentials.play.services.auth)
+}
diff --git a/core/auth/src/main/AndroidManifest.xml b/core/auth/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..49ef4b7e
--- /dev/null
+++ b/core/auth/src/main/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/core/auth/src/main/java/com/teambrake/brake/core/auth/google/GoogleAuthManager.kt b/core/auth/src/main/java/com/teambrake/brake/core/auth/google/GoogleAuthManager.kt
new file mode 100644
index 00000000..add6a512
--- /dev/null
+++ b/core/auth/src/main/java/com/teambrake/brake/core/auth/google/GoogleAuthManager.kt
@@ -0,0 +1,160 @@
+package com.teambrake.brake.core.auth.google
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import android.os.CancellationSignal
+import androidx.activity.result.IntentSenderRequest
+import androidx.credentials.ClearCredentialStateRequest
+import androidx.credentials.CredentialManager
+import androidx.credentials.CredentialManagerCallback
+import androidx.credentials.exceptions.ClearCredentialException
+import androidx.credentials.playservices.CredentialProviderPlayServicesImpl
+import androidx.credentials.playservices.CredentialProviderPlayServicesImpl.Companion.MIN_GMS_APK_VERSION
+import com.google.android.gms.auth.api.identity.AuthorizationRequest
+import com.google.android.gms.auth.api.identity.Identity
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.api.Scope
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.coroutines.resumeWithException
+
+@SuppressLint("RestrictedApi")
+@Singleton
+class GoogleAuthManager @Inject constructor(
+ @ApplicationContext private val appContext: Context,
+) {
+ val credentialProvider = CredentialProviderPlayServicesImpl(appContext)
+ private lateinit var authorizationRequest: AuthorizationRequest
+ private lateinit var credentialManager: CredentialManager
+
+ fun initializeAuthorizationRequest(context: Context, serverClientId: String) {
+ authorizationRequest = AuthorizationRequest.builder()
+ .setRequestedScopes(
+ listOf(
+ Scope(GOOGLE_OAUTH2_EMAIL),
+ Scope(GOOGLE_OAUTH2_PROFILE),
+ ),
+ )
+ .requestOfflineAccess(serverClientId)
+ .build()
+ credentialManager = CredentialManager.create(context)
+ }
+
+ fun requestGoogleAuthorization(
+ context: Context,
+ onRequestGoogleAuth: (IntentSenderRequest) -> Unit,
+ onFailure: () -> Unit,
+ onAlertUpdateGooglePlayServices: () -> Unit,
+ ) {
+ if (!credentialProvider.isAvailableOnDevice()) {
+ val currentGmsApkVersion = GoogleApiAvailability().getApkVersion(appContext)
+ Timber.e("Google Play Services 버전: $currentGmsApkVersion, 최소 필요 버전: $MIN_GMS_APK_VERSION")
+ onAlertUpdateGooglePlayServices()
+ return
+ }
+
+ var attemptCount = 0
+ val maxAttempts = 2
+ val retryDelayMs = 200L
+
+ fun attemptAuthorization() {
+ attemptCount++
+ Timber.d("Google Authorization 재시도 $attemptCount/$maxAttempts")
+
+ Identity.getAuthorizationClient(context)
+ .authorize(authorizationRequest)
+ .addOnSuccessListener { authorizationResult ->
+ authorizationResult.pendingIntent?.let { pendingIntent ->
+ Timber.d("Google One Tap 로그인 창 실행")
+
+ val intentSenderRequest = IntentSenderRequest.Builder(
+ pendingIntent.intentSender,
+ ).build()
+ onRequestGoogleAuth(intentSenderRequest)
+ } ?: run {
+ Timber.e("Google One Tap 로그인 창 실행 실패: pendingIntent is null")
+
+ if (attemptCount < maxAttempts) {
+ CoroutineScope(Dispatchers.Main + SupervisorJob()).launch {
+ signOutGoogleAuth()
+ delay(retryDelayMs)
+ attemptAuthorization()
+ }
+ } else {
+ onFailure()
+ }
+ }
+ }
+ .addOnFailureListener { exception ->
+ onFailure()
+ }
+ }
+
+ attemptAuthorization()
+ }
+
+ /**
+ * Google Credential 상태 초기화
+ *
+ * 로그아웃, 회원탈퇴 시 반드시 호출해야 함, 그렇지 않으면 재로그인 시 이전에 로그인했던 구글 계정으로만 선택됨
+ */
+
+ @SuppressLint("RestrictedApi")
+ fun signOutGoogleAuth() {
+ Timber.i("Google Service Version : ${GoogleApiAvailability().getApkVersion(appContext)}")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
+ // API 34 이상에서 안전하게 사용 가능
+ // API 34 미만에서는 credentialProvider.isAvailableOnDevice() == false 인 경우 에러 발생
+ credentialManager.clearCredentialState(ClearCredentialStateRequest())
+ Timber.i("API 34+ : Google Credential 상태가 초기화되었습니다.")
+ }
+ } else {
+ // API 33 이하에서 credentialProvider.isAvailableOnDevice() == true 인 경우
+ // credentialManager.clearCredentialState(ClearCredentialStateRequest()) 실행 가능
+ CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
+ suspendCancellableCoroutine { continuation ->
+ val canceller = CancellationSignal()
+ val callback =
+ object : CredentialManagerCallback {
+ override fun onResult(result: Void?) {
+ if (continuation.isActive) {
+ Timber.i("API 33- : Google Credential 상태가 초기화되었습니다.")
+ continuation.resume(Unit) { cause, _, _ -> }
+ }
+ }
+
+ override fun onError(e: ClearCredentialException) {
+ if (continuation.isActive) {
+ Timber.e(e, "Google Credential 상태 초기화에 실패했습니다.")
+ continuation.resumeWithException(e)
+ }
+ }
+ }
+ credentialProvider.onClearCredential(
+ ClearCredentialStateRequest(),
+ canceller,
+ Runnable::run,
+ callback,
+ )
+ }
+ }
+ }
+ }
+
+ companion object {
+ // https://developers.google.com/identity/protocols/oauth2/scopes#oauth2
+ const val GOOGLE_OAUTH2_OPEN_ID = "openid"
+ const val GOOGLE_OAUTH2_EMAIL = "https://www.googleapis.com/auth/userinfo.email"
+ const val GOOGLE_OAUTH2_PROFILE = "https://www.googleapis.com/auth/userinfo.profile"
+ }
+}
diff --git a/core/auth/src/main/java/com/teambrake/brake/core/auth/kakao/DestroyWebView.kt b/core/auth/src/main/java/com/teambrake/brake/core/auth/kakao/DestroyWebView.kt
new file mode 100644
index 00000000..077d20fe
--- /dev/null
+++ b/core/auth/src/main/java/com/teambrake/brake/core/auth/kakao/DestroyWebView.kt
@@ -0,0 +1,21 @@
+package com.teambrake.brake.core.auth.kakao
+
+import android.webkit.CookieManager
+import android.webkit.WebView
+import timber.log.Timber
+
+internal fun WebView.destroySafely() {
+ CookieManager.getInstance().removeAllCookies { success ->
+ if (success) {
+ Timber.d("모든 쿠키가 성공적으로 삭제됨")
+ } else {
+ Timber.w("쿠키 삭제 실패")
+ }
+ }
+
+ stopLoading()
+ clearHistory()
+ clearCache(true)
+ removeAllViews()
+ destroy()
+}
diff --git a/core/auth/src/main/java/com/teambrake/brake/core/auth/kakao/IsKakaoInstalled.kt b/core/auth/src/main/java/com/teambrake/brake/core/auth/kakao/IsKakaoInstalled.kt
new file mode 100644
index 00000000..ef89eb0e
--- /dev/null
+++ b/core/auth/src/main/java/com/teambrake/brake/core/auth/kakao/IsKakaoInstalled.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.core.auth.kakao
+
+import android.content.Context
+import android.content.pm.PackageManager
+
+internal fun isKakaoInstalled(context: Context): Boolean = try {
+ context.packageManager.getPackageInfo("com.kakao.talk", 0)
+ true
+} catch (_: PackageManager.NameNotFoundException) {
+ false
+}
diff --git a/core/auth/src/main/java/com/teambrake/brake/core/auth/kakao/KakaoAuthScreen.kt b/core/auth/src/main/java/com/teambrake/brake/core/auth/kakao/KakaoAuthScreen.kt
new file mode 100644
index 00000000..9a6cf4f2
--- /dev/null
+++ b/core/auth/src/main/java/com/teambrake/brake/core/auth/kakao/KakaoAuthScreen.kt
@@ -0,0 +1,214 @@
+package com.teambrake.brake.core.auth.kakao
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Intent
+import android.view.ViewGroup.LayoutParams
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.net.toUri
+import androidx.core.view.WindowInsetsControllerCompat
+import com.teambrake.brake.core.auth.BuildConfig
+import com.teambrake.brake.core.auth.R
+import timber.log.Timber
+import java.net.URISyntaxException
+
+private const val KAKAO_AUTH_URL = "https://kauth.kakao.com" // 카카오 진입 URL
+private const val KAKAO_ACCOUNT_URL = "https://accounts.kakao.com" // 카카오 로그인 URL
+private const val KAKAO_MIDDLE_URL = "https://logins.daum.net" // 로그인 인증 중간 URL
+private val KAKAO_REDIRECT_URL
+ get() = if (BuildConfig.DEBUG) {
+ "https://www.yapp-dev/oauth"
+ } else {
+ "https://www.brake/oauth"
+ }
+
+private val KAKAO_BASE_URL = "$KAKAO_AUTH_URL/oauth/authorize" +
+ "?response_type=code" +
+ "&client_id=${BuildConfig.KAKAO_REST_API_KEY}" +
+ "&redirect_uri=$KAKAO_REDIRECT_URL" +
+ "&prompt=login"
+private val allowedPrefixes = listOf(
+ KAKAO_AUTH_URL,
+ KAKAO_ACCOUNT_URL,
+ KAKAO_REDIRECT_URL,
+ KAKAO_MIDDLE_URL,
+ "intent",
+)
+
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+fun KakaoScreen(
+ onBack: () -> Unit,
+ onAuthSuccess: (String) -> Unit,
+ onAuthError: (String) -> Unit,
+) {
+ val context = LocalContext.current
+ val packageManager = context.packageManager
+ var inAppLoaded by remember { mutableStateOf(false) }
+ var webViewRef by remember { mutableStateOf(null) }
+
+ // 디바이스 뒤로가기 버튼 처리
+ BackHandler {
+ // 웹뷰 내에서 뒤로 갈 수 있으면 웹뷰 내에서 뒤로가기
+ if (webViewRef?.canGoBack() == true) {
+ webViewRef?.goBack()
+ } else {
+ // 웹뷰 내에서 뒤로 갈 수 없으면 웹뷰 닫기
+ webViewRef?.destroySafely()
+ onBack()
+ }
+ }
+
+ val view = LocalView.current
+
+ DisposableEffect(view) {
+ val window = (view.context as? Activity)?.window ?: return@DisposableEffect onDispose {}
+ val insetsController = WindowInsetsControllerCompat(window, view)
+
+ // 원래 설정 저장
+ val originalLightStatusBars = insetsController.isAppearanceLightStatusBars
+
+ // Status Bar 아이콘을 어둡게 설정 (밝은 배경용)
+ insetsController.isAppearanceLightStatusBars = true
+
+ onDispose {
+ // 원래 설정으로 복원
+ insetsController.isAppearanceLightStatusBars = originalLightStatusBars
+ }
+ }
+
+ val webView = WebView(context).apply {
+ webViewRef = this
+
+ settings.apply {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ javaScriptCanOpenWindowsAutomatically = true
+ setSupportMultipleWindows(true)
+ layoutParams = LayoutParams(
+ LayoutParams.MATCH_PARENT,
+ LayoutParams.MATCH_PARENT,
+ )
+ }
+
+ webViewClient = object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(
+ view: WebView?,
+ request: WebResourceRequest?,
+ ): Boolean {
+ Timber.d("카카오 인증: URL 로딩 -> ${request?.url}")
+ if (request?.url?.scheme == "intent" && !inAppLoaded) {
+ try {
+ // Intent 생성
+ val intent =
+ Intent.parseUri(request.url.toString(), Intent.URI_INTENT_SCHEME)
+
+ // 실행 가능한 앱이 있으면 앱 실행
+ if (intent.resolveActivity(packageManager) != null) {
+ context.startActivity(intent).also {
+ inAppLoaded = true
+ }
+ return true
+ }
+
+ // Fallback URL이 있으면 현재 웹뷰에 로딩
+ val fallbackUrl = intent.getStringExtra("browser_fallback_url")
+ if (fallbackUrl != null) {
+ view?.loadUrl(fallbackUrl)
+ return true
+ }
+ } catch (_: URISyntaxException) {
+ return false
+ }
+ }
+
+ val url = request?.url.toString()
+ Timber.d("카카오 인증: URL 이동 시도 -> $url")
+
+ // 허용된 URL만 통과
+ if (allowedPrefixes.none { url.startsWith(it) }) {
+ onAuthError(context.getString(R.string.auth_not_allowed_url_error))
+ return true
+ }
+
+ // 리디렉트 처리
+ if (url.startsWith(KAKAO_REDIRECT_URL)) {
+ val uri = url.toUri()
+ val code = uri.getQueryParameter("code")
+ val errorP = uri.getQueryParameter("error")
+
+ when {
+ errorP != null -> onAuthError(context.getString(R.string.auth_error_failure_kakao_login))
+ code != null -> {
+ onAuthSuccess(code)
+ }
+
+ else -> onAuthError(context.getString(R.string.auth_error_not_earned_auth_code))
+ }
+ return true
+ }
+ return false
+ }
+
+ override fun onPageFinished(view: WebView, url: String) {
+ super.onPageFinished(view, url)
+
+ if (url.startsWith(KAKAO_ACCOUNT_URL) && isKakaoInstalled(context) && !inAppLoaded) {
+ // JS SDK 로드
+ view.evaluateJavascript(
+ """
+ (function(){
+ if (!window.Kakao) {
+ var script = document.createElement('script');
+ script.src = 'https://t1.kakaocdn.net/kakao_js_sdk/2.7.5/kakao.min.js';
+ script.integrity = 'sha384-dok87au0gKqJdxs7msEdBPNnKSRT+/mhTVzq+qOhcL464zXwvcrpjeWvyj1kCdq6';
+ script.crossOrigin = 'anonymous';
+ script.onload = function(){ Kakao.init('${BuildConfig.KAKAO_JS_KEY}'); };
+ document.head.appendChild(script);
+ }
+ })();
+ """.trimIndent(),
+ null,
+ )
+
+ val js = "Kakao.Auth.authorize({redirectUri:'$KAKAO_REDIRECT_URL'});"
+ view.evaluateJavascript(js, null)
+ }
+ }
+
+ override fun onReceivedError(
+ view: WebView?,
+ request: WebResourceRequest?,
+ error: WebResourceError?,
+ ) {
+ super.onReceivedError(view, request, error)
+ onAuthError(context.getString(R.string.auth_error_loading_webview))
+ }
+ }
+
+ loadUrl(KAKAO_BASE_URL)
+ }
+
+ AndroidView(
+ factory = { webView },
+ modifier = Modifier.fillMaxSize(),
+ onRelease = { webView ->
+ webView.destroySafely()
+ },
+ )
+}
diff --git a/core/auth/src/main/res/values/strings.xml b/core/auth/src/main/res/values/strings.xml
new file mode 100644
index 00000000..b6fa8bf0
--- /dev/null
+++ b/core/auth/src/main/res/values/strings.xml
@@ -0,0 +1,7 @@
+
+
+ 허용되지 않은 URL로의 이동이 차단되었습니다.
+ 카카오 로그인을 실패했습니다.
+ 인가코드를 받지 못했습니다.
+ 웹뷰 로딩 중 에러가 발생했습니다.
+
diff --git a/core/common/.gitignore b/core/common/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/common/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
new file mode 100644
index 00000000..d1b83a04
--- /dev/null
+++ b/core/common/build.gradle.kts
@@ -0,0 +1,3 @@
+plugins {
+ alias(libs.plugins.brake.kotlin.library)
+}
diff --git a/core/common/src/main/java/com/teambrake/brake/core/common/AlarmAction.kt b/core/common/src/main/java/com/teambrake/brake/core/common/AlarmAction.kt
new file mode 100644
index 00000000..bff9b2fd
--- /dev/null
+++ b/core/common/src/main/java/com/teambrake/brake/core/common/AlarmAction.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.core.common
+
+enum class AlarmAction {
+ ACTION_USING,
+ ACTION_BLOCKING, ;
+
+ companion object {
+ fun fromString(action: String?): AlarmAction = entries.find { it.name == action } ?: ACTION_USING
+ }
+}
diff --git a/core/common/src/main/java/com/teambrake/brake/core/common/BlockingConstants.kt b/core/common/src/main/java/com/teambrake/brake/core/common/BlockingConstants.kt
new file mode 100644
index 00000000..8fd8200a
--- /dev/null
+++ b/core/common/src/main/java/com/teambrake/brake/core/common/BlockingConstants.kt
@@ -0,0 +1,7 @@
+package com.teambrake.brake.core.common
+
+object BlockingConstants {
+ const val ACTION_SHOW_OVERLAY = "com.teambrake.brake.SHOW_OVERLAY"
+ const val ACTION_CLOSE_OVERLAY = "com.teambrake.brake.ACTION_CLOSE_OVERLAY"
+ const val EXTRA_OVERLAY_DATA = "extra_overlay_data"
+}
diff --git a/core/common/src/main/java/com/teambrake/brake/core/common/Constants.kt b/core/common/src/main/java/com/teambrake/brake/core/common/Constants.kt
new file mode 100644
index 00000000..790d16a8
--- /dev/null
+++ b/core/common/src/main/java/com/teambrake/brake/core/common/Constants.kt
@@ -0,0 +1,12 @@
+package com.teambrake.brake.core.common
+
+object Constants {
+ const val MAX_SNOOZE_COUNT = 2
+
+ const val BLOCKING_TIME: Long = 180
+ const val TEST_BLOCKING_TIME: Long = 10
+
+ const val SNOOZE_TIME: Long = 300
+ const val TEST_SNOOZE_TIME: Long = 10
+ val SNOOZE_MINUTES get() = SNOOZE_TIME / 60
+}
diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts
index ef2cab4f..b3f744b8 100644
--- a/core/database/build.gradle.kts
+++ b/core/database/build.gradle.kts
@@ -1,19 +1,22 @@
-import com.yapp.breake.setNamespace
+import com.teambrake.brake.setNamespace
plugins {
- alias(libs.plugins.breake.android.library)
- alias(libs.plugins.breake.android.hilt)
- alias(libs.plugins.breake.android.room)
+ alias(libs.plugins.brake.android.library)
+ alias(libs.plugins.brake.android.hilt)
+ alias(libs.plugins.brake.android.room)
}
android {
setNamespace("core.database")
+
+ defaultConfig {
+ ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
+ }
+ }
}
dependencies {
- implementation(libs.junit4)
- implementation(libs.androidx.test.ext)
- implementation(libs.hilt.android.testing)
- implementation(libs.coroutines.test)
- implementation(kotlin("reflect"))
-}
\ No newline at end of file
+ implementation(projects.core.model)
+ implementation(libs.kotlinx.serialization.json)
+}
diff --git a/core/database/schemas/com.teambrake.brake.core.database.BrakeDatabase/1.json b/core/database/schemas/com.teambrake.brake.core.database.BrakeDatabase/1.json
new file mode 100644
index 00000000..c97fa315
--- /dev/null
+++ b/core/database/schemas/com.teambrake.brake.core.database.BrakeDatabase/1.json
@@ -0,0 +1,183 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "91aaba8b38b996f0cddd761a8a7fbd0b",
+ "entities": [
+ {
+ "tableName": "app_table",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `name` TEXT NOT NULL, `category` TEXT NOT NULL, `parentGroupId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentGroupId`) REFERENCES `group_table`(`groupId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packageName",
+ "columnName": "packageName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "category",
+ "columnName": "category",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentGroupId",
+ "columnName": "parentGroupId",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_app_table_parentGroupId",
+ "unique": false,
+ "columnNames": [
+ "parentGroupId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_app_table_parentGroupId` ON `${TABLE_NAME}` (`parentGroupId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "group_table",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "parentGroupId"
+ ],
+ "referencedColumns": [
+ "groupId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "snooze_table",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `snoozeTime` TEXT NOT NULL, `parentGroupId` INTEGER NOT NULL, FOREIGN KEY(`parentGroupId`) REFERENCES `group_table`(`groupId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snoozeTime",
+ "columnName": "snoozeTime",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentGroupId",
+ "columnName": "parentGroupId",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_snooze_table_parentGroupId",
+ "unique": false,
+ "columnNames": [
+ "parentGroupId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_snooze_table_parentGroupId` ON `${TABLE_NAME}` (`parentGroupId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "group_table",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "parentGroupId"
+ ],
+ "referencedColumns": [
+ "groupId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "group_table",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `appGroupState` TEXT NOT NULL, `goalMinutes` INTEGER, `sessionStartTime` TEXT, `startTime` TEXT, `endTime` TEXT, PRIMARY KEY(`groupId`))",
+ "fields": [
+ {
+ "fieldPath": "groupId",
+ "columnName": "groupId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "appGroupState",
+ "columnName": "appGroupState",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "goalMinutes",
+ "columnName": "goalMinutes",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "sessionStartTime",
+ "columnName": "sessionStartTime",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "startTime",
+ "columnName": "startTime",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "endTime",
+ "columnName": "endTime",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "groupId"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '91aaba8b38b996f0cddd761a8a7fbd0b')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/BrakeDatabase.kt b/core/database/src/main/java/com/teambrake/brake/core/database/BrakeDatabase.kt
new file mode 100644
index 00000000..5b33552f
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/BrakeDatabase.kt
@@ -0,0 +1,44 @@
+package com.teambrake.brake.core.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import com.teambrake.brake.core.database.converter.AppGroupStateConverter
+import com.teambrake.brake.core.database.converter.LocalDateTimeConverter
+import com.teambrake.brake.core.database.dao.AppDao
+import com.teambrake.brake.core.database.dao.AppGroupDao
+import com.teambrake.brake.core.database.dao.SnoozeDao
+import com.teambrake.brake.core.database.entity.AppEntity
+import com.teambrake.brake.core.database.entity.GroupEntity
+import com.teambrake.brake.core.database.entity.SnoozeEntity
+
+@Database(
+ entities = [
+ AppEntity::class,
+ SnoozeEntity::class,
+ GroupEntity::class,
+ ],
+ autoMigrations = [],
+ version = 1,
+ exportSchema = true,
+)
+@TypeConverters(
+ value = [
+ AppGroupStateConverter::class,
+ LocalDateTimeConverter::class,
+ ],
+)
+internal abstract class BrakeDatabase : RoomDatabase() {
+
+ abstract fun appGroupDao(): AppGroupDao
+ abstract fun appDao(): AppDao
+ abstract fun snoozeDao(): SnoozeDao
+
+ companion object {
+ const val DATABASE_NAME = "brake_database"
+
+ const val GROUP_TABLE_NAME = "group_table"
+ const val APP_TABLE_NAME = "app_table"
+ const val SNOOZE_TABLE_NAME = "snooze_table"
+ }
+}
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/converter/AppGroupStateConverter.kt b/core/database/src/main/java/com/teambrake/brake/core/database/converter/AppGroupStateConverter.kt
new file mode 100644
index 00000000..1f338e3e
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/converter/AppGroupStateConverter.kt
@@ -0,0 +1,13 @@
+package com.teambrake.brake.core.database.converter
+
+import androidx.room.TypeConverter
+import com.teambrake.brake.core.model.app.AppGroupState
+
+internal class AppGroupStateConverter {
+
+ @TypeConverter
+ fun fromAppGroupState(appGroupState: AppGroupState): String = appGroupState.name
+
+ @TypeConverter
+ fun toAppGroupState(value: String): AppGroupState = AppGroupState.valueOf(value)
+}
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/converter/LocalDateTimeConverter.kt b/core/database/src/main/java/com/teambrake/brake/core/database/converter/LocalDateTimeConverter.kt
new file mode 100644
index 00000000..e517b8a1
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/converter/LocalDateTimeConverter.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.core.database.converter
+
+import androidx.room.TypeConverter
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+internal class LocalDateTimeConverter {
+
+ private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
+
+ @TypeConverter
+ fun fromLocalDateTime(dateTime: LocalDateTime?): String? = dateTime?.format(formatter)
+
+ @TypeConverter
+ fun toLocalDateTime(dateTimeString: String?): LocalDateTime? = dateTimeString?.let {
+ LocalDateTime.parse(it, formatter)
+ }
+}
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/dao/AppDao.kt b/core/database/src/main/java/com/teambrake/brake/core/database/dao/AppDao.kt
new file mode 100644
index 00000000..a6bb44a1
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/dao/AppDao.kt
@@ -0,0 +1,34 @@
+package com.teambrake.brake.core.database.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.teambrake.brake.core.database.BrakeDatabase.Companion.APP_TABLE_NAME
+import com.teambrake.brake.core.database.entity.AppEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface AppDao {
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insert(entity: AppEntity)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertAll(entities: List)
+
+ @Query("SELECT * FROM $APP_TABLE_NAME")
+ fun observeApps(): Flow>
+
+ @Query("SELECT * FROM $APP_TABLE_NAME")
+ suspend fun getManagedApps(): List
+
+ @Query("SELECT * FROM $APP_TABLE_NAME WHERE packageName = :packageName")
+ suspend fun getAppByPackageName(packageName: String): AppEntity?
+
+ @Query("DELETE FROM $APP_TABLE_NAME WHERE parentGroupId = :parentGroupId")
+ suspend fun deleteAppsByParentGroupId(parentGroupId: Long)
+
+ @Query("DELETE FROM $APP_TABLE_NAME")
+ suspend fun clearApps()
+}
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/dao/AppGroupDao.kt b/core/database/src/main/java/com/teambrake/brake/core/database/dao/AppGroupDao.kt
new file mode 100644
index 00000000..8334a8ea
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/dao/AppGroupDao.kt
@@ -0,0 +1,86 @@
+package com.teambrake.brake.core.database.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import com.teambrake.brake.core.database.entity.AppGroupEntity
+import com.teambrake.brake.core.database.entity.GroupEntity
+import com.teambrake.brake.core.model.app.AppGroupState
+import kotlinx.coroutines.flow.Flow
+import java.time.LocalDateTime
+
+@Dao
+interface AppGroupDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAppGroups(groupEntities: List)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAppGroup(groupEntity: GroupEntity)
+
+ @Query("SELECT EXISTS(SELECT 1 FROM `group_table` WHERE groupId = :groupId)")
+ suspend fun isAppGroupExists(groupId: Long): Boolean
+
+ @Query(
+ """
+ SELECT CASE
+ WHEN NOT EXISTS(SELECT 1 FROM `group_table` WHERE groupId = 1) THEN 1
+ ELSE (
+ SELECT MIN(t1.groupId + 1)
+ FROM `group_table` t1
+ LEFT JOIN `group_table` t2 ON t1.groupId + 1 = t2.groupId
+ WHERE t2.groupId IS NULL
+ )
+ END
+ """,
+ )
+ suspend fun getAvailableMinGroupId(): Long
+
+ @Transaction
+ @Query("SELECT * FROM `group_table`")
+ fun observeAppGroup(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM `group_table` WHERE groupId = :groupId")
+ suspend fun getAppGroupById(groupId: Long): AppGroupEntity?
+
+ @Query("DELETE FROM `group_table` WHERE groupId = :groupId")
+ suspend fun deleteAppGroupById(groupId: Long)
+
+ @Query("DELETE FROM `group_table`")
+ suspend fun clearAppGroup()
+
+ @Query(
+ "UPDATE `group_table` SET " +
+ "appGroupState = :appGroupState," +
+ " startTime = :startTime," +
+ " endTime = :endTime WHERE groupId = :groupId",
+ )
+ suspend fun updateAppGroupState(
+ groupId: Long,
+ appGroupState: AppGroupState,
+ startTime: LocalDateTime?,
+ endTime: LocalDateTime?,
+ )
+
+ @Query(
+ "UPDATE `group_table` SET " +
+ "goalMinutes = :goalMinutes," +
+ " sessionStartTime = :sessionStartTime WHERE groupId = :groupId",
+ )
+ suspend fun updateGroupSessionInfo(
+ groupId: Long,
+ goalMinutes: Int?,
+ sessionStartTime: LocalDateTime?,
+ )
+
+ @Query(
+ "INSERT INTO `snooze_table` (parentGroupId, snoozeTime) VALUES (:parentGroupId, :snoozeTime)",
+ )
+ suspend fun insertSnooze(parentGroupId: Long, snoozeTime: LocalDateTime)
+
+ @Query("DELETE FROM `snooze_table` WHERE parentGroupId = :groupId")
+ suspend fun resetSnooze(groupId: Long)
+}
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/dao/SnoozeDao.kt b/core/database/src/main/java/com/teambrake/brake/core/database/dao/SnoozeDao.kt
new file mode 100644
index 00000000..c929b403
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/dao/SnoozeDao.kt
@@ -0,0 +1,17 @@
+package com.teambrake.brake.core.database.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import com.teambrake.brake.core.database.BrakeDatabase
+import com.teambrake.brake.core.database.entity.SnoozeEntity
+
+@Dao
+interface SnoozeDao {
+
+ @Insert
+ suspend fun insertSnooze(snooze: SnoozeEntity)
+
+ @Query("DELETE FROM ${BrakeDatabase.SNOOZE_TABLE_NAME} WHERE parentGroupId = :groupId")
+ suspend fun resetSnoozes(groupId: Long)
+}
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/di/DaoModule.kt b/core/database/src/main/java/com/teambrake/brake/core/database/di/DaoModule.kt
new file mode 100644
index 00000000..4728c4d1
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/di/DaoModule.kt
@@ -0,0 +1,28 @@
+package com.teambrake.brake.core.database.di
+
+import com.teambrake.brake.core.database.BrakeDatabase
+import com.teambrake.brake.core.database.dao.AppDao
+import com.teambrake.brake.core.database.dao.AppGroupDao
+import com.teambrake.brake.core.database.dao.SnoozeDao
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object DaoModule {
+
+ @Provides
+ @Singleton
+ fun provideAppDao(database: BrakeDatabase): AppDao = database.appDao()
+
+ @Provides
+ @Singleton
+ fun provideSnoozeDao(database: BrakeDatabase): SnoozeDao = database.snoozeDao()
+
+ @Provides
+ @Singleton
+ fun provideAppGroupDao(database: BrakeDatabase): AppGroupDao = database.appGroupDao()
+}
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/di/DatabaseModule.kt b/core/database/src/main/java/com/teambrake/brake/core/database/di/DatabaseModule.kt
new file mode 100644
index 00000000..4338f8e2
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/di/DatabaseModule.kt
@@ -0,0 +1,36 @@
+package com.teambrake.brake.core.database.di
+
+import android.content.Context
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.sqlite.db.SupportSQLiteDatabase
+import com.teambrake.brake.core.database.BrakeDatabase
+import com.teambrake.brake.core.database.BrakeDatabase.Companion.DATABASE_NAME
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object DatabaseModule {
+
+ @Provides
+ @Singleton
+ fun databaseCallBack(): RoomDatabase.Callback = object : RoomDatabase.Callback() {
+ override fun onCreate(db: SupportSQLiteDatabase) {
+ super.onCreate(db)
+ }
+ }
+
+ @Provides
+ @Singleton
+ fun providesAppDatabase(
+ @ApplicationContext context: Context,
+ addCallback: RoomDatabase.Callback,
+ ): BrakeDatabase = Room.databaseBuilder(context, BrakeDatabase::class.java, DATABASE_NAME)
+ .addCallback(addCallback)
+ .build()
+}
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/entity/AppEntity.kt b/core/database/src/main/java/com/teambrake/brake/core/database/entity/AppEntity.kt
new file mode 100644
index 00000000..bd7e30d9
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/entity/AppEntity.kt
@@ -0,0 +1,27 @@
+package com.teambrake.brake.core.database.entity
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.teambrake.brake.core.database.BrakeDatabase
+
+@Entity(
+ tableName = BrakeDatabase.APP_TABLE_NAME,
+ foreignKeys = [
+ ForeignKey(
+ entity = GroupEntity::class,
+ parentColumns = [AppGroupEntity.Companion.GROUP_ID],
+ childColumns = [AppGroupEntity.Companion.PARENT_GROUP_ID],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [Index(value = [AppGroupEntity.Companion.PARENT_GROUP_ID])],
+)
+data class AppEntity(
+ @PrimaryKey val id: Long,
+ val packageName: String,
+ val name: String,
+ val category: String,
+ val parentGroupId: Long,
+)
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/entity/AppGroupEntity.kt b/core/database/src/main/java/com/teambrake/brake/core/database/entity/AppGroupEntity.kt
new file mode 100644
index 00000000..87de8dfb
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/entity/AppGroupEntity.kt
@@ -0,0 +1,24 @@
+package com.teambrake.brake.core.database.entity
+
+import androidx.room.Embedded
+import androidx.room.Relation
+
+data class AppGroupEntity(
+ @Embedded val group: GroupEntity,
+ @Relation(
+ parentColumn = GROUP_ID,
+ entityColumn = PARENT_GROUP_ID,
+ )
+ val apps: List,
+ @Relation(
+ parentColumn = GROUP_ID,
+ entityColumn = PARENT_GROUP_ID,
+ )
+ val snoozes: List,
+) {
+
+ companion object {
+ const val GROUP_ID = "groupId"
+ const val PARENT_GROUP_ID = "parentGroupId"
+ }
+}
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/entity/GroupEntity.kt b/core/database/src/main/java/com/teambrake/brake/core/database/entity/GroupEntity.kt
new file mode 100644
index 00000000..9fca6ee6
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/entity/GroupEntity.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.core.database.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.teambrake.brake.core.database.BrakeDatabase
+import com.teambrake.brake.core.model.app.AppGroupState
+import java.time.LocalDateTime
+
+@Entity(tableName = BrakeDatabase.GROUP_TABLE_NAME)
+data class GroupEntity(
+ @PrimaryKey val groupId: Long,
+ val name: String,
+ val appGroupState: AppGroupState,
+ val goalMinutes: Int?,
+ val sessionStartTime: LocalDateTime?,
+ val startTime: LocalDateTime?,
+ val endTime: LocalDateTime?,
+)
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/entity/SnoozeEntity.kt b/core/database/src/main/java/com/teambrake/brake/core/database/entity/SnoozeEntity.kt
new file mode 100644
index 00000000..da8ada88
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/entity/SnoozeEntity.kt
@@ -0,0 +1,26 @@
+package com.teambrake.brake.core.database.entity
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.teambrake.brake.core.database.BrakeDatabase
+import java.time.LocalDateTime
+
+@Entity(
+ tableName = BrakeDatabase.SNOOZE_TABLE_NAME,
+ foreignKeys = [
+ ForeignKey(
+ entity = GroupEntity::class,
+ parentColumns = [AppGroupEntity.Companion.GROUP_ID],
+ childColumns = [AppGroupEntity.Companion.PARENT_GROUP_ID],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [Index(value = [AppGroupEntity.Companion.PARENT_GROUP_ID])],
+)
+data class SnoozeEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val snoozeTime: LocalDateTime,
+ val parentGroupId: Long,
+)
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/util/DatabaseMigrations.kt b/core/database/src/main/java/com/teambrake/brake/core/database/util/DatabaseMigrations.kt
new file mode 100644
index 00000000..2d46cd15
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/util/DatabaseMigrations.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.teambrake.brake.core.database.util
+
+/**
+ * 자동 스키마 마이그레이션은 때때로 마이그레이션을 수행하기 위해 추가 지침이 필요할 수 있습니다.
+ * 예를 들어, 열의 이름이 변경되는 경우와 같은 상황이 이에 해당합니다.
+ * 이러한 추가 지침은 `SchemaXtoY`라는 명명 규칙을 사용하여 클래스를 생성함으로써 여기에 배치됩니다.
+ * 여기서 X는 마이그레이션할 스키마 버전이며, Y는 마이그레이션할 대상 스키마 버전입니다.
+ * 이 클래스는 `AutoMigrationSpec`을 구현해야 합니다.
+ */
+internal object DatabaseMigrations
diff --git a/core/database/src/main/java/com/teambrake/brake/core/database/util/QueryGenerator.kt b/core/database/src/main/java/com/teambrake/brake/core/database/util/QueryGenerator.kt
new file mode 100644
index 00000000..d2b51513
--- /dev/null
+++ b/core/database/src/main/java/com/teambrake/brake/core/database/util/QueryGenerator.kt
@@ -0,0 +1,28 @@
+package com.teambrake.brake.core.database.util
+
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import kotlin.reflect.KProperty1
+
+fun generateInsertQuery(entity: T, tableName: String): String {
+ val kClass = entity::class
+ val columns = mutableListOf()
+ val values = mutableListOf()
+
+ kClass.members.filterIsInstance>().forEach { property ->
+ columns.add(property.name)
+ values.add(formatValue(property.get(entity)))
+ }
+
+ val columnsString = columns.joinToString(", ")
+ val valuesString = values.joinToString(", ")
+
+ return "INSERT INTO $tableName ($columnsString) VALUES ($valuesString);"
+}
+
+private fun formatValue(value: Any?): String = when (value) {
+ is String -> "'$value'"
+ is Boolean -> if (value) "1" else "0"
+ is LocalDate -> "'${value.format(DateTimeFormatter.ISO_LOCAL_DATE)}'"
+ else -> value.toString()
+}
diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts
index a3319d56..b80a768b 100644
--- a/core/datastore/build.gradle.kts
+++ b/core/datastore/build.gradle.kts
@@ -1,7 +1,7 @@
-import com.yapp.breake.setNamespace
+import com.teambrake.brake.setNamespace
plugins {
- alias(libs.plugins.breake.android.library)
+ alias(libs.plugins.brake.android.library)
alias(libs.plugins.kotlin.serialization)
}
@@ -10,10 +10,12 @@ android {
}
dependencies {
+ implementation(projects.core.model)
+
implementation(libs.datastore)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.immutable)
testImplementation(libs.junit4)
testImplementation(libs.kotlin.test)
-}
\ No newline at end of file
+}
diff --git a/core/datastore/src/main/java/com/teambrake/brake/core/datastore/di/DatastoreModule.kt b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/di/DatastoreModule.kt
new file mode 100644
index 00000000..37e3f8a1
--- /dev/null
+++ b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/di/DatastoreModule.kt
@@ -0,0 +1,68 @@
+package com.teambrake.brake.core.datastore.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.dataStore
+import com.teambrake.brake.core.datastore.model.DatastoreAuthCode
+import com.teambrake.brake.core.datastore.model.DatastoreOnboarding
+import com.teambrake.brake.core.datastore.model.DatastoreUserInfo
+import com.teambrake.brake.core.datastore.model.DatastoreUserToken
+import com.teambrake.brake.core.datastore.serializer.AuthSerializer
+import com.teambrake.brake.core.datastore.serializer.OnboardingSerializer
+import com.teambrake.brake.core.datastore.serializer.UserInfoSerializer
+import com.teambrake.brake.core.datastore.serializer.UserSerializer
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@InstallIn(SingletonComponent::class)
+@Module
+object DatastoreModule {
+
+ private val Context.TokenDataStore: DataStore by dataStore(
+ fileName = "user_token",
+ serializer = UserSerializer,
+ )
+
+ private val Context.AuthDataStore: DataStore by dataStore(
+ fileName = "auth_code",
+ serializer = AuthSerializer,
+ )
+
+ private val Context.UserInfoDataStore: DataStore by dataStore(
+ fileName = "user_info",
+ serializer = UserInfoSerializer,
+ )
+
+ private val Context.OnboardingDataStore: DataStore by dataStore(
+ fileName = "onboarding",
+ serializer = OnboardingSerializer,
+ )
+
+ @Provides
+ @Singleton
+ fun provideUserTokenDataStore(
+ @ApplicationContext context: Context,
+ ): DataStore = context.TokenDataStore
+
+ @Provides
+ @Singleton
+ fun provideAuthCodeDataStore(
+ @ApplicationContext context: Context,
+ ): DataStore = context.AuthDataStore
+
+ @Provides
+ @Singleton
+ fun provideUserInfoDataStore(
+ @ApplicationContext context: Context,
+ ): DataStore = context.UserInfoDataStore
+
+ @Provides
+ @Singleton
+ fun provideOnboardingDataStore(
+ @ApplicationContext context: Context,
+ ): DataStore = context.OnboardingDataStore
+}
diff --git a/core/datastore/src/main/java/com/teambrake/brake/core/datastore/encryption/CryptoData.kt b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/encryption/CryptoData.kt
new file mode 100644
index 00000000..37574b6f
--- /dev/null
+++ b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/encryption/CryptoData.kt
@@ -0,0 +1,56 @@
+package com.teambrake.brake.core.datastore.encryption
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.IvParameterSpec
+
+internal class CryptoData {
+ private val cipher = Cipher.getInstance(TRANSFORMATION)
+ private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
+ load(null)
+ }
+
+ companion object {
+ private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
+ private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
+ private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
+ private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING"
+ }
+
+ private fun getKey(): SecretKey {
+ // secret key 존재 여부 확인 후 반환, 없으면 새로 생성
+ val existingKey = keyStore.getEntry("secret", null) as? KeyStore.SecretKeyEntry
+ return existingKey?.secretKey ?: createKey()
+ }
+
+ private fun createKey(): SecretKey = KeyGenerator.getInstance(ALGORITHM).apply {
+ init(
+ KeyGenParameterSpec.Builder(
+ "secret",
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ ).setBlockModes(BLOCK_MODE)
+ .setEncryptionPaddings(PADDING)
+ .build(),
+ )
+ }.generateKey()
+
+ fun encrypt(bytes: ByteArray): ByteArray {
+ // cipher 초기화 및 암호화 수행
+ cipher.init(Cipher.ENCRYPT_MODE, getKey())
+ val iv = cipher.iv
+ val encrypted = cipher.doFinal(bytes)
+ return iv + encrypted
+ }
+
+ fun decrypt(bytes: ByteArray): ByteArray? {
+ // IV 와 암호화된 데이터 분리
+ val iv = bytes.copyOfRange(0, cipher.blockSize)
+ val data = bytes.copyOfRange(cipher.blockSize, bytes.size)
+ cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv))
+ return cipher.doFinal(data)
+ }
+}
diff --git a/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreAuthCode.kt b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreAuthCode.kt
new file mode 100644
index 00000000..abea8725
--- /dev/null
+++ b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreAuthCode.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.core.datastore.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class DatastoreAuthCode(val authCode: String?) {
+ companion object {
+ val Empty = DatastoreAuthCode(authCode = null)
+ }
+}
diff --git a/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreOnboarding.kt b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreOnboarding.kt
new file mode 100644
index 00000000..e977ef62
--- /dev/null
+++ b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreOnboarding.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.core.datastore.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class DatastoreOnboarding(val flag: Boolean) {
+ companion object {
+ val Default = DatastoreOnboarding(flag = false)
+ }
+}
diff --git a/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreUserInfo.kt b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreUserInfo.kt
new file mode 100644
index 00000000..122e949f
--- /dev/null
+++ b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreUserInfo.kt
@@ -0,0 +1,13 @@
+package com.teambrake.brake.core.datastore.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class DatastoreUserInfo(
+ val nickname: String?,
+ val imageUrl: String?,
+) {
+ companion object {
+ val Empty = DatastoreUserInfo(nickname = null, imageUrl = null)
+ }
+}
diff --git a/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreUserToken.kt b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreUserToken.kt
new file mode 100644
index 00000000..e91929f5
--- /dev/null
+++ b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/model/DatastoreUserToken.kt
@@ -0,0 +1,20 @@
+package com.teambrake.brake.core.datastore.model
+
+import com.teambrake.brake.core.model.user.UserStatus
+import com.teambrake.brake.core.model.user.UserStatus.INACTIVE
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class DatastoreUserToken(
+ val accessToken: String?,
+ val refreshToken: String?,
+ val status: UserStatus,
+) {
+ companion object {
+ val Empty = DatastoreUserToken(
+ accessToken = null,
+ refreshToken = null,
+ status = INACTIVE,
+ )
+ }
+}
diff --git a/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/AuthSerializer.kt b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/AuthSerializer.kt
new file mode 100644
index 00000000..c54625d2
--- /dev/null
+++ b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/AuthSerializer.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.core.datastore.serializer
+
+import com.teambrake.brake.core.datastore.model.DatastoreAuthCode
+
+internal val AuthSerializer = DataSerializer(
+ DatastoreAuthCode.Companion.serializer(),
+ DatastoreAuthCode.Companion.Empty,
+ "Auth Code Datastore 읽기 실패",
+)
diff --git a/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/DataSerializer.kt b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/DataSerializer.kt
new file mode 100644
index 00000000..1b12d28d
--- /dev/null
+++ b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/DataSerializer.kt
@@ -0,0 +1,47 @@
+package com.teambrake.brake.core.datastore.serializer
+
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import com.teambrake.brake.core.datastore.encryption.CryptoData
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
+import timber.log.Timber
+import java.io.InputStream
+import java.io.OutputStream
+
+internal class DataSerializer(
+ private val serializer: KSerializer,
+ override val defaultValue: T,
+ private val errorMessage: String = "데이터 읽기 실패",
+) : Serializer {
+ private val cryptoData: CryptoData = CryptoData()
+
+ override suspend fun readFrom(input: InputStream): T = try {
+ val decodeStr = cryptoData.decrypt(input.readBytes())
+ ?: throw CorruptionException(errorMessage)
+ Json.decodeFromString(
+ serializer,
+ decodeStr.decodeToString(),
+ )
+ } catch (serialization: SerializationException) {
+ Timber.e(serialization, errorMessage)
+ defaultValue
+ }
+
+ override suspend fun writeTo(
+ t: T,
+ output: OutputStream,
+ ) {
+ withContext(Dispatchers.IO) {
+ output.write(
+ cryptoData.encrypt(
+ Json.encodeToString(serializer, t)
+ .encodeToByteArray(),
+ ),
+ )
+ }
+ }
+}
diff --git a/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/OnboardingSerializer.kt b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/OnboardingSerializer.kt
new file mode 100644
index 00000000..14639399
--- /dev/null
+++ b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/OnboardingSerializer.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.core.datastore.serializer
+
+import com.teambrake.brake.core.datastore.model.DatastoreOnboarding
+
+internal val OnboardingSerializer = DataSerializer(
+ DatastoreOnboarding.Companion.serializer(),
+ DatastoreOnboarding.Companion.Default,
+ "Onboarding Flag Datastore 읽기 실패",
+)
diff --git a/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/UserInfoSerializer.kt b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/UserInfoSerializer.kt
new file mode 100644
index 00000000..b7acc699
--- /dev/null
+++ b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/UserInfoSerializer.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.core.datastore.serializer
+
+import com.teambrake.brake.core.datastore.model.DatastoreUserInfo
+
+internal val UserInfoSerializer = DataSerializer(
+ DatastoreUserInfo.Companion.serializer(),
+ DatastoreUserInfo.Companion.Empty,
+ "User Info Datastore 읽기 실패",
+)
diff --git a/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/UserSerializer.kt b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/UserSerializer.kt
new file mode 100644
index 00000000..b93bf089
--- /dev/null
+++ b/core/datastore/src/main/java/com/teambrake/brake/core/datastore/serializer/UserSerializer.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.core.datastore.serializer
+
+import com.teambrake.brake.core.datastore.model.DatastoreUserToken
+
+internal val UserSerializer = DataSerializer(
+ DatastoreUserToken.Companion.serializer(),
+ DatastoreUserToken.Companion.Empty,
+ "User Token Datastore 읽기 실패",
+)
diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts
index b960da07..580e240e 100644
--- a/core/designsystem/build.gradle.kts
+++ b/core/designsystem/build.gradle.kts
@@ -1,8 +1,8 @@
-import com.yapp.breake.setNamespace
+import com.teambrake.brake.setNamespace
plugins {
- alias(libs.plugins.breake.android.library)
- alias(libs.plugins.breake.android.compose)
+ alias(libs.plugins.brake.android.library)
+ alias(libs.plugins.brake.android.compose)
}
android {
@@ -10,6 +10,8 @@ android {
}
dependencies {
+ implementation(projects.core.util)
+
implementation(libs.androidx.appcompat)
implementation(libs.landscapist.bom)
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Button.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Button.kt
new file mode 100644
index 00000000..2470a836
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Button.kt
@@ -0,0 +1,226 @@
+package com.teambrake.brake.core.designsystem.component
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.R
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.core.designsystem.theme.Gray700
+import com.teambrake.brake.core.designsystem.theme.Gray800
+import com.teambrake.brake.core.designsystem.theme.LocalDynamicPaddings
+import com.teambrake.brake.core.designsystem.theme.Red
+import com.teambrake.brake.core.designsystem.util.BooleanProvider
+import com.teambrake.brake.core.designsystem.util.MultipleEventsCutter
+import com.teambrake.brake.core.designsystem.util.get
+
+@Composable
+fun LargeButton(
+ text: String,
+ modifier: Modifier = Modifier,
+ textStyle: TextStyle = BrakeTheme.typography.subtitle16B,
+ paddingValues: PaddingValues = PaddingValues(vertical = 18.dp, horizontal = 16.dp),
+ leadingIcon: @Composable (() -> Unit)? = null,
+ colors: ButtonColors = ButtonDefaults.buttonColors(
+ disabledContainerColor = Gray700,
+ disabledContentColor = MaterialTheme.colorScheme.onPrimary,
+ ),
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter.get() }
+
+ val density = LocalDensity.current
+ val dynamicPaddingsProvider = LocalDynamicPaddings.current
+
+ Button(
+ shape = MaterialTheme.shapes.large,
+ colors = colors,
+ contentPadding = paddingValues,
+ enabled = enabled,
+ onClick = { multipleEventsCutter.processEvent(onClick) },
+ modifier = modifier
+ .fillMaxWidth()
+ .onGloballyPositioned { coordinates ->
+ with(density) {
+ dynamicPaddingsProvider.updateOneButtonHeight(
+ coordinates.size.height.toDp() + (24 + 12).dp,
+ )
+ }
+ },
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ leadingIcon?.let {
+ leadingIcon()
+ HorizontalSpacer(6.dp)
+ }
+ Text(
+ text = text,
+ style = textStyle,
+ )
+ }
+ }
+}
+
+@Composable
+fun SmallButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter.get() }
+
+ Button(
+ shape = CircleShape,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ disabledContainerColor = MaterialTheme.colorScheme.background,
+ disabledContentColor = Gray200,
+ ),
+ contentPadding = PaddingValues(vertical = 13.dp, horizontal = 16.dp),
+ enabled = enabled,
+ onClick = { multipleEventsCutter.processEvent(onClick) },
+ modifier = modifier,
+ ) {
+ Text(
+ text = text,
+ style = BrakeTheme.typography.subtitle16B,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.defaultMinSize(minWidth = 120.dp),
+ )
+ }
+}
+
+@Composable
+fun BoxButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ color: Color = Red,
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter.get() }
+
+ Button(
+ shape = MaterialTheme.shapes.medium,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = color.copy(alpha = 0.18f),
+ contentColor = color,
+ ),
+ contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
+ onClick = { multipleEventsCutter.processEvent(onClick) },
+ modifier = modifier,
+ ) {
+ Text(
+ text = text,
+ style = BrakeTheme.typography.body14M,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Composable
+fun CircleButton(
+ @DrawableRes icon: Int,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ containerColor: Color = MaterialTheme.colorScheme.secondary,
+ contentColor: Color = Gray800,
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter.get() }
+
+ Surface(
+ shape = CircleShape,
+ color = containerColor,
+ onClick = { multipleEventsCutter.processEvent(onClick) },
+ modifier = modifier.size(56.dp),
+ ) {
+ Icon(
+ painter = painterResource(id = icon),
+ contentDescription = null,
+ tint = contentColor,
+ modifier = Modifier.padding(8.dp),
+ )
+ }
+}
+
+@Preview("Large Buttons")
+@Composable
+private fun LargeButtonPreview(
+ @PreviewParameter(BooleanProvider::class) enabled: Boolean,
+) {
+ BrakeTheme {
+ LargeButton(
+ text = "Large Button",
+ onClick = { },
+ enabled = enabled,
+ )
+ }
+}
+
+@Preview("Small Buttons")
+@Composable
+private fun SmallButtonPreview(
+ @PreviewParameter(BooleanProvider::class) enabled: Boolean,
+) {
+ BrakeTheme {
+ SmallButton(
+ text = "Small Button",
+ onClick = { },
+ enabled = enabled,
+ )
+ }
+}
+
+@Preview("Box Button")
+@Composable
+private fun BoxButtonPreview() {
+ BrakeTheme {
+ BoxButton(
+ text = "Button",
+ onClick = { },
+ )
+ }
+}
+
+@Preview("Box Button")
+@Composable
+private fun CircleButtonPreview() {
+ BrakeTheme {
+ CircleButton(
+ icon = R.drawable.ic_close,
+ onClick = { },
+ )
+ }
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/CircleImage.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/CircleImage.kt
new file mode 100644
index 00000000..5958aeff
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/CircleImage.kt
@@ -0,0 +1,54 @@
+package com.teambrake.brake.core.designsystem.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.skydoves.landscapist.coil.CoilImage
+import com.teambrake.brake.core.designsystem.R
+import com.teambrake.brake.core.designsystem.theme.Gray850
+
+@Composable
+fun CircleImage(
+ modifier: Modifier = Modifier,
+ imageUrl: String? = null,
+) {
+ CoilImage(
+ imageModel = { imageUrl },
+ modifier = modifier
+ .widthIn(min = 40.dp)
+ .aspectRatio(1f)
+ .clip(shape = CircleShape),
+ // 이미지 로딩 중 표시 내용
+ loading = {
+ Box(modifier = Modifier.matchParentSize()) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ },
+ // 이미지 요청 실패 시 표시 내용
+ failure = {
+ Box(
+ modifier = Modifier.matchParentSize().background(Gray850).padding(15.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.img_profile_default),
+ contentDescription = null,
+ modifier = Modifier.matchParentSize(),
+ )
+ }
+ },
+ )
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Dialog.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Dialog.kt
new file mode 100644
index 00000000..5de6ec07
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Dialog.kt
@@ -0,0 +1,238 @@
+package com.teambrake.brake.core.designsystem.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.teambrake.brake.core.designsystem.R
+import com.teambrake.brake.core.designsystem.modifier.clickableSingle
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.ButtonYellow
+import com.teambrake.brake.core.designsystem.theme.Gray300
+import com.teambrake.brake.core.designsystem.theme.Gray400
+import com.teambrake.brake.core.designsystem.theme.Gray800
+import com.teambrake.brake.core.designsystem.theme.Gray850
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.core.designsystem.util.MultipleEventsCutter
+import com.teambrake.brake.core.designsystem.util.get
+
+@Composable
+fun BaseDialog(
+ onDismissRequest: () -> Unit,
+ confirmButton: (@Composable () -> Unit)? = null,
+ dismissButton: (@Composable () -> Unit)? = null,
+ content: @Composable () -> Unit = { },
+) {
+ Dialog(
+ properties = DialogProperties(),
+ onDismissRequest = onDismissRequest,
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(20.dp))
+ .background(Gray850),
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(16.dp),
+ ) {
+ VerticalSpacer(30.dp)
+ content()
+ VerticalSpacer(42.dp)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(
+ 6.dp,
+ Alignment.CenterHorizontally,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ dismissButton?.let {
+ Box(modifier = Modifier.weight(1f)) {
+ it()
+ }
+ }
+ confirmButton?.let {
+ Box(modifier = Modifier.weight(1f)) {
+ it()
+ }
+ }
+ }
+ }
+ if (dismissButton == null) {
+ Icon(
+ painter = painterResource(R.drawable.ic_close),
+ contentDescription = "Close",
+ tint = Gray400,
+ modifier = Modifier
+ .padding(16.dp)
+ .align(Alignment.TopEnd)
+ .clickableSingle(onDismissRequest),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun OneButtonDialog(
+ buttonText: String,
+ onButtonClick: () -> Unit,
+ onDismissRequest: () -> Unit,
+ content: @Composable () -> Unit,
+) {
+ BaseDialog(
+ onDismissRequest = onDismissRequest,
+ confirmButton = {
+ DialogButton(
+ text = buttonText,
+ onClick = onButtonClick,
+ )
+ },
+ content = content,
+ )
+}
+
+@Composable
+fun TwoButtonDialog(
+ dismissButtonText: String,
+ confirmButtonText: String,
+ onDismissRequest: () -> Unit,
+ onConfirmButtonClick: () -> Unit,
+ onDismissButtonClick: () -> Unit = onDismissRequest,
+ content: @Composable () -> Unit,
+) {
+ BaseDialog(
+ onDismissRequest = onDismissRequest,
+ dismissButton = {
+ DialogButton(
+ text = dismissButtonText,
+ onClick = onDismissButtonClick,
+ containerColor = Gray800,
+ contentColor = White,
+ )
+ },
+ confirmButton = {
+ DialogButton(
+ text = confirmButtonText,
+ onClick = onConfirmButtonClick,
+ )
+ },
+ content = content,
+ )
+}
+
+@Composable
+fun DialogButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ containerColor: Color = ButtonYellow,
+ contentColor: Color = Gray850,
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter.get() }
+
+ Button(
+ shape = RoundedCornerShape(12.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = containerColor,
+ contentColor = contentColor,
+ ),
+ contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
+ enabled = enabled,
+ onClick = { multipleEventsCutter.processEvent(onClick) },
+ modifier = modifier,
+ ) {
+ Text(
+ text = text,
+ style = BrakeTheme.typography.subtitle16SB,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun TwoButtonsDialog() {
+ BrakeTheme {
+ BaseDialog(
+ onDismissRequest = { },
+ dismissButton = {
+ DialogButton(
+ text = "취소",
+ onClick = {},
+ containerColor = Gray800,
+ contentColor = White,
+ )
+ },
+ confirmButton = {
+ DialogButton(
+ text = "탈퇴",
+ onClick = {},
+ )
+ },
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.img_warning),
+ contentDescription = null,
+ )
+ VerticalSpacer(16.dp)
+ Text(
+ text = "정말 탈퇴하시겠어요?",
+ style = BrakeTheme.typography.subtitle22SB,
+ color = White,
+ )
+ VerticalSpacer(12.dp)
+ Text(
+ text = "탈퇴하면 모든 계정 정보와 이용 기록이 \n삭제되며, 복구할 수 없습니다.",
+ style = BrakeTheme.typography.body16M,
+ color = Gray300,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun OneButtonDialogPreview() {
+ BrakeTheme {
+ OneButtonDialog(
+ buttonText = "확인",
+ onButtonClick = { },
+ onDismissRequest = { },
+ ) {
+
+ }
+ }
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/DotProgressIndicator.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/DotProgressIndicator.kt
new file mode 100644
index 00000000..826b5e44
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/DotProgressIndicator.kt
@@ -0,0 +1,76 @@
+package com.teambrake.brake.core.designsystem.component
+
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.StartOffset
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.theme.BrakeYellow
+
+@Composable
+fun DotProgressIndicator(
+ dotCount: Int = 4,
+ dotSize: Dp = 12.dp,
+ dotColor: Color = BrakeYellow,
+ animationDuration: Int = 600,
+ staggerDelay: Int = 150,
+) {
+ val transition = rememberInfiniteTransition()
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(dotSize),
+ ) {
+ repeat(dotCount) { index ->
+ // Scale 애니메이션
+ val scale by transition.animateFloat(
+ initialValue = 0.4f,
+ targetValue = 1.0f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(
+ durationMillis = animationDuration,
+ easing = FastOutLinearInEasing,
+ ),
+ repeatMode = RepeatMode.Reverse,
+ initialStartOffset = StartOffset(index * staggerDelay),
+ ),
+ )
+ // Alpha 애니메이션
+ val alpha by transition.animateFloat(
+ initialValue = 0.3f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(
+ durationMillis = animationDuration,
+ easing = FastOutLinearInEasing,
+ ),
+ repeatMode = RepeatMode.Reverse,
+ initialStartOffset = StartOffset(index * staggerDelay),
+ ),
+ )
+ Box(
+ modifier = Modifier
+ .size(dotSize)
+ .graphicsLayer(
+ scaleX = scale,
+ scaleY = scale,
+ alpha = alpha,
+ )
+ .background(color = dotColor, shape = CircleShape),
+ )
+ }
+ }
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Scaffold.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Scaffold.kt
new file mode 100644
index 00000000..8bd23439
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Scaffold.kt
@@ -0,0 +1,105 @@
+package com.teambrake.brake.core.designsystem.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun BaseScaffold(
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ topBar: @Composable () -> Unit = {},
+ bottomBar: @Composable () -> Unit = {},
+ snackBarHost: @Composable () -> Unit = {},
+ floatingActionButton: @Composable () -> Unit = {},
+ containerColor: Color = MaterialTheme.colorScheme.background,
+ contentColor: Color = contentColorFor(containerColor),
+ statusBarColor: Color = MaterialTheme.colorScheme.background,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Scaffold(
+ topBar = topBar,
+ bottomBar = bottomBar,
+ snackbarHost = snackBarHost,
+ floatingActionButton = floatingActionButton,
+ containerColor = containerColor,
+ contentColor = contentColor,
+ modifier = modifier
+ .navigationBarsPadding()
+ .background(color = statusBarColor)
+ .statusBarsPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(it)
+ .padding(contentPadding),
+ ) {
+ content()
+ }
+ }
+}
+
+@Composable
+fun GradientScaffold(
+ gradient: Brush,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ topBar: @Composable () -> Unit = {},
+ bottomBar: @Composable () -> Unit = {},
+ snackBarHost: @Composable () -> Unit = {},
+ floatingActionButton: @Composable () -> Unit = {},
+ containerColor: Color = MaterialTheme.colorScheme.background,
+ contentColor: Color = contentColorFor(containerColor),
+ statusBarColor: Color = MaterialTheme.colorScheme.background,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Scaffold(
+ topBar = topBar,
+ bottomBar = bottomBar,
+ snackbarHost = snackBarHost,
+ floatingActionButton = floatingActionButton,
+ containerColor = containerColor,
+ contentColor = contentColor,
+ modifier = modifier
+ .navigationBarsPadding()
+ .background(color = statusBarColor)
+ .statusBarsPadding(),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.4f)
+ .background(gradient),
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(it)
+ .padding(contentPadding),
+ ) {
+ content()
+ }
+ }
+ }
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/SettingRow.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/SettingRow.kt
new file mode 100644
index 00000000..f4ba55c9
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/SettingRow.kt
@@ -0,0 +1,67 @@
+package com.teambrake.brake.core.designsystem.component
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.R
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray300
+
+@Composable
+fun SettingRow(
+ @StringRes id: Int,
+ onClick: () -> Unit,
+ trailing: @Composable (() -> Unit) = {
+ Image(
+ painter = painterResource(R.drawable.ic_arraow),
+ contentDescription = null,
+ )
+ },
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(
+ enabled = true,
+ onClick = onClick,
+ indication = LocalIndication.current,
+ interactionSource = null,
+ )
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Absolute.SpaceBetween,
+ ) {
+ Text(
+ text = stringResource(id),
+ style = BrakeTheme.typography.body16M,
+ color = Gray300,
+ modifier = Modifier,
+ )
+
+ trailing()
+ }
+}
+
+@Preview
+@Composable
+fun SettingRowPreview() {
+ BrakeTheme {
+ SettingRow(
+ id = R.string.preview_string,
+ onClick = {},
+ )
+ }
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Spacer.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Spacer.kt
new file mode 100644
index 00000000..b5a43f20
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Spacer.kt
@@ -0,0 +1,41 @@
+package com.teambrake.brake.core.designsystem.component
+
+import androidx.annotation.FloatRange
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+
+@Composable
+fun VerticalSpacer(
+ height: Dp,
+) {
+ Spacer(modifier = Modifier.height(height))
+}
+
+@Composable
+fun HorizontalSpacer(
+ width: Dp,
+) {
+ Spacer(modifier = Modifier.width(width))
+}
+
+@Composable
+fun RowScope.HorizontalSpacer(
+ @FloatRange(from = 0.0, fromInclusive = false)
+ width: Float,
+) {
+ Spacer(modifier = Modifier.weight(width))
+}
+
+@Composable
+fun ColumnScope.VerticalSpacer(
+ @FloatRange(from = 0.0, fromInclusive = false)
+ height: Float,
+) {
+ Spacer(modifier = Modifier.weight(height))
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Switch.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Switch.kt
new file mode 100644
index 00000000..f242f3c8
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/Switch.kt
@@ -0,0 +1,67 @@
+package com.teambrake.brake.core.designsystem.component
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.LocalMinimumInteractiveComponentSize
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray850
+import com.teambrake.brake.core.designsystem.theme.Green
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.core.designsystem.util.BooleanProvider
+
+@Composable
+fun BrakeSwitch(
+ checked: Boolean,
+ onCheckedChange: ((Boolean) -> Unit),
+ modifier: Modifier = Modifier,
+ color: Color = Green,
+) {
+ CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
+ Switch(
+ checked = checked,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = White,
+ uncheckedThumbColor = White,
+ checkedTrackColor = color,
+ uncheckedTrackColor = Gray850,
+ checkedBorderColor = color,
+ uncheckedBorderColor = Gray850,
+ ),
+ thumbContent = {
+ Canvas(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ drawCircle(
+ color = White,
+ radius = 38f,
+ center = center,
+ )
+ }
+ },
+ onCheckedChange = onCheckedChange,
+ modifier = modifier,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SwitchPreview(
+ @PreviewParameter(BooleanProvider::class) checked: Boolean,
+) {
+ BrakeTheme {
+ BrakeSwitch(
+ checked = checked,
+ onCheckedChange = {},
+ )
+ }
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/TextField.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/TextField.kt
new file mode 100644
index 00000000..178f4f92
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/TextField.kt
@@ -0,0 +1,72 @@
+package com.teambrake.brake.core.designsystem.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray600
+import com.teambrake.brake.core.designsystem.theme.Gray850
+
+@Composable
+fun BaseTextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ onValueChange: (String) -> Unit,
+ placeholder: String,
+ singleLine: Boolean = true,
+ leadingIcon: Painter? = null,
+ trailingIcon: Painter? = null,
+ supportingText: @Composable (() -> Unit)? = null,
+ keyboardActions: KeyboardActions,
+) {
+ OutlinedTextField(
+ modifier = modifier,
+ value = value,
+ onValueChange = onValueChange,
+ placeholder = {
+ Text(
+ text = placeholder,
+ color = Gray600,
+ style = BrakeTheme.typography.body16M,
+ )
+ },
+ singleLine = singleLine,
+ leadingIcon = leadingIcon?.let {
+ {
+ Image(
+ painter = it,
+ contentDescription = null,
+ )
+ }
+ },
+ trailingIcon = trailingIcon?.let {
+ {
+ Image(
+ painter = it,
+ contentDescription = null,
+ )
+ }
+ },
+ supportingText = supportingText,
+ shape = RoundedCornerShape(12.dp),
+ colors = OutlinedTextFieldDefaults.colors(
+ unfocusedBorderColor = Color.Transparent,
+ focusedBorderColor = Color.Transparent,
+ disabledBorderColor = Color.Transparent,
+ errorBorderColor = Color.Transparent,
+ focusedContainerColor = Gray850,
+ unfocusedContainerColor = Gray850,
+ disabledContainerColor = Gray850,
+ errorContainerColor = Gray850,
+ ),
+ keyboardActions = keyboardActions,
+ )
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/TopAppbar.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/TopAppbar.kt
new file mode 100644
index 00000000..c44a3290
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/component/TopAppbar.kt
@@ -0,0 +1,147 @@
+package com.teambrake.brake.core.designsystem.component
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.R
+import com.teambrake.brake.core.designsystem.modifier.clickableSingle
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray200
+
+@Composable
+fun BrakeTopAppbar(
+ modifier: Modifier = Modifier,
+ title: String = "",
+ appbarType: TopAppbarType = TopAppbarType.Back,
+ onClick: () -> Unit = {},
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Min)
+ .background(MaterialTheme.colorScheme.background)
+ .padding(horizontal = 16.dp, vertical = 10.dp)
+ .statusBarsPadding(),
+ ) {
+ if (appbarType == TopAppbarType.Back || appbarType is TopAppbarType.TextButton) {
+ TopAppbarIcon(
+ icon = R.drawable.ic_back_28,
+ onClick = onClick,
+ modifier = Modifier.align(Alignment.CenterStart),
+ )
+ }
+ Text(
+ text = title,
+ modifier = Modifier.align(Alignment.Center),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = BrakeTheme.typography.subtitle16SB,
+ maxLines = 1,
+ textAlign = TextAlign.Center,
+ )
+ when (appbarType) {
+ TopAppbarType.Back -> {}
+ TopAppbarType.Cancel -> {
+ TopAppbarIcon(
+ icon = R.drawable.ic_close,
+ onClick = onClick,
+ modifier = Modifier.align(Alignment.CenterEnd),
+ )
+ }
+
+ is TopAppbarType.TextButton -> {
+ Box(
+ modifier = Modifier
+ .fillMaxHeight()
+ .clip(MaterialTheme.shapes.large)
+ .align(Alignment.CenterEnd)
+ .clickableSingle(appbarType.onClick),
+ ) {
+ Text(
+ modifier = Modifier.align(Alignment.Center),
+ text = appbarType.text,
+ color = Gray200,
+ style = BrakeTheme.typography.body16M,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+ }
+}
+
+sealed interface TopAppbarType {
+ data object Back : TopAppbarType
+ data object Cancel : TopAppbarType
+ data class TextButton(val text: String, val onClick: () -> Unit) : TopAppbarType
+}
+
+@Composable
+fun TopAppbarIcon(
+ @DrawableRes icon: Int,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .clickableSingle(onClick = onClick),
+ ) {
+ Icon(
+ painter = painterResource(icon),
+ contentDescription = "menu Icon",
+ tint = Gray200,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun BrakeTopAppbarPreview() {
+ BrakeTheme {
+ BrakeTopAppbar(
+ title = "title",
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun CancelPreview() {
+ BrakeTheme {
+ BrakeTopAppbar(
+ title = "title",
+ appbarType = TopAppbarType.Cancel,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun TextButtonPreview() {
+ BrakeTheme {
+ BrakeTopAppbar(
+ title = "title",
+ appbarType = TopAppbarType.TextButton(
+ text = "button",
+ onClick = {},
+ ),
+ )
+ }
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/modifier/ClearFocusOnKeyboardDismiss.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/modifier/ClearFocusOnKeyboardDismiss.kt
new file mode 100644
index 00000000..993ef967
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/modifier/ClearFocusOnKeyboardDismiss.kt
@@ -0,0 +1,52 @@
+package com.teambrake.brake.core.designsystem.modifier
+
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.isImeVisible
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.focus.onFocusEvent
+import androidx.compose.ui.platform.LocalFocusManager
+
+/**
+* Modifier that clears focus when the keyboard is dismissed.
+*
+* To clear the focus of a TextField when the keyboard is dismissed,
+* use the LocalFocusManager and call clearFocus() inside the modifier.clickable() of the root composable.
+ * ```kotlin
+ * val focusManager = LocalFocusManager.current
+ * focusManager.clearFocus()
+ * ```
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Stable
+fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed {
+ var isFocused by remember { mutableStateOf(false) }
+ var keyboardAppearedSinceLastFocused by remember { mutableStateOf(false) }
+
+ if (isFocused) {
+ val imeIsVisible = WindowInsets.isImeVisible
+ val focusManager = LocalFocusManager.current
+
+ LaunchedEffect(imeIsVisible) {
+ if (imeIsVisible) {
+ keyboardAppearedSinceLastFocused = true
+ } else if (keyboardAppearedSinceLastFocused) {
+ focusManager.clearFocus()
+ }
+ }
+ }
+
+ onFocusEvent {
+ if (isFocused != it.isFocused) {
+ isFocused = it.isFocused
+ if (isFocused) keyboardAppearedSinceLastFocused = false
+ }
+ }
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/modifier/ClickableSingle.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/modifier/ClickableSingle.kt
new file mode 100644
index 00000000..8471c638
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/modifier/ClickableSingle.kt
@@ -0,0 +1,37 @@
+package com.teambrake.brake.core.designsystem.modifier
+
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.semantics.Role
+import com.teambrake.brake.core.designsystem.util.MultipleEventsCutter
+import com.teambrake.brake.core.designsystem.util.get
+
+fun Modifier.clickableSingle(
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+ onClickLabel: String? = null,
+ role: Role? = null,
+) = composed(
+ inspectorInfo = debugInspectorInfo {
+ name = "clickable"
+ properties["enabled"] = enabled
+ properties["onClickLabel"] = onClickLabel
+ properties["role"] = role
+ properties["onClick"] = onClick
+ },
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter.Companion.get() }
+ Modifier.clickable(
+ enabled = enabled,
+ onClickLabel = onClickLabel,
+ onClick = { multipleEventsCutter.processEvent(onClick) },
+ role = role,
+ indication = LocalIndication.current,
+ interactionSource = remember { MutableInteractionSource() },
+ )
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Color.kt
new file mode 100644
index 00000000..5fd144a2
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Color.kt
@@ -0,0 +1,28 @@
+package com.teambrake.brake.core.designsystem.theme
+
+import androidx.compose.ui.graphics.Color
+
+val White = Color(0xFFF2F2F2)
+
+val Gray950 = Color(0xFF0B0C0D)
+val Gray900 = Color(0xFF1E2023)
+val Gray850 = Color(0xFF292C31)
+val Gray800 = Color(0xFF3A3D45)
+val Gray700 = Color(0xFF5A5F6C)
+val Gray600 = Color(0xFF777D8F)
+val Gray500 = Color(0xFF9299AD)
+val Gray400 = Color(0xFFACB3C7)
+val Gray300 = Color(0xFFC3CADB)
+val Gray200 = Color(0xFFD9DEEC)
+val Gray100 = Color(0xFFEDF0F8)
+val Gray50 = Color(0xFFF6F8FC)
+
+val InsightBlue = Color(0xFF2684FF)
+val BrakeYellow = Color(0xFFF2FF5E)
+val ButtonYellow = Color(0xFFF3FF6E)
+val KakaoYellow = Color(0xFFFFE502)
+val Error = Color(0xFFFF5762)
+
+val Red = Color(0xFFFF4343)
+val Red2 = Color(0xFFFF5656)
+val Green = Color(0xFF32D74B)
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Gradient.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Gradient.kt
new file mode 100644
index 00000000..1ca94231
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Gradient.kt
@@ -0,0 +1,30 @@
+package com.teambrake.brake.core.designsystem.theme
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+val LinerGradient = Brush.linearGradient(
+ colors = listOf(
+ Color(0x26C0DBFF),
+ Color(0x0BC0DBFF),
+ Color(0x00C0DBFF),
+ ),
+ start = Offset(0f, 0f),
+ end = Offset(0f, Float.POSITIVE_INFINITY),
+)
+
+val AppItemGradient = Brush.linearGradient(
+ colorStops = arrayOf(
+ 0.2837f to Color(0xFF292C31),
+ 0.7582f to Color(0xFF32363B),
+ ),
+ start = Offset(0f, 0f),
+ end = Offset(
+ cos(110.23 * PI / 180).toFloat() * 1000,
+ sin(110.23 * PI / 180).toFloat() * 1000,
+ ),
+)
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Padding.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Padding.kt
new file mode 100644
index 00000000..93c289d5
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Padding.kt
@@ -0,0 +1,42 @@
+package com.teambrake.brake.core.designsystem.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+val Paddings = BrakePadding()
+
+val LocalPadding = staticCompositionLocalOf { BrakePadding() }
+val LocalDynamicPaddings = staticCompositionLocalOf { DynamicPaddingsProvider() }
+
+@Immutable
+data class BrakePadding(
+ val screenPaddingHorizontal: Dp = 16.dp,
+)
+
+data class DynamicPaddings(
+ val bottomNavBarHeight: Dp = 100.dp,
+ val oneButtonHeight: Dp = 80.dp,
+ val twoButtonHeight: Dp = 130.dp,
+)
+
+class DynamicPaddingsProvider {
+ var paddings by mutableStateOf(DynamicPaddings())
+ private set
+
+ fun updateBottomNavHeight(height: Dp) {
+ paddings = paddings.copy(bottomNavBarHeight = height)
+ }
+
+ fun updateOneButtonHeight(height: Dp) {
+ paddings = paddings.copy(oneButtonHeight = height)
+ }
+
+ fun updateTwoButtonHeight(height: Dp) {
+ paddings = paddings.copy(twoButtonHeight = height)
+ }
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Theme.kt
new file mode 100644
index 00000000..0a8a4fa6
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Theme.kt
@@ -0,0 +1,83 @@
+package com.teambrake.brake.core.designsystem.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val DarkColorScheme = darkColorScheme(
+ primary = ButtonYellow,
+ onPrimary = Gray900,
+ secondary = Gray100,
+ onSecondary = Gray800,
+ error = Error,
+ surface = Gray850,
+ surfaceVariant = Gray800,
+ onSurface = White,
+ onSurfaceVariant = Gray300,
+ outline = Gray800,
+ outlineVariant = Gray800,
+ background = Gray950,
+ onBackground = White,
+)
+
+@Composable
+fun BrakeTheme(
+ darkTheme: Boolean = true,
+ content: @Composable () -> Unit,
+) {
+ val colorScheme = DarkColorScheme
+
+ if (!LocalInspectionMode.current) {
+ val view = LocalView.current
+ SideEffect {
+ val window = (view.context as Activity).window
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
+ WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme
+
+ // API level Before EnableEdgeToEdge is Available
+ @Suppress("DEPRECATION")
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ window.apply {
+ statusBarColor = colorScheme.background.toArgb()
+ navigationBarColor = colorScheme.background.toArgb()
+ }
+ }
+ }
+ }
+
+ CompositionLocalProvider(
+ LocalTypography provides Typography,
+ LocalPadding provides Paddings,
+ LocalContentColor provides colorScheme.onSurface,
+ ) {
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = content,
+ )
+ }
+}
+
+object BrakeTheme {
+
+ val typography: BrakeTypography
+ @Composable
+ get() = LocalTypography.current
+
+ val paddings: BrakePadding
+ @Composable
+ get() = LocalPadding.current
+
+ val colorScheme: ColorScheme
+ @Composable
+ get() = DarkColorScheme
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Typography.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Typography.kt
new file mode 100644
index 00000000..2f043f8f
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/theme/Typography.kt
@@ -0,0 +1,186 @@
+package com.teambrake.brake.core.designsystem.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.text.PlatformTextStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import com.teambrake.brake.core.designsystem.R
+
+val pretendard = FontFamily(
+ Font(R.font.pretendard_bold, FontWeight.Bold),
+ Font(R.font.pretendard_semi_bold, FontWeight.SemiBold),
+ Font(R.font.pretendard_medium, FontWeight.Medium),
+ Font(R.font.pretendard_reqular, FontWeight.Normal),
+)
+
+private val pretendardStyle = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.Normal,
+ letterSpacing = 0.sp,
+ platformStyle = PlatformTextStyle(
+ includeFontPadding = false,
+ ),
+)
+
+val Typography = BrakeTypography(
+ title48B = pretendardStyle.copy(
+ fontSize = 48.sp,
+ lineHeight = 48.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ title40B = pretendardStyle.copy(
+ fontSize = 40.sp,
+ lineHeight = 60.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ title28B = pretendardStyle.copy(
+ fontSize = 28.sp,
+ lineHeight = 39.2.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ title24B = pretendardStyle.copy(
+ fontSize = 24.sp,
+ lineHeight = 24.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ subtitle24SB = pretendardStyle.copy(
+ fontSize = 24.sp,
+ lineHeight = 36.sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ subtitle22B = pretendardStyle.copy(
+ fontSize = 22.sp,
+ lineHeight = 33.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ subtitle22SB = pretendardStyle.copy(
+ fontSize = 22.sp,
+ lineHeight = 33.sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ subtitle20B = pretendardStyle.copy(
+ fontSize = 20.sp,
+ lineHeight = 30.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ subtitle20SB = pretendardStyle.copy(
+ fontSize = 20.sp,
+ lineHeight = 30.sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ subtitle18B = pretendardStyle.copy(
+ fontSize = 18.sp,
+ lineHeight = 27.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ subtitle18SB = pretendardStyle.copy(
+ fontSize = 18.sp,
+ lineHeight = 27.sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ subtitle16B = pretendardStyle.copy(
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ subtitle16SB = pretendardStyle.copy(
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ subtitle14B = pretendardStyle.copy(
+ fontSize = 14.sp,
+ lineHeight = 21.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ subtitle14SB = pretendardStyle.copy(
+ fontSize = 14.sp,
+ lineHeight = 21.sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ body16M = pretendardStyle.copy(
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ body14SB = pretendardStyle.copy(
+ fontSize = 14.sp,
+ lineHeight = 21.sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ body14M = pretendardStyle.copy(
+ fontSize = 14.sp,
+ lineHeight = 21.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ body12B = pretendardStyle.copy(
+ fontSize = 12.sp,
+ lineHeight = 18.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ body12M = pretendardStyle.copy(
+ fontSize = 12.sp,
+ lineHeight = 18.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ body10B = pretendardStyle.copy(
+ fontSize = 10.sp,
+ lineHeight = 15.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+)
+
+@Immutable
+data class BrakeTypography(
+ val title48B: TextStyle,
+ val title40B: TextStyle,
+ val title28B: TextStyle,
+ val title24B: TextStyle,
+ val subtitle24SB: TextStyle,
+ val subtitle22B: TextStyle,
+ val subtitle22SB: TextStyle,
+ val subtitle20B: TextStyle,
+ val subtitle20SB: TextStyle,
+ val subtitle18B: TextStyle,
+ val subtitle18SB: TextStyle,
+ val subtitle16B: TextStyle,
+ val subtitle16SB: TextStyle,
+ val subtitle14B: TextStyle,
+ val subtitle14SB: TextStyle,
+ val body16M: TextStyle,
+ val body14SB: TextStyle,
+ val body14M: TextStyle,
+ val body12B: TextStyle,
+ val body12M: TextStyle,
+ val body10B: TextStyle,
+)
+
+val LocalTypography = staticCompositionLocalOf {
+ BrakeTypography(
+ title48B = pretendardStyle,
+ title40B = pretendardStyle,
+ title28B = pretendardStyle,
+ title24B = pretendardStyle,
+ subtitle24SB = pretendardStyle,
+ subtitle22B = pretendardStyle,
+ subtitle22SB = pretendardStyle,
+ subtitle20B = pretendardStyle,
+ subtitle20SB = pretendardStyle,
+ subtitle18B = pretendardStyle,
+ subtitle18SB = pretendardStyle,
+ subtitle16B = pretendardStyle,
+ subtitle16SB = pretendardStyle,
+ subtitle14B = pretendardStyle,
+ subtitle14SB = pretendardStyle,
+ body16M = pretendardStyle,
+ body14SB = pretendardStyle,
+ body14M = pretendardStyle,
+ body12B = pretendardStyle,
+ body12M = pretendardStyle,
+ body10B = pretendardStyle,
+ )
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/util/BooleanProvider.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/util/BooleanProvider.kt
new file mode 100644
index 00000000..8e03d4d4
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/util/BooleanProvider.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.core.designsystem.util
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class BooleanProvider : PreviewParameterProvider {
+ override val values = sequenceOf(
+ true,
+ false,
+ )
+}
diff --git a/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/util/MultipleEventsCutter.kt b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/util/MultipleEventsCutter.kt
new file mode 100644
index 00000000..9ef16981
--- /dev/null
+++ b/core/designsystem/src/main/java/com/teambrake/brake/core/designsystem/util/MultipleEventsCutter.kt
@@ -0,0 +1,24 @@
+package com.teambrake.brake.core.designsystem.util
+
+interface MultipleEventsCutter {
+ fun processEvent(event: () -> Unit)
+
+ companion object
+}
+
+fun MultipleEventsCutter.Companion.get(): MultipleEventsCutter =
+ MultipleEventsCutterImpl()
+
+private class MultipleEventsCutterImpl : MultipleEventsCutter {
+ private val now: Long
+ get() = System.currentTimeMillis()
+
+ private var lastEventTimeMs: Long = 0
+
+ override fun processEvent(event: () -> Unit) {
+ if (now - lastEventTimeMs >= 300L) {
+ event.invoke()
+ }
+ lastEventTimeMs = now
+ }
+}
diff --git a/core/designsystem/src/main/res/drawable/ic_arraow.xml b/core/designsystem/src/main/res/drawable/ic_arraow.xml
new file mode 100644
index 00000000..4593afa7
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_arraow.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_back_24.xml b/core/designsystem/src/main/res/drawable/ic_back_24.xml
new file mode 100644
index 00000000..ea7bdb2b
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_back_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_back_28.xml b/core/designsystem/src/main/res/drawable/ic_back_28.xml
new file mode 100644
index 00000000..b4232bee
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_back_28.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_calendar.xml b/core/designsystem/src/main/res/drawable/ic_calendar.xml
new file mode 100644
index 00000000..592c3a88
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_calendar.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_check.xml b/core/designsystem/src/main/res/drawable/ic_check.xml
new file mode 100644
index 00000000..3fb2849e
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_check.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_close.xml b/core/designsystem/src/main/res/drawable/ic_close.xml
new file mode 100644
index 00000000..e61081b5
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_close.xml
@@ -0,0 +1,18 @@
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_setting.xml b/core/designsystem/src/main/res/drawable/ic_setting.xml
new file mode 100644
index 00000000..c9c97bfd
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_setting.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_snackbar_error.xml b/core/designsystem/src/main/res/drawable/ic_snackbar_error.xml
new file mode 100644
index 00000000..6ae9e996
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_snackbar_error.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_snackbar_success.xml b/core/designsystem/src/main/res/drawable/ic_snackbar_success.xml
new file mode 100644
index 00000000..e684b060
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_snackbar_success.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/img_profile_default.png b/core/designsystem/src/main/res/drawable/img_profile_default.png
new file mode 100644
index 00000000..f3830e63
Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_profile_default.png differ
diff --git a/core/designsystem/src/main/res/drawable/img_warning.png b/core/designsystem/src/main/res/drawable/img_warning.png
new file mode 100644
index 00000000..9ad8c54c
Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_warning.png differ
diff --git a/core/designsystem/src/main/res/font/pretendard_bold.otf b/core/designsystem/src/main/res/font/pretendard_bold.otf
new file mode 100644
index 00000000..8e5e30a2
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_bold.otf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_medium.otf b/core/designsystem/src/main/res/font/pretendard_medium.otf
new file mode 100644
index 00000000..05750698
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_medium.otf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_reqular.otf b/core/designsystem/src/main/res/font/pretendard_reqular.otf
new file mode 100644
index 00000000..08bf4cfc
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_reqular.otf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_semi_bold.otf b/core/designsystem/src/main/res/font/pretendard_semi_bold.otf
new file mode 100644
index 00000000..e7e36abc
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_semi_bold.otf differ
diff --git a/core/designsystem/src/main/res/values/string.xml b/core/designsystem/src/main/res/values/string.xml
new file mode 100644
index 00000000..93a131c9
--- /dev/null
+++ b/core/designsystem/src/main/res/values/string.xml
@@ -0,0 +1,4 @@
+
+
+ 테스트 문자열
+
diff --git a/core/detection/.gitignore b/core/detection/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/detection/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/detection/build.gradle.kts b/core/detection/build.gradle.kts
new file mode 100644
index 00000000..d07703eb
--- /dev/null
+++ b/core/detection/build.gradle.kts
@@ -0,0 +1,17 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.library)
+ alias(libs.plugins.brake.android.hilt)
+}
+
+android {
+ setNamespace("core.detection")
+}
+
+dependencies {
+ implementation(projects.domain)
+ implementation(projects.core.util)
+ implementation(projects.core.common)
+ implementation(projects.core.model)
+}
diff --git a/core/detection/src/main/AndroidManifest.xml b/core/detection/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..76073216
--- /dev/null
+++ b/core/detection/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/core/detection/src/main/java/com/teambrake/brake/core/detection/AppLaunchDetectionService.kt b/core/detection/src/main/java/com/teambrake/brake/core/detection/AppLaunchDetectionService.kt
new file mode 100644
index 00000000..c420cb80
--- /dev/null
+++ b/core/detection/src/main/java/com/teambrake/brake/core/detection/AppLaunchDetectionService.kt
@@ -0,0 +1,288 @@
+package com.teambrake.brake.core.detection
+
+import android.accessibilityservice.AccessibilityService
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.view.accessibility.AccessibilityEvent
+import com.teambrake.brake.core.model.accessibility.IntentConfig
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.core.util.OverlayLauncher
+import com.teambrake.brake.core.util.getAppNameFromPackage
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class AppLaunchDetectionService : AccessibilityService() {
+
+ @Inject
+ lateinit var cachedDatabase: CachedDatabase
+
+ private val serviceJob = SupervisorJob()
+ private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
+
+ // StateFlow의 최신 값을 저장할 변수
+ private var cachedGroups: List = emptyList()
+
+ /** 현재 유저의 사용 앱 캐싱, AccessibilityService 활용이 가장 정확도가 높음 **/
+ private var currentAppPkg: String? = null
+ private var previousAppPkg: String? = null
+
+ private var isScreenOn: Boolean = true
+
+ /**
+ * 차단 예약 알람 이벤트의 동적 BroadcastReceiver 정의
+ *
+ * NotificationReceiver에서 전달받은 알람의 인텐트를 감지하여 오버레이 띄우기 시도
+ */
+ private val alarmReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent) {
+ val pkg = intent.`package`
+ if (pkg == applicationContext.packageName) {
+ Timber.i(
+ "NotificationReceiver -> Accessibility 명령 수신됨: ${intent.action}, 그룹 ID: ${
+ intent.getLongExtra(
+ IntentConfig.EXTRA_GROUP_ID,
+ 0,
+ )
+ }",
+ )
+ showOverlay(intent)
+ }
+ }
+ }
+
+ /**
+ * 화면 켜짐/꺼짐 이벤트의 동적 BroadcastReceiver 정의
+ *
+ * 화면이 꺼진 상태에서 백그라운드 실행 (특히 유튜브 백그라운드) 으로 인해 AccessibilityEvent가 계속 발생하는 문제 방지
+ */
+ private val screenReaderReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent) {
+ when (intent.action) {
+ Intent.ACTION_SCREEN_ON -> {
+ if (!isScreenOn) {
+ isScreenOn = true
+ }
+ }
+
+ Intent.ACTION_SCREEN_OFF -> {
+ if (isScreenOn) {
+ isScreenOn = false
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * registerReceiver() 를 통해 동적 BroadcastReceiver 등록
+ *
+ * IntentFilter 를 통해 송신자가 수신자 (해당 BroadcastReceiver) 를 식별할 수 있도록 설정
+ */
+ @SuppressLint("UnspecifiedRegisterReceiverFlag")
+ override fun onCreate() {
+ super.onCreate()
+
+ val screenIntentFilter = IntentFilter().apply {
+ addAction(Intent.ACTION_SCREEN_ON)
+ addAction(Intent.ACTION_SCREEN_OFF)
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(
+ alarmReceiver,
+ IntentFilter(IntentConfig.RECEIVER_IDENTITY),
+ RECEIVER_NOT_EXPORTED,
+ )
+ registerReceiver(
+ screenReaderReceiver,
+ screenIntentFilter,
+ RECEIVER_NOT_EXPORTED,
+ )
+ } else {
+ registerReceiver(alarmReceiver, IntentFilter(IntentConfig.RECEIVER_IDENTITY))
+ registerReceiver(screenReaderReceiver, screenIntentFilter)
+ }
+
+ // 서비스 시작 시 캐싱 구독 초기화
+ serviceScope.launch {
+ cachedDatabase.cachedAppGroups.collect { groups ->
+ cachedGroups = groups
+ }
+ }
+ }
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent?) {
+ if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
+ if (!isScreenOn) return
+
+ val packageName = event.packageName?.toString()
+ val className = event.className?.toString()
+
+ if (packageName != null && className != null) {
+
+ if (isActivity(className)) {
+ if (packageName == applicationContext.packageName) {
+ return
+ } else {
+ Timber.i("앱 실행 감지: 패키지명: $packageName, 클래스명: $className")
+ previousAppPkg = currentAppPkg
+ currentAppPkg = packageName
+ }
+
+ if (isRecentsScreen(packageName, className)) {
+ OverlayLauncher.closeOverlay(applicationContext)
+ return
+ }
+
+ findAppGroupAndAction(packageName)
+ }
+ }
+ }
+ }
+
+ private fun isActivity(className: String): Boolean {
+ return className.contains(".") && !className.startsWith("android.widget") // 예: android.widget.Toast 제외
+ }
+
+ private fun isRecentsScreen(pkg: String, cls: String): Boolean {
+ // pixel 디바이스에서 Recent Screen 이동 시 서비스 앱 Activity 의 onPause 감지 불가
+ // 따라서 AccessibilityEvent 로 Recent Screen 진입 감지하여 오버레이 종료 처리
+ if (pkg == "com.android.systemui" &&
+ (
+ cls.contains("Recents", ignoreCase = true) ||
+ cls.contains("Overview", ignoreCase = true)
+ )
+ ) {
+ return true
+ }
+
+ // Google Pixel Launcher
+ if (pkg == "com.google.android.apps.nexuslauncher" &&
+ cls.contains("NexusLauncherActivity", ignoreCase = true)
+ ) {
+ return true
+ }
+
+ return false
+ }
+
+ private fun findAppGroupAndAction(packageName: String) {
+
+ serviceScope.launch {
+ val targetApp = cachedGroups.find { app ->
+ app.apps.any { it.packageName == packageName }
+ }
+
+ if (targetApp == null) {
+ Timber.d("$packageName 는 관리 대상 앱이 아닙니다.")
+ return@launch
+ }
+
+ val blockingState = targetApp.groupState
+
+ Timber.i("앱 그룹 발견: ${targetApp.groupName}, 상태: $blockingState, 앱: $packageName")
+
+ val appName = getAppNameFromPackage(packageName) ?: packageName
+
+ when (blockingState) {
+ AppGroupState.NeedSetting, AppGroupState.Blocking -> {
+ OverlayLauncher.startOverlay(
+ context = applicationContext,
+ groupId = targetApp.groupId,
+ groupName = targetApp.groupName,
+ appName = appName,
+ appGroupState = blockingState,
+ )
+ }
+
+ AppGroupState.Using -> {
+ Timber.i("$packageName 앱은 사용 상태입니다. 아무 작업도 하지 않습니다.")
+ }
+
+ else -> {
+ Timber.d("$packageName 앱은 상태가 $blockingState 입니다.")
+ }
+ }
+ }
+ }
+
+ /**
+ * 해당 함수까지의 작업 흐름
+ *
+ * 1. NotificationReceiver에서 예약된 세션 시간 알림을 감지하고, sendBroadcast 로 인텐트(세션 그룹 데이터)를 전달
+ * 2. AccessibilityService에서 인텐트를 받아 가공하고, 현재 사용 어플이 세션의 관리 대상 어플이면 Overlay 띄우기
+ **/
+ private fun showOverlay(intent: Intent) {
+ val groupId = intent.getLongExtra(IntentConfig.EXTRA_GROUP_ID, 0)
+ val appGroupState = getGroupStateFromIntent(intent)
+
+ serviceScope.launch {
+ val appGroup = cachedGroups.find { it.groupId == groupId }
+ val appsPkgs = appGroup?.apps?.map { app -> app.packageName }?.toSet() ?: emptySet()
+
+ // Edge case : 재부팅 후 즉각 관리 앱을 실행한 경우 해당 AccessibilityService가 시작되기 전에 앱이 실행될 수 있음
+ currentAppPkg ?: monitorCurrentAppLaunch(appsPkgs)
+
+ // 관리 앱이 실행 중인 경우 오버레이 띄우기
+ appGroup?.apps?.forEach {
+ if (it.packageName == currentAppPkg) {
+ val appName = appGroup.apps.find { app -> app.packageName == currentAppPkg }?.name
+ OverlayLauncher.startOverlay(
+ context = applicationContext,
+ groupId = appGroup.groupId,
+ groupName = appGroup.groupName,
+ appName = appName,
+ appGroupState = appGroupState,
+ snoozesCount = appGroup.snoozesCount,
+ )
+ return@launch
+ }
+ }
+ }
+ }
+
+ private fun getGroupStateFromIntent(intent: Intent): AppGroupState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getSerializableExtra(IntentConfig.EXTRA_GROUP_STATE, AppGroupState::class.java)
+ ?: AppGroupState.Blocking
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getSerializableExtra(IntentConfig.EXTRA_GROUP_STATE) as? AppGroupState
+ ?: AppGroupState.Blocking
+ }
+
+ private fun monitorCurrentAppLaunch(monitoredApps: Set) {
+ val root = this.rootInActiveWindow
+ if (root != null) {
+ val className = root.className?.toString()
+ Timber.i("현재 활성화된 윈도우 패키지명: ${root.packageName}, 클래스명: $className")
+ if (className != null && monitoredApps.contains(root.packageName)) {
+ currentAppPkg = root.packageName?.toString()
+ return
+ }
+ }
+ Timber.i("현재 뷰와 대응되는 앱 ${monitoredApps.joinToString(", ")} 을 발견하지 못했습니다.")
+ }
+
+ override fun onInterrupt() {
+ Timber.d("onInterrupt: 접근성 서비스 중단됨")
+ // 접근성 서비스 중단에 맞춘 이벤트 정의해야 함
+ }
+
+ override fun onDestroy() {
+ serviceJob.cancel()
+ unregisterReceiver(alarmReceiver)
+ unregisterReceiver(screenReaderReceiver)
+ super.onDestroy()
+ Timber.d("접근성 서비스가 소멸되었습니다.")
+ }
+}
diff --git a/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedDatabase.kt b/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedDatabase.kt
new file mode 100644
index 00000000..02538d77
--- /dev/null
+++ b/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedDatabase.kt
@@ -0,0 +1,16 @@
+package com.teambrake.brake.core.detection
+
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import kotlinx.coroutines.flow.StateFlow
+
+interface CachedDatabase {
+ val cachedAppGroups: StateFlow>
+ fun initializeCachedState(appGroups: List)
+ fun addAppGroupToCache(appGroup: AppGroup)
+ fun updateAppGroupInCache(appGroup: AppGroup)
+ fun updateCachedState(groupId: Long, appGroupState: AppGroupState)
+ fun updateSnoozeCount(groupId: Long, snoozesCount: Int)
+ fun removeAppGroupFromCache(groupId: Long)
+ fun clearCache()
+}
diff --git a/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedDatabaseImpl.kt b/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedDatabaseImpl.kt
new file mode 100644
index 00000000..a6d9e790
--- /dev/null
+++ b/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedDatabaseImpl.kt
@@ -0,0 +1,103 @@
+package com.teambrake.brake.core.detection
+
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class CachedDatabaseImpl @Inject constructor() : CachedDatabase {
+ private val _cachedAppGroups: MutableStateFlow> =
+ MutableStateFlow(emptyList())
+ override val cachedAppGroups: StateFlow> = _cachedAppGroups.asStateFlow()
+
+ override fun initializeCachedState(appGroups: List) {
+ _cachedAppGroups.value = appGroups.map {
+ CachedTargetAppGroup(
+ apps = it.apps.map { app ->
+ CachedTargetApp(
+ packageName = app.packageName,
+ name = app.name,
+ category = app.category,
+ )
+ },
+ groupId = it.id,
+ groupName = it.name,
+ groupState = it.appGroupState,
+ snoozesCount = it.snoozesCount,
+ )
+ }
+ }
+
+ override fun addAppGroupToCache(appGroup: AppGroup) {
+ _cachedAppGroups.value = _cachedAppGroups.value + CachedTargetAppGroup(
+ apps = appGroup.apps.map { app ->
+ CachedTargetApp(
+ packageName = app.packageName,
+ name = app.name,
+ category = app.category,
+ )
+ },
+ groupId = appGroup.id,
+ groupName = appGroup.name,
+ groupState = appGroup.appGroupState,
+ snoozesCount = appGroup.snoozesCount,
+ )
+ }
+
+ override fun updateAppGroupInCache(appGroup: AppGroup) {
+ _cachedAppGroups.value = _cachedAppGroups.value.map {
+ if (it.groupId == appGroup.id) {
+ CachedTargetAppGroup(
+ apps = appGroup.apps.map { app ->
+ CachedTargetApp(
+ packageName = app.packageName,
+ name = app.name,
+ category = app.category,
+ )
+ },
+ groupId = appGroup.id,
+ groupName = appGroup.name,
+ groupState = appGroup.appGroupState,
+ snoozesCount = appGroup.snoozesCount,
+ )
+ } else {
+ it
+ }
+ }
+ }
+
+ override fun updateCachedState(
+ groupId: Long,
+ appGroupState: AppGroupState,
+ ) {
+ _cachedAppGroups.value = _cachedAppGroups.value.map {
+ if (it.groupId == groupId) {
+ it.copy(groupState = appGroupState)
+ } else {
+ it
+ }
+ }
+ }
+
+ override fun updateSnoozeCount(groupId: Long, snoozesCount: Int) {
+ _cachedAppGroups.value = _cachedAppGroups.value.map {
+ if (it.groupId == groupId) {
+ it.copy(snoozesCount = snoozesCount)
+ } else {
+ it
+ }
+ }
+ }
+
+ override fun removeAppGroupFromCache(groupId: Long) {
+ _cachedAppGroups.value = _cachedAppGroups.value.filter { it.groupId != groupId }
+ }
+
+ override fun clearCache() {
+ _cachedAppGroups.value = emptyList()
+ }
+}
diff --git a/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedModule.kt b/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedModule.kt
new file mode 100644
index 00000000..6580776d
--- /dev/null
+++ b/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedModule.kt
@@ -0,0 +1,16 @@
+package com.teambrake.brake.core.detection
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface CachedModule {
+
+ @Binds
+ fun bindCachedDatabase(
+ cachedDatabaseImpl: CachedDatabaseImpl,
+ ): CachedDatabase
+}
diff --git a/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedTargetApp.kt b/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedTargetApp.kt
new file mode 100644
index 00000000..7862edaf
--- /dev/null
+++ b/core/detection/src/main/java/com/teambrake/brake/core/detection/CachedTargetApp.kt
@@ -0,0 +1,17 @@
+package com.teambrake.brake.core.detection
+
+import com.teambrake.brake.core.model.app.AppGroupState
+
+data class CachedTargetApp(
+ val packageName: String,
+ val name: String,
+ val category: String,
+)
+
+data class CachedTargetAppGroup(
+ val apps: List,
+ val groupId: Long,
+ val groupName: String,
+ val groupState: AppGroupState,
+ val snoozesCount: Int,
+)
diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts
index 401fe0c4..5d9297c0 100644
--- a/core/model/build.gradle.kts
+++ b/core/model/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- alias(libs.plugins.breake.kotlin.library)
+ alias(libs.plugins.brake.kotlin.library)
alias(libs.plugins.kotlin.serialization)
}
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/accessibility/IntentConfig.kt b/core/model/src/main/java/com/teambrake/brake/core/model/accessibility/IntentConfig.kt
new file mode 100644
index 00000000..8933039b
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/accessibility/IntentConfig.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.core.model.accessibility
+
+object IntentConfig {
+ // 동적 BroadcastReceiver 등록 시 키로 등록하며,
+ const val RECEIVER_IDENTITY = "com.teambrake.brake.core.model.accessibility.ACCESSIBILITY_SERVICE_INTENT_FILTER"
+ const val EXTRA_GROUP_ID = "extra_group_id"
+ const val EXTRA_GROUP_STATE = "extra_group_state"
+ const val EXTRA_SNOOZES_COUNT = "extra_snoozes_count"
+}
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/app/App.kt b/core/model/src/main/java/com/teambrake/brake/core/model/app/App.kt
new file mode 100644
index 00000000..305d884b
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/app/App.kt
@@ -0,0 +1,36 @@
+package com.teambrake.brake.core.model.app
+
+data class App(
+ val packageName: String,
+ val id: Long?,
+ val name: String,
+ val icon: ByteArray?,
+ val category: String,
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as App
+
+ if (packageName != other.packageName) return false
+ if (name != other.name) return false
+ if (icon != null) {
+ if (other.icon == null) return false
+ if (!icon.contentEquals(other.icon)) return false
+ } else if (other.icon != null) {
+ return false
+ }
+ if (category != other.category) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = packageName.hashCode()
+ result = 31 * result + name.hashCode()
+ result = 31 * result + (icon?.contentHashCode() ?: 0)
+ result = 31 * result + category.hashCode()
+ return result
+ }
+}
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/app/AppGroup.kt b/core/model/src/main/java/com/teambrake/brake/core/model/app/AppGroup.kt
new file mode 100644
index 00000000..6842dc1f
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/app/AppGroup.kt
@@ -0,0 +1,41 @@
+package com.teambrake.brake.core.model.app
+
+import java.time.LocalDateTime
+
+data class AppGroup(
+ val id: Long,
+ val name: String,
+ val appGroupState: AppGroupState,
+ val apps: List,
+ val snoozes: List,
+ val goalMinutes: Int?,
+ val sessionStartTime: LocalDateTime?,
+ val startTime: LocalDateTime?,
+ val endTime: LocalDateTime?,
+) {
+
+ val snoozesCount: Int
+ get() = snoozes.size
+
+ companion object {
+ val sample = AppGroup(
+ id = 1L,
+ name = "SNS",
+ appGroupState = AppGroupState.NeedSetting,
+ apps = listOf(
+ App(
+ packageName = "com.example.app1",
+ id = 1L,
+ category = "앱",
+ name = "앱1",
+ icon = null,
+ ),
+ ),
+ snoozes = emptyList(),
+ goalMinutes = 30,
+ sessionStartTime = null,
+ startTime = LocalDateTime.now().minusMinutes(2).minusSeconds(3),
+ endTime = LocalDateTime.now().plusMinutes(12).plusSeconds(7),
+ )
+ }
+}
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/app/AppGroupState.kt b/core/model/src/main/java/com/teambrake/brake/core/model/app/AppGroupState.kt
new file mode 100644
index 00000000..40f0f6bc
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/app/AppGroupState.kt
@@ -0,0 +1,8 @@
+package com.teambrake.brake.core.model.app
+
+enum class AppGroupState {
+ NeedSetting,
+ SnoozeBlocking,
+ Blocking,
+ Using,
+}
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/app/Snooze.kt b/core/model/src/main/java/com/teambrake/brake/core/model/app/Snooze.kt
new file mode 100644
index 00000000..44fa30db
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/app/Snooze.kt
@@ -0,0 +1,7 @@
+package com.teambrake.brake.core.model.app
+
+import java.time.LocalDateTime
+
+data class Snooze(
+ val startTime: LocalDateTime,
+)
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/app/Statistics.kt b/core/model/src/main/java/com/teambrake/brake/core/model/app/Statistics.kt
new file mode 100644
index 00000000..1888b3ff
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/app/Statistics.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.core.model.app
+
+import java.time.Duration
+import java.time.LocalDate
+
+data class Statistics(
+ val date: LocalDate,
+ val dayOfWeek: String,
+ val actualTime: Duration,
+ val goalTime: Duration,
+) {
+
+ val usageHours: Long
+ get() = actualTime.toHours()
+
+ val usageMinutes: Long
+ get() = actualTime.toMinutes()
+}
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/user/Destination.kt b/core/model/src/main/java/com/teambrake/brake/core/model/user/Destination.kt
new file mode 100644
index 00000000..a269ac7e
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/user/Destination.kt
@@ -0,0 +1,8 @@
+package com.teambrake.brake.core.model.user
+
+sealed interface Destination {
+ data object Login : Destination
+ data object Onboarding : Destination
+ data object PermissionOrHome : Destination
+ data object NotChanged : Destination
+}
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/user/UserName.kt b/core/model/src/main/java/com/teambrake/brake/core/model/user/UserName.kt
new file mode 100644
index 00000000..324be13f
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/user/UserName.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.core.model.user
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class UserName(
+ val nickname: String,
+ val state: UserStatus,
+)
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/user/UserStatus.kt b/core/model/src/main/java/com/teambrake/brake/core/model/user/UserStatus.kt
new file mode 100644
index 00000000..b80c1687
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/user/UserStatus.kt
@@ -0,0 +1,7 @@
+package com.teambrake.brake.core.model.user
+
+enum class UserStatus {
+ ACTIVE,
+ HALF_SIGNUP,
+ INACTIVE,
+}
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/user/UserToken.kt b/core/model/src/main/java/com/teambrake/brake/core/model/user/UserToken.kt
new file mode 100644
index 00000000..d0998ae3
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/user/UserToken.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.core.model.user
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class UserToken(
+ val accessToken: String,
+ val refreshToken: String,
+ val status: UserStatus,
+)
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/user/exception/LocalException.kt b/core/model/src/main/java/com/teambrake/brake/core/model/user/exception/LocalException.kt
new file mode 100644
index 00000000..1efc792f
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/user/exception/LocalException.kt
@@ -0,0 +1,7 @@
+package com.teambrake.brake.core.model.user.exception
+
+sealed class LocalException(override val message: String) : Exception(message) {
+ data class DataStoreEmptyException(
+ override val message: String = "DataStore is empty",
+ ) : LocalException(message)
+}
diff --git a/core/model/src/main/java/com/teambrake/brake/core/model/worker/WorkerConfig.kt b/core/model/src/main/java/com/teambrake/brake/core/model/worker/WorkerConfig.kt
new file mode 100644
index 00000000..84424883
--- /dev/null
+++ b/core/model/src/main/java/com/teambrake/brake/core/model/worker/WorkerConfig.kt
@@ -0,0 +1,5 @@
+package com.teambrake.brake.core.model.worker
+
+object WorkerConfig {
+ const val RESCHEDULE_ALARM = "reschedule_alarm"
+}
diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts
index 0badf2b5..b2ff1414 100644
--- a/core/navigation/build.gradle.kts
+++ b/core/navigation/build.gradle.kts
@@ -1,8 +1,8 @@
-import com.yapp.breake.setNamespace
+import com.teambrake.brake.setNamespace
plugins {
- alias(libs.plugins.breake.android.library)
- alias(libs.plugins.breake.android.compose)
+ alias(libs.plugins.brake.android.library)
+ alias(libs.plugins.brake.android.compose)
alias(libs.plugins.kotlin.serialization)
}
@@ -12,4 +12,5 @@ android {
dependencies {
implementation(libs.kotlinx.serialization.json)
+ implementation(libs.androidx.compose.navigation)
}
diff --git a/core/navigation/src/main/java/com/teambrake/brake/core/navigation/action/MainAction.kt b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/action/MainAction.kt
new file mode 100644
index 00000000..e057895f
--- /dev/null
+++ b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/action/MainAction.kt
@@ -0,0 +1,13 @@
+package com.teambrake.brake.core.navigation.action
+
+import androidx.compose.runtime.Composable
+
+interface MainAction {
+ @Composable fun OnFinishBackHandler()
+
+ @Composable fun OnShowLogoutDialog(onConfirm: () -> Unit, onDismiss: () -> Unit)
+
+ @Composable fun OnShowLoading()
+ fun onShowErrorMessage(message: String)
+ fun onShowSuccessMessage(message: String)
+}
diff --git a/core/navigation/src/main/java/com/teambrake/brake/core/navigation/action/NavigatorAction.kt b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/action/NavigatorAction.kt
new file mode 100644
index 00000000..ea06afa8
--- /dev/null
+++ b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/action/NavigatorAction.kt
@@ -0,0 +1,19 @@
+package com.teambrake.brake.core.navigation.action
+
+import androidx.navigation.NavOptions
+
+interface NavigatorAction {
+ fun popBackStack(navOptions: NavOptions? = null)
+ fun navigateToLogin(navOptions: NavOptions? = null)
+ fun navigateToSignup(navOptions: NavOptions? = null)
+ fun navigateToGuide(navOptions: NavOptions? = null)
+ fun navigateToPrivacy(navOptions: NavOptions? = null)
+ fun navigateToTerms(navOptions: NavOptions? = null)
+ fun navigateToComplete(navOptions: NavOptions? = null)
+ fun navigateToPermission(navOptions: NavOptions? = null)
+ fun navigateToHome(navOptions: NavOptions? = null)
+ fun navigateToRegistry(groupId: Long?, navOptions: NavOptions? = null)
+ fun navigateToNickname(navOptions: NavOptions? = null)
+ fun navigateToOpinion(navOptions: NavOptions? = null)
+ fun navigateToInquiry(navOptions: NavOptions? = null)
+}
diff --git a/core/navigation/src/main/java/com/teambrake/brake/core/navigation/compositionlocal/MainActionCompositionLocal.kt b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/compositionlocal/MainActionCompositionLocal.kt
new file mode 100644
index 00000000..1145a80f
--- /dev/null
+++ b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/compositionlocal/MainActionCompositionLocal.kt
@@ -0,0 +1,8 @@
+package com.teambrake.brake.core.navigation.compositionlocal
+
+import androidx.compose.runtime.staticCompositionLocalOf
+import com.teambrake.brake.core.navigation.action.MainAction
+
+val LocalMainAction = staticCompositionLocalOf {
+ error("No MainAction provided")
+}
diff --git a/core/navigation/src/main/java/com/teambrake/brake/core/navigation/compositionlocal/NavActionCompositionLocal.kt b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/compositionlocal/NavActionCompositionLocal.kt
new file mode 100644
index 00000000..881928ca
--- /dev/null
+++ b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/compositionlocal/NavActionCompositionLocal.kt
@@ -0,0 +1,8 @@
+package com.teambrake.brake.core.navigation.compositionlocal
+
+import androidx.compose.runtime.staticCompositionLocalOf
+import com.teambrake.brake.core.navigation.action.NavigatorAction
+
+val LocalNavigatorAction = staticCompositionLocalOf {
+ error("No NavAction provided")
+}
diff --git a/core/navigation/src/main/java/com/teambrake/brake/core/navigation/compositionlocal/NavProviderCompositionLocal.kt b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/compositionlocal/NavProviderCompositionLocal.kt
new file mode 100644
index 00000000..c836b702
--- /dev/null
+++ b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/compositionlocal/NavProviderCompositionLocal.kt
@@ -0,0 +1,8 @@
+package com.teambrake.brake.core.navigation.compositionlocal
+
+import androidx.compose.runtime.staticCompositionLocalOf
+import com.teambrake.brake.core.navigation.provider.NavigatorProvider
+
+val LocalNavigatorProvider = staticCompositionLocalOf {
+ error("No NavProvider provided")
+}
diff --git a/core/navigation/src/main/java/com/teambrake/brake/core/navigation/provider/NavigatorProvider.kt b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/provider/NavigatorProvider.kt
new file mode 100644
index 00000000..4f868e56
--- /dev/null
+++ b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/provider/NavigatorProvider.kt
@@ -0,0 +1,8 @@
+package com.teambrake.brake.core.navigation.provider
+
+import androidx.navigation.NavOptions
+
+interface NavigatorProvider {
+ fun getNavOptionsClearingBackStack(): NavOptions
+ fun getPreviousDestination(): String
+}
diff --git a/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/InitialRoute.kt b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/InitialRoute.kt
new file mode 100644
index 00000000..44ba7139
--- /dev/null
+++ b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/InitialRoute.kt
@@ -0,0 +1,23 @@
+package com.teambrake.brake.core.navigation.route
+
+import kotlinx.serialization.Serializable
+
+sealed interface InitialRoute : Route {
+ @Serializable
+ data object Login : InitialRoute
+
+ @Serializable
+ data object SignUp : InitialRoute
+
+ @Serializable
+ sealed interface Onboarding : InitialRoute {
+ @Serializable
+ data object Guide : Onboarding
+
+ @Serializable
+ data object Complete : Onboarding
+ }
+
+ @Serializable
+ data object Permission : InitialRoute
+}
diff --git a/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/MainTabRoute.kt b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/MainTabRoute.kt
new file mode 100644
index 00000000..cded69a2
--- /dev/null
+++ b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/MainTabRoute.kt
@@ -0,0 +1,15 @@
+package com.teambrake.brake.core.navigation.route
+
+import kotlinx.serialization.Serializable
+
+sealed interface MainTabRoute : Route {
+
+ @Serializable
+ data object Report : MainTabRoute
+
+ @Serializable
+ data object Home : MainTabRoute
+
+ @Serializable
+ data object Setting : MainTabRoute
+}
diff --git a/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/Route.kt b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/Route.kt
new file mode 100644
index 00000000..d1734ae7
--- /dev/null
+++ b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/Route.kt
@@ -0,0 +1,15 @@
+package com.teambrake.brake.core.navigation.route
+
+import timber.log.Timber
+
+interface Route
+
+/**
+ * 2025-08-03 기준
+ * Jetpack Compose Type-Safe Navigation 내부에서 Route 타입을 문자열로 변환하는 방식과 동일한 변환 함수
+ * Jetpack Compose Type-Safe Navigation 내부 변환 방식이 변경될 수 있음
+ */
+fun Route.stringRoute(): String {
+ Timber.d("stringRoute: ${this::class.qualifiedName}")
+ return this::class.qualifiedName ?: ""
+}
diff --git a/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/SubRoute.kt b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/SubRoute.kt
new file mode 100644
index 00000000..7909e2fe
--- /dev/null
+++ b/core/navigation/src/main/java/com/teambrake/brake/core/navigation/route/SubRoute.kt
@@ -0,0 +1,25 @@
+package com.teambrake.brake.core.navigation.route
+
+import kotlinx.serialization.Serializable
+
+interface SubRoute : Route {
+ @Serializable
+ data class Registry(val groupId: Long? = null) : SubRoute
+
+ @Serializable
+ data object Nickname : SubRoute
+
+ @Serializable
+ data object Privacy : SubRoute
+
+ @Serializable
+ data object Terms : SubRoute
+
+ sealed interface Feedback : SubRoute {
+ @Serializable
+ data object Inquiry : Feedback
+
+ @Serializable
+ data object Opinion : Feedback
+ }
+}
diff --git a/core/permission/.gitignore b/core/permission/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/permission/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/permission/build.gradle.kts b/core/permission/build.gradle.kts
new file mode 100644
index 00000000..0685740f
--- /dev/null
+++ b/core/permission/build.gradle.kts
@@ -0,0 +1,10 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.library)
+ alias(libs.plugins.brake.android.hilt)
+}
+
+android {
+ setNamespace("core.permission")
+}
diff --git a/core/permission/src/main/java/com/teambrake/brake/core/permission/PermissionManager.kt b/core/permission/src/main/java/com/teambrake/brake/core/permission/PermissionManager.kt
new file mode 100644
index 00000000..b2f15a9e
--- /dev/null
+++ b/core/permission/src/main/java/com/teambrake/brake/core/permission/PermissionManager.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.core.permission
+
+import android.content.Context
+import android.content.Intent
+
+interface PermissionManager {
+ fun isAllGranted(context: Context): Boolean
+ fun isGranted(context: Context, permissionType: PermissionType): Boolean
+ fun getIntent(context: Context, permissionType: PermissionType): Intent
+}
diff --git a/core/permission/src/main/java/com/teambrake/brake/core/permission/PermissionManagerImpl.kt b/core/permission/src/main/java/com/teambrake/brake/core/permission/PermissionManagerImpl.kt
new file mode 100644
index 00000000..067a1ff0
--- /dev/null
+++ b/core/permission/src/main/java/com/teambrake/brake/core/permission/PermissionManagerImpl.kt
@@ -0,0 +1,112 @@
+package com.teambrake.brake.core.permission
+
+import android.accessibilityservice.AccessibilityServiceInfo
+import android.app.AlarmManager
+import android.app.AppOpsManager
+import android.app.AppOpsManager.MODE_ALLOWED
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Process
+import android.provider.Settings
+import android.view.accessibility.AccessibilityManager
+import jakarta.inject.Inject
+
+class PermissionManagerImpl @Inject constructor() : PermissionManager {
+ override fun isAllGranted(context: Context): Boolean {
+ if (!isOverlayPermissionGranted(context)) return false
+ if (!isStatsPermissionGranted(context)) return false
+ if (!isExactAlarmPermissionGranted(context)) return false
+ if (!isAccessibilityPermissionGranted(context)) return false
+ return true
+ }
+
+ override fun isGranted(context: Context, permissionType: PermissionType): Boolean = when (permissionType) {
+ PermissionType.OVERLAY -> isOverlayPermissionGranted(context)
+ PermissionType.STATS -> isStatsPermissionGranted(context)
+ PermissionType.EXACT_ALARM -> isExactAlarmPermissionGranted(context)
+ PermissionType.ACCESSIBILITY -> isAccessibilityPermissionGranted(context)
+ }
+
+ private fun isOverlayPermissionGranted(context: Context): Boolean =
+ Settings.canDrawOverlays(context)
+
+ private fun isStatsPermissionGranted(context: Context): Boolean {
+ // API 29 이상 부터 PACKAGE_USAGE_STATS 권한 설정 요구
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
+
+ val appOpsManager =
+ context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
+ // API 29 이상에서 unsafeCheckOpNoThrow 사용
+ val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ appOpsManager.unsafeCheckOpNoThrow(
+ "android:get_usage_stats",
+ Process.myUid(),
+ context.packageName,
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ appOpsManager.checkOpNoThrow(
+ "android:get_usage_stats",
+ Process.myUid(),
+ context.packageName,
+ )
+ }
+ return mode == MODE_ALLOWED
+ }
+
+ private fun isExactAlarmPermissionGranted(context: Context): Boolean {
+ // API 31 이상 부터 SCHEDULE_EXACT_ALARM 권한 설정 요구
+ // API 33 미만은 Manifest에 선언된 경우 자동으로 허용됨
+ // API 33 이상에서는 Manifest에 선언되어 있어도 사용자가 명시적으로 허용해야 함
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ alarmManager.canScheduleExactAlarms()
+ } else {
+ true
+ }
+ }
+
+ private fun isAccessibilityPermissionGranted(context: Context): Boolean {
+ val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
+ return am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
+ .any { it.resolveInfo.serviceInfo.packageName == context.packageName }
+ }
+
+ override fun getIntent(context: Context, permissionType: PermissionType): Intent =
+ when (permissionType) {
+ PermissionType.OVERLAY -> getOverlayIntent(context)
+ PermissionType.STATS -> getStatsIntent(context)
+ PermissionType.EXACT_ALARM -> getExactAlarmIntent(context)
+ PermissionType.ACCESSIBILITY -> getAccessibilityIntent()
+ }
+
+ private fun getOverlayIntent(context: Context): Intent =
+ Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply {
+ data = Uri.fromParts("package", context.packageName, null)
+ }
+
+ private fun getStatsIntent(context: Context): Intent =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS).apply {
+ data = Uri.fromParts("package", context.packageName, null)
+ }
+ } else {
+ // 이전 버전에서는 PACKAGE_USAGE_STATS 권한이 필요하지 않아 비어있는 Intent 반환
+ Intent()
+ }
+
+ private fun getExactAlarmIntent(context: Context): Intent =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
+ data = Uri.fromParts("package", context.packageName, null)
+ }
+ } else {
+ // 이전 버전에서는 SCHEDULE_EXACT_ALARM 권한이 필요하지 않아 비어있는 Intent 반환
+ Intent()
+ }
+
+ private fun getAccessibilityIntent(): Intent =
+ Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
+}
diff --git a/core/permission/src/main/java/com/teambrake/brake/core/permission/PermissionType.kt b/core/permission/src/main/java/com/teambrake/brake/core/permission/PermissionType.kt
new file mode 100644
index 00000000..dac73328
--- /dev/null
+++ b/core/permission/src/main/java/com/teambrake/brake/core/permission/PermissionType.kt
@@ -0,0 +1,8 @@
+package com.teambrake.brake.core.permission
+
+enum class PermissionType {
+ OVERLAY,
+ EXACT_ALARM,
+ STATS,
+ ACCESSIBILITY,
+}
diff --git a/core/permission/src/main/java/com/teambrake/brake/core/permission/di/PermissionModule.kt b/core/permission/src/main/java/com/teambrake/brake/core/permission/di/PermissionModule.kt
new file mode 100644
index 00000000..d984d589
--- /dev/null
+++ b/core/permission/src/main/java/com/teambrake/brake/core/permission/di/PermissionModule.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.core.permission.di
+
+import com.teambrake.brake.core.permission.PermissionManager
+import com.teambrake.brake.core.permission.PermissionManagerImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class PermissionModule {
+
+ @Binds
+ abstract fun bindPermissionManager(
+ permissionManager: PermissionManagerImpl,
+ ): PermissionManager
+}
diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts
index 2e3861e0..7933bb54 100644
--- a/core/testing/build.gradle.kts
+++ b/core/testing/build.gradle.kts
@@ -1,7 +1,7 @@
-import com.yapp.breake.setNamespace
+import com.teambrake.brake.setNamespace
plugins {
- alias(libs.plugins.breake.android.library)
+ alias(libs.plugins.brake.android.library)
}
android {
diff --git a/core/datastore/src/main/java/com/yapp/breake/core/datastore/.gitkeep b/core/testing/src/main/java/com/teambrake/brake/core/testing/.gitkeep
similarity index 100%
rename from core/datastore/src/main/java/com/yapp/breake/core/datastore/.gitkeep
rename to core/testing/src/main/java/com/teambrake/brake/core/testing/.gitkeep
diff --git a/core/ui/.gitignore b/core/ui/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/ui/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
new file mode 100644
index 00000000..3a26dd34
--- /dev/null
+++ b/core/ui/build.gradle.kts
@@ -0,0 +1,14 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.library)
+ alias(libs.plugins.brake.android.compose)
+}
+
+android {
+ setNamespace("core.ui")
+}
+
+dependencies {
+ implementation(projects.core.designsystem)
+}
diff --git a/core/ui/src/main/java/com/teambrake/brake/core/ui/ErrorBody.kt b/core/ui/src/main/java/com/teambrake/brake/core/ui/ErrorBody.kt
new file mode 100644
index 00000000..42bd86c3
--- /dev/null
+++ b/core/ui/src/main/java/com/teambrake/brake/core/ui/ErrorBody.kt
@@ -0,0 +1,114 @@
+package com.teambrake.brake.core.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.core.designsystem.util.MultipleEventsCutter
+import com.teambrake.brake.core.designsystem.util.get
+
+@Composable
+fun ErrorBody(
+ onRetry: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(horizontal = 36.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Image(
+ painter = painterResource(R.drawable.img_error),
+ contentDescription = null,
+ modifier = Modifier.size(140.dp),
+ )
+ VerticalSpacer(28.dp)
+ Text(
+ text = stringResource(R.string.error_title),
+ style = BrakeTheme.typography.subtitle22SB,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ VerticalSpacer(10.dp)
+ Text(
+ text = stringResource(R.string.error_message),
+ style = BrakeTheme.typography.body16M,
+ color = Gray200,
+ textAlign = TextAlign.Center,
+ )
+ VerticalSpacer(24.dp)
+ RetryButton(
+ onRetryClick = onRetry,
+ )
+ }
+}
+
+@Composable
+internal fun RetryButton(
+ onRetryClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter.get() }
+
+ Button(
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = BrakeTheme.colorScheme.onSurface,
+ contentColor = White,
+ ),
+ contentPadding = PaddingValues(vertical = 15.dp, horizontal = 22.dp),
+ onClick = { multipleEventsCutter.processEvent(onRetryClick) },
+ modifier = modifier,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_refresh),
+ contentDescription = stringResource(R.string.retry_content_description),
+ )
+ HorizontalSpacer(6.dp)
+ Text(
+ text = stringResource(R.string.retry),
+ style = BrakeTheme.typography.body14SB,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun ErrorBodyPreview() {
+ BrakeTheme {
+ ErrorBody(
+ onRetry = {},
+ )
+ }
+}
diff --git a/core/ui/src/main/java/com/teambrake/brake/core/ui/SnackBarState.kt b/core/ui/src/main/java/com/teambrake/brake/core/ui/SnackBarState.kt
new file mode 100644
index 00000000..b4176ccf
--- /dev/null
+++ b/core/ui/src/main/java/com/teambrake/brake/core/ui/SnackBarState.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.core.ui
+
+import androidx.compose.runtime.Immutable
+
+sealed interface SnackBarState {
+ @Immutable
+ data class Success(val uiString: UiString) : SnackBarState
+
+ @Immutable
+ data class Error(val uiString: UiString) : SnackBarState
+}
diff --git a/core/ui/src/main/java/com/teambrake/brake/core/ui/UiString.kt b/core/ui/src/main/java/com/teambrake/brake/core/ui/UiString.kt
new file mode 100644
index 00000000..c524b426
--- /dev/null
+++ b/core/ui/src/main/java/com/teambrake/brake/core/ui/UiString.kt
@@ -0,0 +1,36 @@
+package com.teambrake.brake.core.ui
+
+import android.content.Context
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+
+/**
+ * For use of StringRes in ViewModel
+ */
+sealed class UiString {
+ data class DynamicString(val value: String) : UiString()
+
+ // data class 의 주 생성자 vararg 사용 불가
+ class ResourceString(
+ @StringRes val resId: Int,
+ vararg val args: Any,
+ ) : UiString()
+
+ /**
+ * Used in Composable fun
+ */
+ @Composable
+ fun asString(): String = when (this) {
+ is DynamicString -> value
+ is ResourceString -> stringResource(resId, *args)
+ }
+
+ /**
+ * Used in non-Composable fun
+ */
+ fun asString(context: Context): String = when (this) {
+ is DynamicString -> value
+ is ResourceString -> context.getString(resId, *args)
+ }
+}
diff --git a/core/ui/src/main/java/com/teambrake/brake/core/ui/ValidateInput.kt b/core/ui/src/main/java/com/teambrake/brake/core/ui/ValidateInput.kt
new file mode 100644
index 00000000..111618bc
--- /dev/null
+++ b/core/ui/src/main/java/com/teambrake/brake/core/ui/ValidateInput.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.core.ui
+
+/**
+ * Validates if the input string on Text Field is a valid value.
+ *
+ * The Following conditions must be met:
+ *
+ * 1. A valid value must be between 2 and 10 characters long
+ *
+ * 2. A valid value must not contain spaces
+ *
+ * 3. A valid value must consist only of letters and digits
+ *
+ * @return true if the string is a valid value, false otherwise.
+ */
+fun String.isValidInput(): Boolean = this.length in 2..10 &&
+ !this.contains(" ") &&
+ this.all { it.isLetterOrDigit() }
diff --git a/core/ui/src/main/res/drawable/ic_refresh.xml b/core/ui/src/main/res/drawable/ic_refresh.xml
new file mode 100644
index 00000000..cbd431a8
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_refresh.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/ui/src/main/res/drawable/img_error.png b/core/ui/src/main/res/drawable/img_error.png
new file mode 100644
index 00000000..4bf56119
Binary files /dev/null and b/core/ui/src/main/res/drawable/img_error.png differ
diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml
new file mode 100644
index 00000000..11c1951e
--- /dev/null
+++ b/core/ui/src/main/res/values/strings.xml
@@ -0,0 +1,7 @@
+
+
+ 정보를 불러올 수 없어요
+ 인터넷 연결 확인 후 다시 시도해 주세요.
+ 다시 시도
+ Retry
+
diff --git a/core/util/.gitignore b/core/util/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/util/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/util/build.gradle.kts b/core/util/build.gradle.kts
new file mode 100644
index 00000000..f81b8799
--- /dev/null
+++ b/core/util/build.gradle.kts
@@ -0,0 +1,15 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.library)
+ alias(libs.plugins.brake.android.compose)
+ id("kotlin-parcelize")
+}
+
+android {
+ setNamespace("core.utils")
+}
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.model)
+}
diff --git a/core/util/src/main/java/com/teambrake/brake/core/util/AddJosaEulReul.kt b/core/util/src/main/java/com/teambrake/brake/core/util/AddJosaEulReul.kt
new file mode 100644
index 00000000..f2ecdaae
--- /dev/null
+++ b/core/util/src/main/java/com/teambrake/brake/core/util/AddJosaEulReul.kt
@@ -0,0 +1,31 @@
+package com.teambrake.brake.core.util
+
+fun String.addJosaEulReul(): String {
+ if (this.isEmpty()) return "${this}를"
+
+ val lastChar = this.last()
+
+ return when {
+ lastChar.code in 0xAC00..0xD7A3 -> {
+ if ((lastChar.code - 0xAC00) % 28 != 0) "${this}을" else "${this}를"
+ }
+
+ lastChar.isLetter() -> {
+ when (lastChar.lowercaseChar()) {
+ 'a', 'e', 'i', 'o', 'u', 'l', 'm', 'n' -> "${this}을"
+ else -> "${this}를"
+ }
+ }
+
+ lastChar.isDigit() -> {
+ when (lastChar) {
+ '0' -> "${this}을"
+ '1', '3', '6', '7', '8' -> "${this}을"
+ '2', '4', '5', '9' -> "${this}를"
+ else -> "${this}를"
+ }
+ }
+
+ else -> "${this}를"
+ }
+}
diff --git a/core/util/src/main/java/com/teambrake/brake/core/util/AppNameFromPackage.kt b/core/util/src/main/java/com/teambrake/brake/core/util/AppNameFromPackage.kt
new file mode 100644
index 00000000..e263ff00
--- /dev/null
+++ b/core/util/src/main/java/com/teambrake/brake/core/util/AppNameFromPackage.kt
@@ -0,0 +1,19 @@
+package com.teambrake.brake.core.util
+
+import android.content.Context
+import android.content.pm.PackageManager
+import timber.log.Timber
+
+fun Context.getAppNameFromPackage(packageName: String): String? = try {
+ val packageManager = packageManager
+ val applicationInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
+ val appName = packageManager.getApplicationLabel(applicationInfo).toString()
+ Timber.d("앱 이름을 찾았습니다: $packageName -> $appName")
+ appName
+} catch (e: PackageManager.NameNotFoundException) {
+ Timber.w("패키지를 찾을 수 없습니다: $packageName, 기본 이름을 사용합니다")
+ packageName.substringAfterLast('.').replaceFirstChar { it.uppercase() }
+} catch (e: Exception) {
+ Timber.e(e, "앱 이름을 가져오는 중 오류 발생: $packageName")
+ packageName.substringAfterLast('.').replaceFirstChar { it.uppercase() }
+}
diff --git a/core/util/src/main/java/com/teambrake/brake/core/util/DateUtil.kt b/core/util/src/main/java/com/teambrake/brake/core/util/DateUtil.kt
new file mode 100644
index 00000000..6d3427f0
--- /dev/null
+++ b/core/util/src/main/java/com/teambrake/brake/core/util/DateUtil.kt
@@ -0,0 +1,22 @@
+package com.teambrake.brake.core.util
+
+import java.time.DayOfWeek
+import java.time.format.TextStyle
+import java.util.Locale
+
+object DateUtil {
+
+ fun getShortDayOfWeekNames(locale: Locale = Locale.getDefault()): List = listOf(
+ DayOfWeek.MONDAY,
+ DayOfWeek.TUESDAY,
+ DayOfWeek.WEDNESDAY,
+ DayOfWeek.THURSDAY,
+ DayOfWeek.FRIDAY,
+ DayOfWeek.SATURDAY,
+ DayOfWeek.SUNDAY,
+ ).map { dayOfWeek ->
+ dayOfWeek.getDisplayName(TextStyle.SHORT, locale).replace(".", "")
+ }
+
+ fun getDayOfWeekName(dayOfWeek: DayOfWeek, locale: Locale = Locale.getDefault()): String = dayOfWeek.getDisplayName(TextStyle.SHORT, locale).replace(".", "")
+}
diff --git a/core/util/src/main/java/com/teambrake/brake/core/util/DrawableUtil.kt b/core/util/src/main/java/com/teambrake/brake/core/util/DrawableUtil.kt
new file mode 100644
index 00000000..99bcfdec
--- /dev/null
+++ b/core/util/src/main/java/com/teambrake/brake/core/util/DrawableUtil.kt
@@ -0,0 +1,46 @@
+package com.teambrake.brake.core.util
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.core.graphics.createBitmap
+import java.io.ByteArrayOutputStream
+
+fun Drawable.toByteArray(): ByteArray? = try {
+ val bitmap = if (this is BitmapDrawable) {
+ bitmap
+ } else {
+ val bitmap = createBitmap(
+ intrinsicWidth.takeIf { it > 0 } ?: 1,
+ intrinsicHeight.takeIf { it > 0 } ?: 1,
+ )
+ val canvas = Canvas(bitmap)
+ setBounds(0, 0, canvas.width, canvas.height)
+ draw(canvas)
+ bitmap
+ }
+
+ val stream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
+ stream.toByteArray()
+} catch (e: Exception) {
+ null
+}
+
+fun ByteArray.toImageBitmap(): ImageBitmap? = try {
+ val bitmap = BitmapFactory.decodeByteArray(this, 0, size)
+ bitmap?.asImageBitmap()
+} catch (e: Exception) {
+ null
+}
+
+fun ByteArray.toDrawable(): Drawable? = try {
+ val bitmap = BitmapFactory.decodeByteArray(this, 0, size)
+ bitmap?.let { BitmapDrawable(null, it) }
+} catch (e: Exception) {
+ null
+}
diff --git a/core/util/src/main/java/com/teambrake/brake/core/util/IconUtil.kt b/core/util/src/main/java/com/teambrake/brake/core/util/IconUtil.kt
new file mode 100644
index 00000000..8963617b
--- /dev/null
+++ b/core/util/src/main/java/com/teambrake/brake/core/util/IconUtil.kt
@@ -0,0 +1,19 @@
+package com.teambrake.brake.core.util
+
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import androidx.core.graphics.drawable.toBitmap
+import java.io.ByteArrayOutputStream
+
+fun Drawable?.toByteArray(): ByteArray? {
+ if (this == null) return null
+
+ return try {
+ val bitmap = this.toBitmap()
+ val outputStream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
+ outputStream.toByteArray()
+ } catch (e: Exception) {
+ null
+ }
+}
diff --git a/core/util/src/main/java/com/teambrake/brake/core/util/OverlayData.kt b/core/util/src/main/java/com/teambrake/brake/core/util/OverlayData.kt
new file mode 100644
index 00000000..d03185dc
--- /dev/null
+++ b/core/util/src/main/java/com/teambrake/brake/core/util/OverlayData.kt
@@ -0,0 +1,14 @@
+package com.teambrake.brake.core.util
+
+import android.os.Parcelable
+import com.teambrake.brake.core.model.app.AppGroupState
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class OverlayData(
+ val appGroupState: AppGroupState,
+ val groupId: Long,
+ val appName: String,
+ val groupName: String,
+ val snoozesCount: Int = 0,
+) : Parcelable
diff --git a/core/util/src/main/java/com/teambrake/brake/core/util/OverlayLauncher.kt b/core/util/src/main/java/com/teambrake/brake/core/util/OverlayLauncher.kt
new file mode 100644
index 00000000..3c6f9954
--- /dev/null
+++ b/core/util/src/main/java/com/teambrake/brake/core/util/OverlayLauncher.kt
@@ -0,0 +1,69 @@
+package com.teambrake.brake.core.util
+
+import android.app.ActivityManager
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import com.teambrake.brake.core.common.BlockingConstants
+import com.teambrake.brake.core.model.app.AppGroupState
+import timber.log.Timber
+
+object OverlayLauncher {
+
+ fun startOverlay(
+ context: Context,
+ groupId: Long,
+ groupName: String,
+ appName: String?,
+ appGroupState: AppGroupState,
+ snoozesCount: Int = 0,
+ ) {
+ Timber.d("startOverlayActivity 호출")
+
+ val overlayData = OverlayData(
+ appGroupState = appGroupState,
+ groupId = groupId,
+ snoozesCount = snoozesCount,
+ appName = appName ?: "Unknown App",
+ groupName = groupName,
+ )
+
+ // 이미 OverlayActivity가 실행 중인지 확인
+ val activityManager = context.getSystemService(
+ Context.ACTIVITY_SERVICE,
+ ) as ActivityManager
+ val runningTasks = activityManager.appTasks
+ val isOverlayRunning = runningTasks.any { taskInfo ->
+ taskInfo.taskInfo.topActivity?.className == "com.teambrake.brake.overlay.main.OverlayActivity"
+ }
+
+ if (isOverlayRunning) {
+ Timber.d("OverlayActivity가 이미 실행 중입니다. 중복 실행을 방지합니다.")
+ return
+ }
+
+ val bundle = Bundle().apply {
+ // ClassLoader 를 설정하여 해당 Parcelable 클래스를 찾을 수 있도록 함
+ classLoader = OverlayData::class.java.classLoader
+ putParcelable(BlockingConstants.EXTRA_OVERLAY_DATA, overlayData)
+ }
+
+ val intent = Intent(BlockingConstants.ACTION_SHOW_OVERLAY).apply {
+ `package` = context.packageName
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or
+ Intent.FLAG_ACTIVITY_SINGLE_TOP or
+ Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtras(bundle)
+ }
+
+ context.startActivity(intent)
+ }
+
+ fun closeOverlay(context: Context) {
+ Timber.d("OverlayActivity 종료 신호를 전송합니다.")
+ val intent = Intent(BlockingConstants.ACTION_CLOSE_OVERLAY).apply {
+ `package` = context.packageName
+ }
+ context.sendBroadcast(intent)
+ }
+}
diff --git a/core/util/src/main/java/com/teambrake/brake/core/util/extensions/LocalDateTimeExt.kt b/core/util/src/main/java/com/teambrake/brake/core/util/extensions/LocalDateTimeExt.kt
new file mode 100644
index 00000000..eb83c6d0
--- /dev/null
+++ b/core/util/src/main/java/com/teambrake/brake/core/util/extensions/LocalDateTimeExt.kt
@@ -0,0 +1,44 @@
+package com.teambrake.brake.core.util.extensions
+
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.util.Locale
+import java.time.temporal.ChronoUnit
+
+fun LocalDateTime.toLocalizedTime(locale: Locale = Locale.getDefault()): String = when (locale.language) {
+ Locale.KOREAN.language -> {
+ val formatter = DateTimeFormatter.ofPattern("h시 mm분", locale)
+ this.format(formatter)
+ }
+ else -> {
+ val formatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
+ .withLocale(locale)
+ this.format(formatter)
+ }
+}
+
+fun LocalDateTime.toSimpleTime(): String {
+ val formatter = DateTimeFormatter.ofPattern("HH:mm")
+ return this.format(formatter)
+}
+
+fun getRemainingSeconds(
+ endTime: LocalDateTime?,
+): Long {
+ if (endTime == null) return 0L
+ val remaining = ChronoUnit.SECONDS.between(LocalDateTime.now(), endTime)
+ return maxOf(0L, remaining)
+}
+
+fun Long.toMinutesAndSeconds(): Pair {
+ val minutes = this / 60
+ val seconds = this % 60
+ return Pair(minutes, seconds)
+}
+
+fun LocalDate.toShortDateFormat(): String {
+ val formatter = DateTimeFormatter.ofPattern("M/d")
+ return this.format(formatter)
+}
diff --git a/data-test/.gitignore b/data-test/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/data-test/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/data-test/build.gradle.kts b/data-test/build.gradle.kts
new file mode 100644
index 00000000..0025ab99
--- /dev/null
+++ b/data-test/build.gradle.kts
@@ -0,0 +1,26 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.library)
+ alias(libs.plugins.brake.android.hilt)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ setNamespace("dataTest")
+}
+
+dependencies {
+ implementation(projects.core.model)
+ implementation(projects.core.datastore)
+ implementation(projects.core.database)
+ implementation(projects.domain)
+
+ implementation(libs.datastore)
+ implementation(libs.retrofit.core)
+ implementation(libs.retrofit.kotlin.serialization)
+ implementation(libs.okhttp.logging)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.kotlinx.datetime)
+ testImplementation(libs.turbine)
+}
diff --git a/data-test/src/main/java/com/teambrake/brake/data/test/repository/FakeNicknameRepositoryImpl.kt b/data-test/src/main/java/com/teambrake/brake/data/test/repository/FakeNicknameRepositoryImpl.kt
new file mode 100644
index 00000000..05c874db
--- /dev/null
+++ b/data-test/src/main/java/com/teambrake/brake/data/test/repository/FakeNicknameRepositoryImpl.kt
@@ -0,0 +1,47 @@
+package com.teambrake.brake.data.test.repository
+
+import com.teambrake.brake.core.model.user.UserName
+import com.teambrake.brake.core.model.user.UserStatus
+import com.teambrake.brake.domain.repository.NicknameRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+
+internal class FakeNicknameRepositoryImpl @Inject constructor() : NicknameRepository {
+
+ override fun getRemoteUserName(onError: suspend (Throwable) -> Unit): Flow = flow {
+ emit(
+ UserName(
+ nickname = "FakeUser",
+ state = UserStatus.ACTIVE,
+ ),
+ )
+ }
+
+ override fun getLocalUserName(onError: suspend (Throwable) -> Unit): Flow = flow {
+ // Fake 구현체에서는 아무 동작도 하지 않음
+ }
+
+ override suspend fun saveLocalUserName(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ // Fake 구현체에서는 아무 동작도 하지 않음
+ }
+
+ override fun updateUserName(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = flow {
+ emit(
+ UserName(
+ nickname = nickname,
+ state = UserStatus.ACTIVE,
+ ),
+ )
+ }
+
+ override suspend fun clearLocalName(onError: suspend (Throwable) -> Unit) {
+ // Fake 구현체에서는 아무 동작도 하지 않음
+ }
+}
diff --git a/data-test/src/main/java/com/teambrake/brake/data/test/repository/FakeTokenRepositoryImpl.kt b/data-test/src/main/java/com/teambrake/brake/data/test/repository/FakeTokenRepositoryImpl.kt
new file mode 100644
index 00000000..9b49cf63
--- /dev/null
+++ b/data-test/src/main/java/com/teambrake/brake/data/test/repository/FakeTokenRepositoryImpl.kt
@@ -0,0 +1,49 @@
+package com.teambrake.brake.data.test.repository
+
+import com.teambrake.brake.core.model.user.UserToken
+import com.teambrake.brake.core.model.user.UserStatus
+import com.teambrake.brake.domain.repository.TokenRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+
+internal class FakeTokenRepositoryImpl @Inject constructor() : TokenRepository {
+ override fun getRemoteTokens(
+ provider: String,
+ authorizationCode: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = flow {
+ emit(
+ UserToken(
+ accessToken = "FakeAccessToken",
+ refreshToken = "FakeRefreshToken",
+ status = UserStatus.HALF_SIGNUP,
+ ),
+ )
+ }
+
+ override fun getRemoteTokensRetry(
+ provider: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = getRemoteTokens(
+ provider = provider,
+ authorizationCode = "Fake",
+ onError = onError,
+ )
+
+ override suspend fun clearLocalTokens(onError: suspend (Throwable) -> Unit) {
+ // Fake 구현체 에서는 아무 동작도 하지 않음
+ }
+
+ override suspend fun refreshTokens(onError: suspend (Throwable) -> Unit) {
+ // Fake 구현체 에서는 아무 동작도 하지 않음
+ }
+
+ override suspend fun clearLocalAuthCode(onError: suspend (Throwable) -> Unit) {
+ // Fake 구현체에서는 아무 동작도 하지 않음
+ }
+
+ override fun logoutRemoteAccount() {
+ // Fake 구현체에서는 아무 동작도 하지 않음
+ }
+}
diff --git a/data-test/src/main/java/com/teambrake/brake/data/test/repository/di/TestRepositoryModule.kt b/data-test/src/main/java/com/teambrake/brake/data/test/repository/di/TestRepositoryModule.kt
new file mode 100644
index 00000000..b6b5a46c
--- /dev/null
+++ b/data-test/src/main/java/com/teambrake/brake/data/test/repository/di/TestRepositoryModule.kt
@@ -0,0 +1,28 @@
+package com.teambrake.brake.data.test.repository.di
+
+import com.teambrake.brake.data.test.repository.FakeTokenRepositoryImpl
+import com.teambrake.brake.data.test.repository.FakeNicknameRepositoryImpl
+import com.teambrake.brake.domain.repository.TokenRepository
+import com.teambrake.brake.domain.repository.NicknameRepository
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Named
+
+@InstallIn(SingletonComponent::class)
+@Module
+internal abstract class TestRepositoryModule {
+
+ @Binds
+ @Named("FakeTokenRepo")
+ abstract fun bindFakeTokenRepository(
+ fakeTokenRepository: FakeTokenRepositoryImpl,
+ ): TokenRepository
+
+ @Binds
+ @Named("FakeNicknameRepo")
+ abstract fun bindFakeNicknameRepository(
+ fakeNicknameRepository: FakeNicknameRepositoryImpl,
+ ): NicknameRepository
+}
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index 1fd41299..8eb62a8d 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -1,24 +1,36 @@
-import com.yapp.breake.setNamespace
+import com.teambrake.brake.setNamespace
plugins {
- alias(libs.plugins.breake.android.library)
- alias(libs.plugins.breake.android.hilt)
+ alias(libs.plugins.brake.android.library)
+ alias(libs.plugins.brake.android.hilt)
alias(libs.plugins.kotlin.serialization)
}
android {
setNamespace("data")
+
+ buildFeatures {
+ buildConfig = true
+ }
}
dependencies {
+ implementation(projects.domain)
+ implementation(projects.core.auth)
implementation(projects.core.model)
+ implementation(projects.core.util)
implementation(projects.core.datastore)
implementation(projects.core.database)
+ implementation(projects.core.common)
+ implementation(projects.core.appscanner)
+ implementation(projects.core.detection)
+ implementation(libs.datastore)
implementation(libs.retrofit.core)
implementation(libs.retrofit.kotlin.serialization)
implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
+ implementation(libs.sandwich.retrofit)
testImplementation(libs.turbine)
}
diff --git a/data/src/main/java/com/teambrake/brake/data/di/ConfigModule.kt b/data/src/main/java/com/teambrake/brake/data/di/ConfigModule.kt
new file mode 100644
index 00000000..7e3670d7
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/di/ConfigModule.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.data.di
+
+import com.teambrake.brake.data.etc.ConstTimeProviderImpl
+import com.teambrake.brake.domain.etc.ConstTimeProvider
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class ConfigModule {
+
+ @Binds
+ abstract fun bindConstTimeProvider(
+ impl: ConstTimeProviderImpl,
+ ): ConstTimeProvider
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/etc/ConstTimeProviderImpl.kt b/data/src/main/java/com/teambrake/brake/data/etc/ConstTimeProviderImpl.kt
new file mode 100644
index 00000000..8e0af6b9
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/etc/ConstTimeProviderImpl.kt
@@ -0,0 +1,31 @@
+package com.teambrake.brake.data.etc
+
+import com.teambrake.brake.core.common.Constants
+import com.teambrake.brake.data.BuildConfig
+import com.teambrake.brake.domain.etc.ConstTimeProvider
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ConstTimeProviderImpl @Inject constructor() : ConstTimeProvider {
+
+ private val isDebug get() = BuildConfig.DEBUG
+
+ override val snoozeTime: Long = if (isDebug) {
+ Constants.TEST_SNOOZE_TIME
+ } else {
+ Constants.SNOOZE_TIME
+ }
+
+ override val blockingTime: Long = if (isDebug) {
+ Constants.TEST_BLOCKING_TIME
+ } else {
+ Constants.BLOCKING_TIME
+ }
+
+ override fun getTime(seconds: Long): Long = if (isDebug) {
+ seconds
+ } else {
+ seconds * 60
+ }
+}
diff --git a/core/designsystem/src/main/java/com/yapp/breake/core/designsystem/.gitkeep b/data/src/main/java/com/teambrake/brake/data/local/model/.gitkeep
similarity index 100%
rename from core/designsystem/src/main/java/com/yapp/breake/core/designsystem/.gitkeep
rename to data/src/main/java/com/teambrake/brake/data/local/model/.gitkeep
diff --git a/data/src/main/java/com/teambrake/brake/data/local/source/AppGroupLocalDataSource.kt b/data/src/main/java/com/teambrake/brake/data/local/source/AppGroupLocalDataSource.kt
new file mode 100644
index 00000000..1f70201a
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/local/source/AppGroupLocalDataSource.kt
@@ -0,0 +1,72 @@
+package com.teambrake.brake.data.local.source
+
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import kotlinx.coroutines.flow.Flow
+import java.time.LocalDateTime
+
+interface AppGroupLocalDataSource {
+
+ suspend fun insertAppGroup(
+ appGroup: AppGroup,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+
+ suspend fun insertAppGroups(
+ appGroups: List,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+
+ suspend fun isAppGroupExists(
+ groupId: Long,
+ onError: suspend (Throwable) -> Unit = {},
+ ): Boolean
+
+ suspend fun getAvailableMinGroupId(
+ onError: suspend (Throwable) -> Unit = {},
+ ): Long
+
+ suspend fun deleteAppGroupById(
+ groupId: Long,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+
+ fun observeAppGroup(
+ onError: suspend (Throwable) -> Unit = {},
+ ): Flow>
+
+ suspend fun getAppGroupById(
+ groupId: Long,
+ onError: suspend (Throwable) -> Unit = {},
+ ): AppGroup?
+
+ suspend fun updateAppGroupState(
+ groupId: Long,
+ appGroupState: AppGroupState,
+ startTime: LocalDateTime?,
+ endTime: LocalDateTime?,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+
+ suspend fun updateGroupSessionInfo(
+ groupId: Long,
+ goalMinutes: Int?,
+ sessionStartTime: LocalDateTime?,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+
+ suspend fun insertSnooze(
+ parentGroupId: Long,
+ snoozeTime: LocalDateTime,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+
+ suspend fun resetSnooze(
+ groupId: Long,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+
+ suspend fun clearAppGroup(
+ onError: suspend (Throwable) -> Unit = {},
+ )
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/local/source/AppGroupLocalDataSourceImpl.kt b/data/src/main/java/com/teambrake/brake/data/local/source/AppGroupLocalDataSourceImpl.kt
new file mode 100644
index 00000000..ebacbbb5
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/local/source/AppGroupLocalDataSourceImpl.kt
@@ -0,0 +1,160 @@
+package com.teambrake.brake.data.local.source
+
+import com.teambrake.brake.core.appscanner.InstalledAppScanner
+import com.teambrake.brake.core.database.dao.AppGroupDao
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.data.mapper.toAppList
+import com.teambrake.brake.data.mapper.toGroupEntity
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import java.time.LocalDateTime
+import javax.inject.Inject
+
+internal class AppGroupLocalDataSourceImpl @Inject constructor(
+ private val appGroupDao: AppGroupDao,
+ private val appScanner: InstalledAppScanner,
+) : AppGroupLocalDataSource {
+
+ override suspend fun insertAppGroup(
+ appGroup: AppGroup,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ try {
+ appGroupDao.insertAppGroup(appGroup.toGroupEntity())
+ } catch (e: Exception) {
+ onError(Throwable("앱 그룹 저장에 실패했습니다"))
+ }
+ }
+
+ override suspend fun insertAppGroups(
+ appGroups: List,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ try {
+ if (appGroups.isEmpty()) return
+ appGroupDao.insertAppGroups(appGroups.map(AppGroup::toGroupEntity))
+ } catch (e: Exception) {
+ onError(Throwable("앱 그룹 일괄 저장에 실패했습니다"))
+ }
+ }
+
+ override suspend fun isAppGroupExists(
+ groupId: Long,
+ onError: suspend (Throwable) -> Unit,
+ ): Boolean = try {
+ appGroupDao.isAppGroupExists(groupId)
+ } catch (e: Exception) {
+ onError(Throwable("앱 그룹 존재 여부 확인에 실패했습니다"))
+ false
+ }
+
+ override suspend fun getAvailableMinGroupId(
+ onError: suspend (Throwable) -> Unit,
+ ): Long = try {
+ appGroupDao.getAvailableMinGroupId()
+ } catch (e: Exception) {
+ onError(Throwable("사용 가능한 그룹 ID를 가져오는데 실패했습니다"))
+ -1L
+ }
+
+ override suspend fun deleteAppGroupById(
+ groupId: Long,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ try {
+ appGroupDao.deleteAppGroupById(groupId)
+ } catch (e: Exception) {
+ onError(Throwable("앱 그룹 삭제에 실패했습니다"))
+ }
+ }
+
+ override fun observeAppGroup(
+ onError: suspend (Throwable) -> Unit,
+ ): Flow> = appGroupDao.observeAppGroup()
+ .map { appGroupEntities ->
+ appGroupEntities.map { it.toAppList(appScanner) }
+ }
+ .catch { onError(Throwable("앱 그룹 목록 관찰에 실패했습니다")) }
+
+ override suspend fun getAppGroupById(
+ groupId: Long,
+ onError: suspend (Throwable) -> Unit,
+ ): AppGroup? = try {
+ appGroupDao.getAppGroupById(groupId)?.toAppList(appScanner)
+ } catch (e: Exception) {
+ onError(Throwable("앱 그룹 정보를 가져오는데 실패했습니다"))
+ null
+ }
+
+ override suspend fun updateAppGroupState(
+ groupId: Long,
+ appGroupState: AppGroupState,
+ startTime: LocalDateTime?,
+ endTime: LocalDateTime?,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ try {
+ appGroupDao.updateAppGroupState(
+ groupId = groupId,
+ appGroupState = appGroupState,
+ startTime = startTime,
+ endTime = endTime,
+ )
+ } catch (e: Exception) {
+ onError(Throwable("앱 그룹 상태 업데이트에 실패했습니다"))
+ }
+ }
+
+ override suspend fun updateGroupSessionInfo(
+ groupId: Long,
+ goalMinutes: Int?,
+ sessionStartTime: LocalDateTime?,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ try {
+ appGroupDao.updateGroupSessionInfo(
+ groupId = groupId,
+ goalMinutes = goalMinutes,
+ sessionStartTime = sessionStartTime,
+ )
+ } catch (e: Exception) {
+ onError(Throwable("그룹 세션 정보 업데이트에 실패했습니다"))
+ }
+ }
+
+ override suspend fun insertSnooze(
+ parentGroupId: Long,
+ snoozeTime: LocalDateTime,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ try {
+ appGroupDao.insertSnooze(
+ parentGroupId = parentGroupId,
+ snoozeTime = snoozeTime,
+ )
+ } catch (e: Exception) {
+ onError(Throwable("스누즈 설정에 실패했습니다"))
+ }
+ }
+
+ override suspend fun resetSnooze(
+ groupId: Long,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ try {
+ appGroupDao.resetSnooze(groupId)
+ } catch (e: Exception) {
+ onError(Throwable("스누즈 초기화에 실패했습니다"))
+ }
+ }
+
+ override suspend fun clearAppGroup(onError: suspend (Throwable) -> Unit) {
+ try {
+ appGroupDao.clearAppGroup()
+ } catch (e: Exception) {
+ onError(Throwable("앱 그룹 초기화에 실패했습니다"))
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/local/source/AppLocalDataSource.kt b/data/src/main/java/com/teambrake/brake/data/local/source/AppLocalDataSource.kt
new file mode 100644
index 00000000..8294d41f
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/local/source/AppLocalDataSource.kt
@@ -0,0 +1,37 @@
+package com.teambrake.brake.data.local.source
+
+import com.teambrake.brake.core.model.app.App
+import kotlinx.coroutines.flow.Flow
+
+interface AppLocalDataSource {
+
+ suspend fun insertApp(
+ parentGroupId: Long,
+ app: App,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+
+ suspend fun insertApps(
+ parentGroupId: Long,
+ apps: List,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+
+ fun observeApp(
+ onError: suspend (Throwable) -> Unit = {},
+ ): Flow>
+
+ suspend fun getAppGroupIdByPackage(
+ packageName: String,
+ onError: suspend (Throwable) -> Unit = {},
+ ): Long?
+
+ suspend fun deleteAppByParentGroupId(
+ parentGroupId: Long,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+
+ suspend fun clearApps(
+ onError: suspend (Throwable) -> Unit = {},
+ )
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/local/source/AppLocalDataSourceImpl.kt b/data/src/main/java/com/teambrake/brake/data/local/source/AppLocalDataSourceImpl.kt
new file mode 100644
index 00000000..976eb18a
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/local/source/AppLocalDataSourceImpl.kt
@@ -0,0 +1,81 @@
+package com.teambrake.brake.data.local.source
+
+import com.teambrake.brake.core.appscanner.InstalledAppScanner
+import com.teambrake.brake.core.database.dao.AppDao
+import com.teambrake.brake.core.model.app.App
+import com.teambrake.brake.data.mapper.toApp
+import com.teambrake.brake.data.mapper.toAppEntity
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+internal class AppLocalDataSourceImpl @Inject constructor(
+ private val appDao: AppDao,
+ private val appScanner: InstalledAppScanner,
+) : AppLocalDataSource {
+
+ override suspend fun insertApp(
+ parentGroupId: Long,
+ app: App,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ try {
+ appDao.insert(app.toAppEntity(parentGroupId))
+ } catch (e: Exception) {
+ onError(Throwable("앱 저장에 실패했습니다"))
+ }
+ }
+
+ override suspend fun insertApps(
+ parentGroupId: Long,
+ apps: List,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ try {
+ val appEntities = apps.map { it.toAppEntity(parentGroupId) }
+ appDao.insertAll(appEntities)
+ } catch (e: Exception) {
+ onError(Throwable("앱 목록 저장에 실패했습니다"))
+ }
+ }
+
+ override fun observeApp(
+ onError: suspend (Throwable) -> Unit,
+ ): Flow> = appDao.observeApps()
+ .map { appEntities ->
+ appEntities.map { it.toApp(appScanner) }
+ }
+ .catch { onError(Throwable("앱 목록 관찰에 실패했습니다")) }
+
+ override suspend fun getAppGroupIdByPackage(
+ packageName: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Long? = try {
+ appDao.getAppByPackageName(packageName)?.parentGroupId
+ } catch (e: Exception) {
+ onError(Throwable("앱 그룹 ID 조회에 실패했습니다"))
+ null
+ }
+
+ override suspend fun deleteAppByParentGroupId(
+ parentGroupId: Long,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ try {
+ appDao.deleteAppsByParentGroupId(parentGroupId)
+ } catch (e: Exception) {
+ onError(Throwable("앱 삭제에 실패했습니다"))
+ }
+ }
+
+ override suspend fun clearApps(
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ try {
+ appDao.clearApps()
+ } catch (e: Exception) {
+ onError(Throwable("앱 목록 초기화에 실패했습니다"))
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/local/source/AuthLocalDataSource.kt b/data/src/main/java/com/teambrake/brake/data/local/source/AuthLocalDataSource.kt
new file mode 100644
index 00000000..9fbb5493
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/local/source/AuthLocalDataSource.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.data.local.source
+
+import kotlinx.coroutines.flow.Flow
+
+interface AuthLocalDataSource {
+ suspend fun updateAuthCode(authCode: String?, onError: suspend (Throwable) -> Unit)
+
+ fun getAuthCode(onError: suspend (Throwable) -> Unit): Flow
+
+ suspend fun clearAuthCode(onError: suspend (Throwable) -> Unit)
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/local/source/AuthLocalDataSourceImpl.kt b/data/src/main/java/com/teambrake/brake/data/local/source/AuthLocalDataSourceImpl.kt
new file mode 100644
index 00000000..cc5cff4f
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/local/source/AuthLocalDataSourceImpl.kt
@@ -0,0 +1,48 @@
+package com.teambrake.brake.data.local.source
+
+import androidx.datastore.core.DataStore
+import com.teambrake.brake.core.datastore.model.DatastoreAuthCode
+import com.teambrake.brake.core.model.user.exception.LocalException.DataStoreEmptyException
+import jakarta.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flow
+
+internal class AuthLocalDataSourceImpl @Inject constructor(
+ private val authCodeDataStore: DataStore,
+) : AuthLocalDataSource {
+
+ override suspend fun updateAuthCode(authCode: String?, onError: suspend (Throwable) -> Unit) {
+ authCodeDataStore.updateData { authCodeObj ->
+ authCodeObj.copy(authCode = authCode)
+ }.runCatching {
+ // 성공적인 업데이트 후 아무 작업도 하지 않음
+ }.onFailure {
+ onError(Throwable("인증 코드를 업데이트하는데 실패했습니다"))
+ }
+ }
+
+ override fun getAuthCode(onError: suspend (Throwable) -> Unit): Flow = flow {
+ authCodeDataStore.data
+ .catch {
+ onError(it)
+ }
+ .collect {
+ it.authCode?.let {
+ emit(it)
+ } ?: run {
+ onError(DataStoreEmptyException("인증 코드가 만료되었습니다"))
+ }
+ }
+ }
+
+ override suspend fun clearAuthCode(onError: suspend (Throwable) -> Unit) {
+ authCodeDataStore.updateData {
+ it.copy(authCode = null)
+ }.runCatching {
+ // 성공적인 업데이트 후 아무 작업도 하지 않음
+ }.onFailure {
+ onError(Throwable("인증 코드를 초기화하는데 실패했습니다"))
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/local/source/TokenLocalDataSource.kt b/data/src/main/java/com/teambrake/brake/data/local/source/TokenLocalDataSource.kt
new file mode 100644
index 00000000..1482ed54
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/local/source/TokenLocalDataSource.kt
@@ -0,0 +1,21 @@
+package com.teambrake.brake.data.local.source
+
+import com.teambrake.brake.core.model.user.UserStatus
+import kotlinx.coroutines.flow.Flow
+
+interface TokenLocalDataSource {
+ suspend fun updateUserToken(
+ userAccessToken: String?,
+ userRefreshToken: String?,
+ userStatus: UserStatus?,
+ onError: suspend (Throwable) -> Unit,
+ )
+
+ fun getUserAccessToken(onError: suspend (Throwable) -> Unit): Flow
+
+ fun getUserRefreshToken(onError: suspend (Throwable) -> Unit): Flow
+
+ fun getUserStatus(onError: suspend (Throwable) -> Unit): Flow
+
+ suspend fun clearUserToken(onError: suspend (Throwable) -> Unit)
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/local/source/TokenLocalDataSourceImpl.kt b/data/src/main/java/com/teambrake/brake/data/local/source/TokenLocalDataSourceImpl.kt
new file mode 100644
index 00000000..433c2afc
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/local/source/TokenLocalDataSourceImpl.kt
@@ -0,0 +1,84 @@
+package com.teambrake.brake.data.local.source
+
+import androidx.datastore.core.DataStore
+import com.teambrake.brake.core.datastore.model.DatastoreUserToken
+import com.teambrake.brake.core.model.user.UserStatus
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+
+internal class TokenLocalDataSourceImpl @Inject constructor(
+ private val userTokenDataSource: DataStore,
+) : TokenLocalDataSource {
+ override suspend fun updateUserToken(
+ userAccessToken: String?,
+ userRefreshToken: String?,
+ userStatus: UserStatus?,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ userTokenDataSource.updateData { tokenObject ->
+ tokenObject.copy(
+ accessToken = userAccessToken ?: tokenObject.accessToken,
+ refreshToken = userRefreshToken ?: tokenObject.refreshToken,
+ status = userStatus ?: tokenObject.status,
+ )
+ }.runCatching {
+ // 성공적인 업데이트 후 아무 작업도 하지 않음
+ }.onFailure {
+ onError(Throwable("유저 인증 업데이트를 실패했습니다"))
+ }
+ }
+
+ override fun getUserAccessToken(onError: suspend (Throwable) -> Unit): Flow = flow {
+ userTokenDataSource.data
+ .catch {
+ onError(Throwable("유저 인증을 가져오는데 실패했습니다"))
+ }
+ .collect { tokenData ->
+ tokenData.accessToken?.let {
+ emit(it)
+ } ?: run {
+ onError(Throwable("유저 인증 액세스 토큰이 설정되어 있지 않습니다"))
+ }
+ }
+ }
+
+ override fun getUserRefreshToken(onError: suspend (Throwable) -> Unit): Flow = flow {
+ userTokenDataSource.data
+ .catch {
+ onError(Throwable("유저 인증을 가져오는데 실패했습니다"))
+ }
+ .collect { tokenData ->
+ tokenData.refreshToken?.let {
+ emit(it)
+ } ?: run {
+ onError(Throwable("유저 인증 리프레시 토큰이 설정되어 있지 않습니다"))
+ }
+ }
+ }
+
+ override fun getUserStatus(onError: suspend (Throwable) -> Unit): Flow = flow {
+ userTokenDataSource.data
+ .catch {
+ onError(it)
+ }
+ .collect { tokenData ->
+ emit(tokenData.status)
+ }
+ }
+
+ override suspend fun clearUserToken(onError: suspend (Throwable) -> Unit) {
+ userTokenDataSource.updateData { tokenObject ->
+ tokenObject.copy(
+ accessToken = null,
+ refreshToken = null,
+ status = UserStatus.INACTIVE,
+ )
+ }.runCatching {
+ // 성공적인 업데이트 후 아무 작업도 하지 않음
+ }.onFailure {
+ onError(Throwable("유저 인증을 초기화하는데 실패했습니다"))
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/local/source/UserLocalDataSource.kt b/data/src/main/java/com/teambrake/brake/data/local/source/UserLocalDataSource.kt
new file mode 100644
index 00000000..92e4f760
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/local/source/UserLocalDataSource.kt
@@ -0,0 +1,20 @@
+package com.teambrake.brake.data.local.source
+
+import kotlinx.coroutines.flow.Flow
+
+interface UserLocalDataSource {
+ suspend fun updateOnboardingFlag(isComplete: Boolean, onError: suspend (Throwable) -> Unit)
+
+ fun getOnboardingFlag(onError: suspend (Throwable) -> Unit): Flow
+
+ suspend fun clearUserInfo(onError: suspend (Throwable) -> Unit)
+
+ suspend fun updateNickname(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ )
+
+ suspend fun clearNickname(onError: suspend (Throwable) -> Unit)
+
+ fun getNickname(onError: suspend (Throwable) -> Unit): Flow
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/local/source/UserLocalDataSourceImpl.kt b/data/src/main/java/com/teambrake/brake/data/local/source/UserLocalDataSourceImpl.kt
new file mode 100644
index 00000000..d050577b
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/local/source/UserLocalDataSourceImpl.kt
@@ -0,0 +1,83 @@
+package com.teambrake.brake.data.local.source
+
+import androidx.datastore.core.DataStore
+import com.teambrake.brake.core.datastore.model.DatastoreOnboarding
+import com.teambrake.brake.core.datastore.model.DatastoreUserInfo
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flow
+import timber.log.Timber
+import javax.inject.Inject
+
+internal class UserLocalDataSourceImpl @Inject constructor(
+ private val userInfoDataStore: DataStore,
+ private val onboardingDataStore: DataStore,
+) : UserLocalDataSource {
+ override suspend fun updateOnboardingFlag(
+ isComplete: Boolean,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ onboardingDataStore.updateData {
+ it.copy(flag = isComplete)
+ }
+ val result = onboardingDataStore.data.firstOrNull()?.flag == true
+ Timber.e("Onboarding flag updated: $result")
+ }
+
+ override fun getOnboardingFlag(onError: suspend (Throwable) -> Unit): Flow = flow {
+ onboardingDataStore.data
+ .catch {
+ onError(Throwable("온보딩 플래그를 가져오는데 실패했습니다"))
+ }.collect { onboardingFlag ->
+ emit(onboardingFlag.flag)
+ }
+ }
+
+ override suspend fun clearUserInfo(onError: suspend (Throwable) -> Unit) {
+ userInfoDataStore.updateData {
+ DatastoreUserInfo.Empty
+ }.runCatching {
+ // 성공적인 업데이트 후 아무 작업도 하지 않음
+ }.onFailure {
+ onError(Throwable("유저 정보 비우기에 실패했습니다"))
+ }
+ }
+
+ override suspend fun updateNickname(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ userInfoDataStore.updateData {
+ it.copy(nickname = nickname)
+ }.runCatching {
+ // 성공적인 업데이트 후 아무 작업도 하지 않음
+ }.onFailure {
+ onError(Throwable("유저 닉네임 업데이트를 실패했습니다"))
+ }
+ }
+
+ override suspend fun clearNickname(onError: suspend (Throwable) -> Unit) {
+ userInfoDataStore.updateData {
+ it.copy(nickname = null)
+ }.runCatching {
+ // 성공적인 업데이트 후 아무 작업도 하지 않음
+ }.onFailure {
+ onError(Throwable("유저 닉네임을 초기화하는데 실패했습니다"))
+ }
+ }
+
+ override fun getNickname(onError: suspend (Throwable) -> Unit): Flow = flow {
+ userInfoDataStore.data
+ .catch {
+ onError(Throwable("유저 닉네임을 가져오는데 실패했습니다"))
+ }
+ .collect { userInfo ->
+ userInfo.nickname?.let {
+ emit(userInfo.nickname!!)
+ } ?: run {
+ onError(Throwable("닉네임이 설정되어 있지 않습니다"))
+ }
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/local/source/di/LocalSourceModule.kt b/data/src/main/java/com/teambrake/brake/data/local/source/di/LocalSourceModule.kt
new file mode 100644
index 00000000..d6ad9034
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/local/source/di/LocalSourceModule.kt
@@ -0,0 +1,51 @@
+package com.teambrake.brake.data.local.source.di
+
+import com.teambrake.brake.data.local.source.AppGroupLocalDataSource
+import com.teambrake.brake.data.local.source.AppGroupLocalDataSourceImpl
+import com.teambrake.brake.data.local.source.AppLocalDataSource
+import com.teambrake.brake.data.local.source.AppLocalDataSourceImpl
+import com.teambrake.brake.data.local.source.AuthLocalDataSource
+import com.teambrake.brake.data.local.source.AuthLocalDataSourceImpl
+import com.teambrake.brake.data.local.source.TokenLocalDataSource
+import com.teambrake.brake.data.local.source.TokenLocalDataSourceImpl
+import com.teambrake.brake.data.local.source.UserLocalDataSource
+import com.teambrake.brake.data.local.source.UserLocalDataSourceImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class LocalSourceModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindAuthLocalDataSource(
+ authLocalDataSource: AuthLocalDataSourceImpl,
+ ): AuthLocalDataSource
+
+ @Binds
+ @Singleton
+ abstract fun bindTokenLocalDataSource(
+ tokenLocalDataSource: TokenLocalDataSourceImpl,
+ ): TokenLocalDataSource
+
+ @Binds
+ @Singleton
+ abstract fun bindUserLocalDataSource(
+ userLocalDataSource: UserLocalDataSourceImpl,
+ ): UserLocalDataSource
+
+ @Binds
+ @Singleton
+ abstract fun bindAppGroupLocalDataSource(
+ appGroupLocalDataSource: AppGroupLocalDataSourceImpl,
+ ): AppGroupLocalDataSource
+
+ @Binds
+ internal abstract fun bindAppLocalDataSource(
+ appLocalDataSourceImpl: AppLocalDataSourceImpl,
+ ): AppLocalDataSource
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/mapper/AppGroupMapper.kt b/data/src/main/java/com/teambrake/brake/data/mapper/AppGroupMapper.kt
new file mode 100644
index 00000000..5f831f10
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/mapper/AppGroupMapper.kt
@@ -0,0 +1,90 @@
+package com.teambrake.brake.data.mapper
+
+import com.teambrake.brake.core.appscanner.InstalledAppScanner
+import com.teambrake.brake.core.database.entity.AppEntity
+import com.teambrake.brake.core.database.entity.AppGroupEntity
+import com.teambrake.brake.core.database.entity.GroupEntity
+import com.teambrake.brake.core.database.entity.SnoozeEntity
+import com.teambrake.brake.core.model.app.App
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.core.model.app.Snooze
+import com.teambrake.brake.core.util.toByteArray
+import com.teambrake.brake.data.remote.model.AppGroupData
+import com.teambrake.brake.data.remote.model.AppGroupRequest
+import com.teambrake.brake.data.remote.model.AppRequest
+
+internal fun AppGroupEntity.toAppList(appScanner: InstalledAppScanner): AppGroup = AppGroup(
+ id = group.groupId,
+ name = group.name,
+ appGroupState = group.appGroupState,
+ apps = apps.map {
+ it.toApp(appScanner)
+ },
+ snoozes = snoozes.map(SnoozeEntity::toSnooze),
+ goalMinutes = group.goalMinutes,
+ sessionStartTime = group.sessionStartTime,
+ startTime = group.startTime,
+ endTime = group.endTime,
+)
+
+internal fun AppGroup.toGroupEntity(): GroupEntity = GroupEntity(
+ groupId = id,
+ name = name,
+ appGroupState = appGroupState,
+ goalMinutes = goalMinutes,
+ sessionStartTime = sessionStartTime,
+ startTime = startTime,
+ endTime = endTime,
+)
+
+internal fun AppEntity.toApp(appScanner: InstalledAppScanner): App = App(
+ packageName = packageName,
+ id = id,
+ name = name,
+ icon = appScanner.getIconDrawable(packageName).toByteArray(),
+ category = category,
+)
+
+internal fun App.toAppEntity(parentGroupId: Long): AppEntity = AppEntity(
+ packageName = packageName,
+ id = id ?: 0L,
+ name = name,
+ category = category,
+ parentGroupId = parentGroupId,
+)
+
+internal fun SnoozeEntity.toSnooze(): Snooze = Snooze(
+ startTime = snoozeTime,
+)
+
+internal fun AppGroup.toAppGroupRequest(): AppGroupRequest = AppGroupRequest(
+ name = this.name,
+ groupApps = this.apps.map { app ->
+ AppRequest(
+ name = app.name,
+ packageName = app.packageName,
+ groupAppId = app.id,
+ )
+ },
+)
+
+internal fun AppGroupData.toAppGroup(): AppGroup = AppGroup(
+ id = groupId,
+ name = name,
+ appGroupState = AppGroupState.NeedSetting,
+ apps = groupApps.map { groupApp ->
+ App(
+ id = groupApp.groupAppId,
+ name = groupApp.name,
+ packageName = groupApp.packageName,
+ icon = null,
+ category = "",
+ )
+ },
+ snoozes = emptyList(),
+ goalMinutes = null,
+ sessionStartTime = null,
+ startTime = null,
+ endTime = null,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/mapper/SessionMapper.kt b/data/src/main/java/com/teambrake/brake/data/mapper/SessionMapper.kt
new file mode 100644
index 00000000..850f9e87
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/mapper/SessionMapper.kt
@@ -0,0 +1,27 @@
+package com.teambrake.brake.data.mapper
+
+import com.teambrake.brake.core.common.Constants
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.data.remote.model.SessionRequest
+import java.time.format.DateTimeFormatter
+
+private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
+
+internal fun AppGroup.toSessionRequest(): SessionRequest? {
+ val startTime = this.sessionStartTime
+ val endTime = this.endTime?.minusSeconds(Constants.BLOCKING_TIME)
+ val goalMinutes = this.goalMinutes
+
+ if (startTime == null || endTime == null || goalMinutes == null) {
+ return null
+ }
+
+ return SessionRequest(
+ groupId = id,
+ start = startTime.format(dateFormatter),
+ end = endTime.format(dateFormatter),
+ goalMinutes = goalMinutes,
+ snoozeUnit = Constants.SNOOZE_MINUTES.toInt(),
+ snoozeCount = snoozesCount,
+ )
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/mapper/StatisticMapper.kt b/data/src/main/java/com/teambrake/brake/data/mapper/StatisticMapper.kt
new file mode 100644
index 00000000..51bfb1bb
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/mapper/StatisticMapper.kt
@@ -0,0 +1,27 @@
+package com.teambrake.brake.data.mapper
+
+import com.teambrake.brake.core.model.app.Statistics
+import com.teambrake.brake.data.remote.model.StatisticData
+import java.time.Duration
+import java.time.LocalDate
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+
+private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
+private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
+
+internal fun StatisticData.toStatistics(): List = statistics.map { statistic ->
+ Statistics(
+ date = LocalDate.parse(statistic.date, dateFormatter),
+ dayOfWeek = statistic.dayOfWeek,
+ actualTime = stringToDuration(statistic.actualTime),
+ goalTime = stringToDuration(statistic.goalTime),
+ )
+}
+
+private fun stringToDuration(timeString: String): Duration {
+ val localTime = LocalTime.parse(timeString, timeFormatter)
+ return Duration.between(LocalTime.MIDNIGHT, localTime)
+}
+
+internal fun LocalDate.toDateString(): String = this.format(dateFormatter)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/AppGroupData.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/AppGroupData.kt
new file mode 100644
index 00000000..046235bf
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/AppGroupData.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class AppGroupData(
+ val groupId: Long,
+ val name: String,
+ val groupApps: List,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/AppGroupRequest.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/AppGroupRequest.kt
new file mode 100644
index 00000000..8eacea44
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/AppGroupRequest.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class AppGroupRequest(
+ val name: String,
+ val groupApps: List,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/AppGroupResponse.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/AppGroupResponse.kt
new file mode 100644
index 00000000..bf1e7938
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/AppGroupResponse.kt
@@ -0,0 +1,8 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class AppGroupResponse(
+ val data: AppGroupData,
+) : BaseResponse(status = 0)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/AppRequest.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/AppRequest.kt
new file mode 100644
index 00000000..aacbdd83
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/AppRequest.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class AppRequest(
+ val name: String,
+ val packageName: String,
+ val groupAppId: Long? = null,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/BaseResponse.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/BaseResponse.kt
new file mode 100644
index 00000000..cab96a49
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/BaseResponse.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+open class BaseResponse(
+ @SerialName("status") val status: Int = 0,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/GroupApp.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/GroupApp.kt
new file mode 100644
index 00000000..3a312d2f
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/GroupApp.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class GroupApp(
+ val groupAppId: Long,
+ val name: String,
+ val packageName: String = "",
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/GroupListResponse.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/GroupListResponse.kt
new file mode 100644
index 00000000..2d157f1f
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/GroupListResponse.kt
@@ -0,0 +1,13 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class GroupListResponse(
+ val data: GroupListData,
+) : BaseResponse(status = 0)
+
+@Serializable
+internal data class GroupListData(
+ val groups: List,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/LoginRequest.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/LoginRequest.kt
new file mode 100644
index 00000000..ad1b9b4f
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/LoginRequest.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.data.remote.model
+
+import com.teambrake.brake.data.remote.retrofit.ApiConfig
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LoginRequest(
+ val provider: String,
+ val authorizationCode: String,
+ val deviceName: String = ApiConfig.AndroidID.deviceInfo,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/LoginResponse.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/LoginResponse.kt
new file mode 100644
index 00000000..ea98869c
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/LoginResponse.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LoginResponse(
+ @SerialName("data") val data: LoginToken,
+) : BaseResponse(status = 0)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/LoginToken.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/LoginToken.kt
new file mode 100644
index 00000000..9381d6da
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/LoginToken.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LoginToken(
+ @SerialName("accessToken") val accessToken: String,
+ @SerialName("refreshToken") val refreshToken: String,
+ @SerialName("memberState") val memberState: String,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/MemberData.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/MemberData.kt
new file mode 100644
index 00000000..93c6c547
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/MemberData.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MemberData(
+ @SerialName("nickname") val nickname: String,
+ @SerialName("state") val state: String,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/MemberRequest.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/MemberRequest.kt
new file mode 100644
index 00000000..7d8e7c27
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/MemberRequest.kt
@@ -0,0 +1,6 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MemberRequest(val nickname: String)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/MemberResponse.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/MemberResponse.kt
new file mode 100644
index 00000000..1e580342
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/MemberResponse.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MemberResponse(
+ @SerialName("data") val data: MemberData,
+) : BaseResponse(status = 0)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/RefreshRequest.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/RefreshRequest.kt
new file mode 100644
index 00000000..d842dbdc
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/RefreshRequest.kt
@@ -0,0 +1,6 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class RefreshRequest(val refreshToken: String)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/RefreshResponse.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/RefreshResponse.kt
new file mode 100644
index 00000000..094e3f5f
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/RefreshResponse.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class RefreshResponse(
+ @SerialName("data") val data: RefreshToken,
+) : BaseResponse(status = 0)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/RefreshToken.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/RefreshToken.kt
new file mode 100644
index 00000000..b80fa7d1
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/RefreshToken.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class RefreshToken(
+ val accessToken: String,
+ val refreshToken: String,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/SessionRequest.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/SessionRequest.kt
new file mode 100644
index 00000000..1e8bcaab
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/SessionRequest.kt
@@ -0,0 +1,13 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SessionRequest(
+ val groupId: Long,
+ val start: String,
+ val end: String,
+ val goalMinutes: Int,
+ val snoozeUnit: Int,
+ val snoozeCount: Int,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/SessionResponse.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/SessionResponse.kt
new file mode 100644
index 00000000..0763d51a
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/SessionResponse.kt
@@ -0,0 +1,13 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class SessionResponse(
+ val data: SessionData,
+)
+
+@Serializable
+internal data class SessionData(
+ val sessionId: Long,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/model/StatisticResponse.kt b/data/src/main/java/com/teambrake/brake/data/remote/model/StatisticResponse.kt
new file mode 100644
index 00000000..cfd9b51f
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/model/StatisticResponse.kt
@@ -0,0 +1,21 @@
+package com.teambrake.brake.data.remote.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class StatisticResponse(
+ val data: StatisticData,
+)
+
+@Serializable
+internal data class StatisticData(
+ val statistics: List,
+)
+
+@Serializable
+internal data class Statistic(
+ val date: String,
+ val dayOfWeek: String,
+ val actualTime: String,
+ val goalTime: String,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/retrofit/ApiConfig.kt b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/ApiConfig.kt
new file mode 100644
index 00000000..203c14c6
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/ApiConfig.kt
@@ -0,0 +1,29 @@
+package com.teambrake.brake.data.remote.retrofit
+
+import android.os.Build
+import com.teambrake.brake.data.BuildConfig
+
+sealed interface ApiConfig {
+
+ data object AndroidID : ApiConfig {
+ val deviceInfo = buildString {
+ append(Build.MANUFACTURER).append("/")
+ append(Build.MODEL).append("/")
+ append(Build.DEVICE).append("/")
+ append(Build.PRODUCT).append("/")
+ append(Build.BRAND).append("/")
+ append(Build.BOARD).append("/")
+ append(Build.HARDWARE)
+ }
+ // 서드 파티 사용 없이 최대한 디바이스의 정보를 조합하여 최대한 유니크하게 설정
+ }
+
+ data object ServerDomain : ApiConfig {
+ val BASE_URL
+ get() = if (BuildConfig.DEBUG) {
+ "https://dev-brake.r-e.kr/"
+ } else {
+ "https://brake.r-e.kr/"
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/retrofit/HeaderSelectionInterceptor.kt b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/HeaderSelectionInterceptor.kt
new file mode 100644
index 00000000..6fbdd6df
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/HeaderSelectionInterceptor.kt
@@ -0,0 +1,42 @@
+package com.teambrake.brake.data.remote.retrofit
+
+import androidx.datastore.core.DataStore
+import com.teambrake.brake.core.datastore.model.DatastoreUserToken
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.runBlocking
+import okhttp3.Interceptor
+import okhttp3.Response
+import timber.log.Timber
+import javax.inject.Inject
+
+class HeaderSelectionInterceptor @Inject constructor(
+ private val tokenDataStore: DataStore,
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+
+ val path = chain.request().url.encodedPath
+ val token = runBlocking {
+ try {
+ tokenDataStore.data.firstOrNull()?.accessToken
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ Timber.d("Token: $token")
+
+ val request = if (token != null) {
+ if (!path.startsWith("/v1/auth/login")) {
+ chain.request().newBuilder()
+ .addHeader("Authorization", "Bearer $token")
+ .build()
+ } else {
+ chain.request()
+ }
+ } else {
+ chain.request()
+ }
+ return chain.proceed(request)
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/retrofit/HttpNetworkLogger.kt b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/HttpNetworkLogger.kt
new file mode 100644
index 00000000..cccfc199
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/HttpNetworkLogger.kt
@@ -0,0 +1,56 @@
+package com.teambrake.brake.data.remote.retrofit
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import timber.log.Timber
+
+internal class HttpNetworkLogger : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+
+ val logMessage = StringBuilder()
+ logMessage.appendLine("\n${request.method} Request URL: ${request.url}")
+
+ if (request.body != null) {
+ val buffer = okio.Buffer()
+ request.body!!.writeTo(buffer)
+ logMessage.appendLine("Request Body: ${buffer.readUtf8()}")
+ }
+
+ val startTime = System.nanoTime()
+ try {
+ val response = chain.proceed(request)
+ val endTime = System.nanoTime()
+
+ val durationMs = (endTime - startTime) / 1e6
+ logMessage.appendLine("${request.method} Response URL: ${response.request.url}")
+ logMessage.appendLine("Response Code: ${response.code}")
+ logMessage.appendLine("Response Time: ${durationMs}ms")
+
+ val responseBody = response.body
+ val contentLength = responseBody?.contentLength() ?: 0L
+
+ if (contentLength != 0L) {
+ val source = responseBody?.source()
+ source?.request(Long.MAX_VALUE)
+ val buffer = source?.buffer
+
+ logMessage.appendLine(
+ "Response Body: ${buffer?.clone()?.readString(Charsets.UTF_8)}",
+ )
+ }
+
+ Timber.v(logMessage.toString())
+
+ return response
+ } catch (e: Exception) {
+ val endTime = System.nanoTime()
+ val durationMs = (endTime - startTime) / 1e6
+ logMessage.appendLine("${request.method} Error occurred: ${e.message}")
+ logMessage.appendLine("Response Time: ${durationMs}ms")
+
+ Timber.e(logMessage.toString())
+ throw e
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/retrofit/RefreshTokenInterceptor.kt b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/RefreshTokenInterceptor.kt
new file mode 100644
index 00000000..88928a19
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/RefreshTokenInterceptor.kt
@@ -0,0 +1,100 @@
+package com.teambrake.brake.data.remote.retrofit
+
+import androidx.datastore.core.DataStore
+import com.teambrake.brake.core.datastore.model.DatastoreUserToken
+import com.teambrake.brake.data.remote.model.RefreshRequest
+import com.teambrake.brake.data.remote.model.RefreshResponse
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.json.Json
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import timber.log.Timber
+
+class RefreshTokenInterceptor(
+ private val userTokenDataSource: DataStore,
+) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+ val originalResponse = chain.proceed(originalRequest)
+
+ // 헤더가 AccessToken 인 요청만을 대상으로 토큰 재발급 시도
+ if (!originalRequest.url.encodedPath.startsWith("/v1/auth") &&
+ originalResponse.code in (400..499)
+ ) {
+ val originalRefreshToken = runBlocking {
+ userTokenDataSource.data.first().refreshToken
+ }
+
+ if (originalRefreshToken != null) {
+ // 토큰 재발급 요청 및 응답 처리를 위한 작업
+ val mediaType = "application/json; charset=utf-8".toMediaType()
+ val retryRefresh = originalRequest.newBuilder()
+ .url(
+ originalRequest.url.newBuilder()
+ .encodedPath("/v1/auth/refresh")
+ .query(null)
+ .build(),
+ )
+ .method(
+ "POST",
+ Json.encodeToString(RefreshRequest(originalRefreshToken))
+ .toRequestBody(mediaType),
+ )
+ // 기존 헤더 제거
+ .removeHeader("Authorization")
+ .build()
+
+ // 요청 전 Response 리소스 해제
+ originalResponse.close()
+
+ // 토큰 재발급 요청 및 응답 처리
+ val refreshResponse = chain.proceed(retryRefresh)
+ Timber.d(
+ "RefreshTokenInterceptor: Refresh Tokens response code: ${refreshResponse.code}",
+ )
+ if (refreshResponse.isSuccessful) {
+ val responseBody = refreshResponse.body?.string()
+
+ responseBody?.let { body ->
+ val refreshData = Json.decodeFromString(body)
+ val newAccessToken = refreshData.data.accessToken
+ val newRefreshToken = refreshData.data.refreshToken
+
+ Timber.d(
+ "TokenRefreshInterceptor: New tokens received - Access: $newAccessToken, Refresh: $newRefreshToken",
+ )
+
+ runBlocking {
+ userTokenDataSource.updateData { tokenObject ->
+ tokenObject.copy(
+ accessToken = newAccessToken,
+ refreshToken = newRefreshToken,
+ )
+ }
+ }
+ // 토큰 재발급 요청의 response 의 body 가 null인 경우 response 반환
+ } ?: return refreshResponse
+
+ val newAccessToken = runBlocking {
+ userTokenDataSource.data.first().accessToken
+ }
+ // originalRequest 기반으로 최초 실패 요청 재시도
+ val retryRequest = originalRequest.newBuilder()
+ .removeHeader("Authorization")
+ .header("Authorization", "Bearer $newAccessToken")
+ .build()
+
+ refreshResponse.close()
+ return chain.proceed(retryRequest)
+ } else {
+ return refreshResponse
+ }
+ }
+ }
+
+ return originalResponse
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/retrofit/RetrofitBrakeApi.kt b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/RetrofitBrakeApi.kt
new file mode 100644
index 00000000..fb8aafad
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/RetrofitBrakeApi.kt
@@ -0,0 +1,77 @@
+package com.teambrake.brake.data.remote.retrofit
+
+import com.skydoves.sandwich.ApiResponse
+import com.teambrake.brake.data.remote.model.AppGroupResponse
+import com.teambrake.brake.data.remote.model.BaseResponse
+import com.teambrake.brake.data.remote.model.AppGroupRequest
+import com.teambrake.brake.data.remote.model.GroupListResponse
+import com.teambrake.brake.data.remote.model.LoginRequest
+import com.teambrake.brake.data.remote.model.LoginResponse
+import com.teambrake.brake.data.remote.model.MemberRequest
+import com.teambrake.brake.data.remote.model.MemberResponse
+import com.teambrake.brake.data.remote.model.RefreshRequest
+import com.teambrake.brake.data.remote.model.RefreshResponse
+import com.teambrake.brake.data.remote.model.SessionRequest
+import com.teambrake.brake.data.remote.model.SessionResponse
+import com.teambrake.brake.data.remote.model.StatisticResponse
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.PATCH
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+internal interface RetrofitBrakeApi {
+
+ @POST("/v1/auth/login")
+ suspend fun getTokens(
+ @Body request: LoginRequest,
+ ): ApiResponse
+
+ @PATCH("/v1/members/me")
+ suspend fun updateMemberName(
+ @Body request: MemberRequest,
+ ): ApiResponse
+
+ @GET("/v1/members/me")
+ suspend fun getMemberName(): ApiResponse
+
+ @POST("/v1/auth/refresh")
+ suspend fun refreshTokens(
+ @Body refreshRequest: RefreshRequest,
+ ): ApiResponse
+
+ @DELETE("/v1/members/me")
+ suspend fun deleteMemberName(): ApiResponse
+
+ @POST("/v1/auth/logout")
+ suspend fun logoutAuth(
+ @Body accessToken: String,
+ ): ApiResponse
+
+ @GET("/v1/groups")
+ suspend fun getAppGroups(): ApiResponse
+
+ @POST("/v1/groups")
+ suspend fun createAppGroup(@Body request: AppGroupRequest): ApiResponse
+
+ @PUT("/v1/groups/{groupId}")
+ suspend fun updateAppGroup(
+ @Path("groupId") groupId: Long,
+ @Body request: AppGroupRequest,
+ ): ApiResponse
+
+ @DELETE("/v1/groups/{groupId}")
+ suspend fun deleteAppGroup(@Path("groupId") groupId: Long): ApiResponse
+
+ @POST("/v1/session")
+ suspend fun sendSession(@Body request: SessionRequest): ApiResponse
+
+ @GET("/v1/statistics")
+ suspend fun getStatistics(
+ @Query("start") start: String,
+ @Query("end") end: String,
+ ): ApiResponse
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/retrofit/RetryTimeoutInterceptor.kt b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/RetryTimeoutInterceptor.kt
new file mode 100644
index 00000000..f04f123e
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/RetryTimeoutInterceptor.kt
@@ -0,0 +1,53 @@
+package com.teambrake.brake.data.remote.retrofit
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import timber.log.Timber
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+
+class RetryTimeoutInterceptor(private val maxRetries: Int) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ var tryCount = 0
+
+ while (true) {
+ try {
+ Timber.d("Attempt: ${tryCount + 1}\nRequest URL: ${request.url}")
+ val response = chain.proceed(request)
+
+ return response
+ } catch (e: Exception) {
+ tryCount++
+
+ if (canRetryException(e) && tryCount <= maxRetries) {
+ Timber.w(e, "Retry Count: ($tryCount)")
+ continue
+ } else {
+ Timber.e(e, "Request failed after $tryCount Retries")
+ throw e
+ }
+ }
+ }
+ }
+
+ private fun canRetryException(e: Exception): Boolean = when (e) {
+ // IP 주소를 찾을 수 없는 경우 재시도 제외
+ is UnknownHostException -> false
+ // Read, Write Timeout 관련 예외는 재시도 가능
+ is SocketTimeoutException -> true
+
+ // 기타 IOException은 원인을 더 자세히 검사
+ is IOException -> {
+ // 원인(cause)이 Timeout 관련 예외면 재시도 가능
+ val cause = e.cause
+ when (cause) {
+ is SocketTimeoutException -> true
+ else -> false
+ }
+ }
+ else -> false
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/retrofit/di/NetworkModule.kt b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/di/NetworkModule.kt
new file mode 100644
index 00000000..adf1c172
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/retrofit/di/NetworkModule.kt
@@ -0,0 +1,93 @@
+package com.teambrake.brake.data.remote.retrofit.di
+
+import androidx.datastore.core.DataStore
+import com.teambrake.brake.data.remote.retrofit.ApiConfig
+import com.teambrake.brake.data.remote.retrofit.HeaderSelectionInterceptor
+import com.teambrake.brake.data.remote.retrofit.HttpNetworkLogger
+import com.teambrake.brake.data.remote.retrofit.RefreshTokenInterceptor
+import com.teambrake.brake.data.remote.retrofit.RetrofitBrakeApi
+import com.teambrake.brake.data.remote.retrofit.RetryTimeoutInterceptor
+import com.skydoves.sandwich.retrofit.adapters.ApiResponseCallAdapterFactory
+import com.teambrake.brake.core.datastore.model.DatastoreUserToken
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+import kotlinx.serialization.json.Json
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import retrofit2.Converter
+import retrofit2.Retrofit
+import retrofit2.converter.kotlinx.serialization.asConverterFactory
+import java.util.concurrent.TimeUnit
+import javax.inject.Provider
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object NetworkModule {
+
+ @Provides
+ @Singleton
+ fun provideOkhttpClient(
+ interceptors: Provider>,
+ userTokenDataSource: DataStore,
+ ): OkHttpClient =
+ OkHttpClient.Builder()
+ .apply {
+ interceptors.get().forEach(::addInterceptor)
+ }
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .addInterceptor(RetryTimeoutInterceptor(maxRetries = 1))
+ .addInterceptor(
+ RefreshTokenInterceptor(
+ userTokenDataSource = userTokenDataSource,
+ ),
+ )
+ .build()
+
+ @Provides
+ @Singleton
+ @IntoSet
+ fun providerHttpLoggingInterceptor(): Interceptor =
+ HttpNetworkLogger()
+
+ @Provides
+ @Singleton
+ @IntoSet
+ fun provideHeaderSelectionInterceptor(
+ tokenDataStore: DataStore,
+ ): Interceptor = HeaderSelectionInterceptor(tokenDataStore)
+
+ @Provides
+ @Singleton
+ fun provideConverterFactory(
+ json: Json,
+ ): Converter.Factory =
+ json.asConverterFactory("application/json".toMediaType())
+
+ @Provides
+ fun provideBrakeApi(
+ okHttpClient: OkHttpClient,
+ converterFactory: Converter.Factory,
+ ): RetrofitBrakeApi = Retrofit.Builder()
+ .addConverterFactory(converterFactory)
+ .addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
+ .baseUrl(ApiConfig.ServerDomain.BASE_URL)
+ .client(okHttpClient)
+ .build()
+ .create(RetrofitBrakeApi::class.java)
+
+ @Provides
+ @Singleton
+ fun provideJson(): Json =
+ Json {
+ ignoreUnknownKeys = true
+ coerceInputValues = true
+ encodeDefaults = true
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/source/AccountRemoteDataSource.kt b/data/src/main/java/com/teambrake/brake/data/remote/source/AccountRemoteDataSource.kt
new file mode 100644
index 00000000..5fcb479b
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/source/AccountRemoteDataSource.kt
@@ -0,0 +1,12 @@
+package com.teambrake.brake.data.remote.source
+
+interface AccountRemoteDataSource {
+ suspend fun deleteAccount(
+ onError: suspend (Throwable) -> Unit,
+ )
+
+ suspend fun logoutAccount(
+ accessToken: String,
+ onError: suspend (Throwable) -> Unit,
+ )
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/source/AccountRemoteDataSourceImpl.kt b/data/src/main/java/com/teambrake/brake/data/remote/source/AccountRemoteDataSourceImpl.kt
new file mode 100644
index 00000000..c6a5874b
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/source/AccountRemoteDataSourceImpl.kt
@@ -0,0 +1,49 @@
+package com.teambrake.brake.data.remote.source
+
+import com.skydoves.sandwich.retrofit.statusCode
+import com.skydoves.sandwich.suspendOnError
+import com.skydoves.sandwich.suspendOnException
+import com.skydoves.sandwich.suspendOnFailure
+import com.skydoves.sandwich.suspendOnSuccess
+import com.teambrake.brake.data.remote.retrofit.RetrofitBrakeApi
+import javax.inject.Inject
+
+internal class AccountRemoteDataSourceImpl @Inject constructor(
+ private val retrofitBrakeApi: RetrofitBrakeApi,
+) : AccountRemoteDataSource {
+ override suspend fun deleteAccount(onError: suspend (Throwable) -> Unit) {
+ retrofitBrakeApi.deleteMemberName()
+ .suspendOnSuccess {
+ // 계정 삭제 성공 시 아무 작업도 하지 않음
+ }
+ .suspendOnError {
+ when (statusCode.code) {
+ in 400..499 -> {
+ // 서버에서 이미 삭제된 계정에 대한 요청이 들어온 경우
+ }
+
+ else -> {
+ onError(
+ Throwable("서버 오류로 계정을 삭제할 수 없습니다."),
+ )
+ }
+ }
+ }
+ .suspendOnException {
+ onError(Throwable("계정을 삭제하는 중 오류가 발생했습니다"))
+ }
+ }
+
+ override suspend fun logoutAccount(
+ accessToken: String,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ retrofitBrakeApi.logoutAuth(accessToken)
+ .suspendOnSuccess {
+ // 로그아웃 성공 시 아무 작업도 하지 않음
+ }
+ .suspendOnFailure {
+ onError(Throwable("로그아웃 중 오류가 발생했습니다"))
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/source/AppGroupRemoteDataSource.kt b/data/src/main/java/com/teambrake/brake/data/remote/source/AppGroupRemoteDataSource.kt
new file mode 100644
index 00000000..b1c4b950
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/source/AppGroupRemoteDataSource.kt
@@ -0,0 +1,27 @@
+package com.teambrake.brake.data.remote.source
+
+import com.teambrake.brake.core.model.app.AppGroup
+import kotlinx.coroutines.flow.Flow
+
+internal interface AppGroupRemoteDataSource {
+
+ fun getAppGroups(
+ onError: suspend (Throwable) -> Unit = {},
+ ): Flow>
+
+ fun createAppGroup(
+ appGroup: AppGroup,
+ onError: suspend (Throwable) -> Unit = {},
+ ): Flow
+
+ fun updateAppGroup(
+ appGroup: AppGroup,
+ onError: suspend (Throwable) -> Unit = {},
+ ): Flow
+
+ suspend fun deleteAppGroup(
+ groupId: Long,
+ onSuccess: suspend () -> Unit,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/source/AppGroupRemoteDataSourceImpl.kt b/data/src/main/java/com/teambrake/brake/data/remote/source/AppGroupRemoteDataSourceImpl.kt
new file mode 100644
index 00000000..7e320ec0
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/source/AppGroupRemoteDataSourceImpl.kt
@@ -0,0 +1,68 @@
+package com.teambrake.brake.data.remote.source
+
+import com.skydoves.sandwich.suspendOnFailure
+import com.skydoves.sandwich.suspendOnSuccess
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.data.mapper.toAppGroup
+import com.teambrake.brake.data.mapper.toAppGroupRequest
+import com.teambrake.brake.data.remote.model.AppGroupData
+import com.teambrake.brake.data.remote.retrofit.RetrofitBrakeApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import timber.log.Timber
+import javax.inject.Inject
+
+internal class AppGroupRemoteDataSourceImpl @Inject constructor(
+ private val retrofitBrakeApi: RetrofitBrakeApi,
+) : AppGroupRemoteDataSource {
+
+ override fun getAppGroups(onError: suspend (Throwable) -> Unit): Flow> = flow {
+ retrofitBrakeApi.getAppGroups()
+ .suspendOnSuccess {
+ emit(data.data.groups.map(AppGroupData::toAppGroup))
+ }.suspendOnFailure {
+ onError(Throwable("앱 그룹을 가져오는 중 오류가 발생했습니다"))
+ Timber.e("Error fetching app groups: $this")
+ }
+ }
+
+ override fun createAppGroup(
+ appGroup: AppGroup,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = flow {
+ retrofitBrakeApi.createAppGroup(appGroup.toAppGroupRequest())
+ .suspendOnSuccess {
+ emit(data.data.toAppGroup())
+ }.suspendOnFailure {
+ onError(Throwable("앱 그룹을 생성하는 중 오류가 발생했습니다"))
+ Timber.e("Error creating app group: $this")
+ }
+ }
+
+ override fun updateAppGroup(
+ appGroup: AppGroup,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = flow {
+ retrofitBrakeApi.updateAppGroup(appGroup.id, appGroup.toAppGroupRequest())
+ .suspendOnSuccess {
+ emit(data.data.toAppGroup())
+ }.suspendOnFailure {
+ onError(Throwable("앱 그룹을 수정하는 중 오류가 발생했습니다"))
+ Timber.e("Error updating app group: $this")
+ }
+ }
+
+ override suspend fun deleteAppGroup(
+ groupId: Long,
+ onSuccess: suspend () -> Unit,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ retrofitBrakeApi.deleteAppGroup(groupId)
+ .suspendOnSuccess {
+ onSuccess()
+ }.suspendOnFailure {
+ onError(Throwable("앱 그룹을 삭제하는 중 오류가 발생했습니다"))
+ Timber.e("Error deleting app group: $this")
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/source/NameRemoteDataSource.kt b/data/src/main/java/com/teambrake/brake/data/remote/source/NameRemoteDataSource.kt
new file mode 100644
index 00000000..8f476a06
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/source/NameRemoteDataSource.kt
@@ -0,0 +1,13 @@
+package com.teambrake.brake.data.remote.source
+
+import com.teambrake.brake.data.remote.model.MemberResponse
+import kotlinx.coroutines.flow.Flow
+
+interface NameRemoteDataSource {
+ fun updateUserName(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow
+
+ fun getUserName(onError: suspend (Throwable) -> Unit): Flow
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/source/NameRemoteDataSourceImpl.kt b/data/src/main/java/com/teambrake/brake/data/remote/source/NameRemoteDataSourceImpl.kt
new file mode 100644
index 00000000..7ccc3aca
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/source/NameRemoteDataSourceImpl.kt
@@ -0,0 +1,35 @@
+package com.teambrake.brake.data.remote.source
+
+import com.skydoves.sandwich.suspendOnFailure
+import com.skydoves.sandwich.suspendOnSuccess
+import com.teambrake.brake.data.remote.model.MemberRequest
+import com.teambrake.brake.data.remote.model.MemberResponse
+import com.teambrake.brake.data.remote.retrofit.RetrofitBrakeApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+
+internal class NameRemoteDataSourceImpl @Inject constructor(
+ private val retrofitBrakeApi: RetrofitBrakeApi,
+) : NameRemoteDataSource {
+ override fun updateUserName(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = flow {
+ retrofitBrakeApi.updateMemberName(MemberRequest(nickname))
+ .suspendOnSuccess {
+ emit(this.data)
+ }.suspendOnFailure {
+ onError(Throwable("유저 닉네임을 등록하는 중 오류가 발생했습니다"))
+ }
+ }
+
+ override fun getUserName(onError: suspend (Throwable) -> Unit): Flow = flow {
+ retrofitBrakeApi.getMemberName()
+ .suspendOnSuccess {
+ emit(this.data)
+ }.suspendOnFailure {
+ onError(Throwable("유저 정보를 가져오는 중 오류가 발생했습니다"))
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/source/StatisticRemoteDataSource.kt b/data/src/main/java/com/teambrake/brake/data/remote/source/StatisticRemoteDataSource.kt
new file mode 100644
index 00000000..937f5e40
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/source/StatisticRemoteDataSource.kt
@@ -0,0 +1,21 @@
+package com.teambrake.brake.data.remote.source
+
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.Statistics
+import kotlinx.coroutines.flow.Flow
+import java.time.LocalDate
+
+internal interface StatisticRemoteDataSource {
+
+ suspend fun pushSession(
+ appGroup: AppGroup,
+ onSuccess: suspend (Long) -> Unit,
+ onError: suspend (Throwable) -> Unit = {},
+ )
+
+ fun getStatistic(
+ startDate: LocalDate,
+ endDate: LocalDate,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow?>
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/source/StatisticRemoteDataSourceImpl.kt b/data/src/main/java/com/teambrake/brake/data/remote/source/StatisticRemoteDataSourceImpl.kt
new file mode 100644
index 00000000..068cf09e
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/source/StatisticRemoteDataSourceImpl.kt
@@ -0,0 +1,53 @@
+package com.teambrake.brake.data.remote.source
+
+import com.skydoves.sandwich.suspendOnFailure
+import com.skydoves.sandwich.suspendOnSuccess
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.Statistics
+import com.teambrake.brake.data.mapper.toDateString
+import com.teambrake.brake.data.mapper.toSessionRequest
+import com.teambrake.brake.data.mapper.toStatistics
+import com.teambrake.brake.data.remote.retrofit.RetrofitBrakeApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import java.time.LocalDate
+import javax.inject.Inject
+
+internal class StatisticRemoteDataSourceImpl @Inject constructor(
+ private val retrofitBrakeApi: RetrofitBrakeApi,
+) : StatisticRemoteDataSource {
+
+ override suspend fun pushSession(
+ appGroup: AppGroup,
+ onSuccess: suspend (Long) -> Unit,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ val request = appGroup.toSessionRequest() ?: run {
+ onError(Throwable("세션 요청을 생성하는 중 오류가 발생했습니다"))
+ return
+ }
+
+ retrofitBrakeApi.sendSession(request)
+ .suspendOnSuccess {
+ onSuccess(data.data.sessionId)
+ }.suspendOnFailure {
+ onError(Throwable("세션을 생성하는 중 오류가 발생했습니다"))
+ }
+ }
+
+ override fun getStatistic(
+ startDate: LocalDate,
+ endDate: LocalDate,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow?> = flow {
+ retrofitBrakeApi.getStatistics(
+ start = startDate.toDateString(),
+ end = endDate.toDateString(),
+ ).suspendOnSuccess {
+ emit(data.data.toStatistics())
+ }.suspendOnFailure {
+ emit(null)
+ onError(Throwable("통계 정보를 가져오는 중 오류가 발생했습니다"))
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/source/TokenRemoteDataSource.kt b/data/src/main/java/com/teambrake/brake/data/remote/source/TokenRemoteDataSource.kt
new file mode 100644
index 00000000..2c44f72c
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/source/TokenRemoteDataSource.kt
@@ -0,0 +1,23 @@
+package com.teambrake.brake.data.remote.source
+
+import com.teambrake.brake.data.remote.model.LoginResponse
+import com.teambrake.brake.data.remote.model.RefreshResponse
+import kotlinx.coroutines.flow.Flow
+
+interface TokenRemoteDataSource {
+ fun getTokens(
+ provider: String,
+ authorizationCode: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow
+
+ fun refreshTokens(
+ refreshToken: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow
+
+ suspend fun logoutAccount(
+ accessToken: String,
+ onError: suspend (Throwable) -> Unit,
+ )
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/source/TokenRemoteDataSourceImpl.kt b/data/src/main/java/com/teambrake/brake/data/remote/source/TokenRemoteDataSourceImpl.kt
new file mode 100644
index 00000000..e2cb1cf2
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/source/TokenRemoteDataSourceImpl.kt
@@ -0,0 +1,62 @@
+package com.teambrake.brake.data.remote.source
+
+import com.skydoves.sandwich.suspendOnFailure
+import com.skydoves.sandwich.suspendOnSuccess
+import com.teambrake.brake.data.remote.model.LoginRequest
+import com.teambrake.brake.data.remote.model.LoginResponse
+import com.teambrake.brake.data.remote.model.RefreshRequest
+import com.teambrake.brake.data.remote.model.RefreshResponse
+import com.teambrake.brake.data.remote.retrofit.RetrofitBrakeApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import timber.log.Timber
+import javax.inject.Inject
+
+internal class TokenRemoteDataSourceImpl @Inject constructor(
+ private val retrofitBrakeApi: RetrofitBrakeApi,
+) : TokenRemoteDataSource {
+ override fun getTokens(
+ provider: String,
+ authorizationCode: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = flow {
+ Timber.d("getTokens called with provider: $provider, authorizationCode: $authorizationCode")
+ retrofitBrakeApi.getTokens(
+ LoginRequest(
+ provider = provider,
+ authorizationCode = authorizationCode,
+ ),
+ ).suspendOnSuccess {
+ emit(this.data)
+ }.suspendOnFailure {
+ Timber.e("$this")
+ onError(Throwable("서버 연결에 문제가 있습니다"))
+ }
+ }
+
+ override fun refreshTokens(
+ refreshToken: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = flow {
+ Timber.d("refreshTokens called with refreshToken: $refreshToken")
+ retrofitBrakeApi.refreshTokens(RefreshRequest(refreshToken)).suspendOnSuccess {
+ Timber.d("refreshTokens called with refreshToken: $refreshToken")
+ emit(this.data)
+ }.suspendOnFailure {
+ Timber.e("refreshTokens failed")
+ onError(Throwable("서버 연결에 문제가 있습니다"))
+ }
+ }
+
+ override suspend fun logoutAccount(
+ accessToken: String,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ retrofitBrakeApi.logoutAuth(accessToken).suspendOnSuccess {
+ Timber.d("logoutAccount successful")
+ }.suspendOnFailure {
+ Timber.e("logoutAccount failed")
+ onError(Throwable("로그아웃 중 오류가 발생했습니다"))
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/remote/source/di/RemoteSourceModule.kt b/data/src/main/java/com/teambrake/brake/data/remote/source/di/RemoteSourceModule.kt
new file mode 100644
index 00000000..9528e72b
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/remote/source/di/RemoteSourceModule.kt
@@ -0,0 +1,52 @@
+package com.teambrake.brake.data.remote.source.di
+
+import com.teambrake.brake.data.remote.source.AccountRemoteDataSource
+import com.teambrake.brake.data.remote.source.AccountRemoteDataSourceImpl
+import com.teambrake.brake.data.remote.source.AppGroupRemoteDataSource
+import com.teambrake.brake.data.remote.source.AppGroupRemoteDataSourceImpl
+import com.teambrake.brake.data.remote.source.NameRemoteDataSource
+import com.teambrake.brake.data.remote.source.NameRemoteDataSourceImpl
+import com.teambrake.brake.data.remote.source.StatisticRemoteDataSource
+import com.teambrake.brake.data.remote.source.StatisticRemoteDataSourceImpl
+import com.teambrake.brake.data.remote.source.TokenRemoteDataSource
+import com.teambrake.brake.data.remote.source.TokenRemoteDataSourceImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class RemoteSourceModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindTokenRemoteDataSource(
+ tokenRemoteDataSource: TokenRemoteDataSourceImpl,
+ ): TokenRemoteDataSource
+
+ @Binds
+ @Singleton
+ abstract fun bindNicknameRemoteDataSource(
+ nameRemoteDataSourceImpl: NameRemoteDataSourceImpl,
+ ): NameRemoteDataSource
+
+ @Binds
+ @Singleton
+ abstract fun bindAccountRemoteDataSource(
+ accountRemoteDataSource: AccountRemoteDataSourceImpl,
+ ): AccountRemoteDataSource
+
+ @Binds
+ @Singleton
+ abstract fun bindAppGroupRemoteDataSource(
+ appGroupRemoteDataSource: AppGroupRemoteDataSourceImpl,
+ ): AppGroupRemoteDataSource
+
+ @Binds
+ @Singleton
+ abstract fun bindSessionRemoteDataSource(
+ sessionRemoteDataSource: StatisticRemoteDataSourceImpl,
+ ): StatisticRemoteDataSource
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/repository/AppGroupRepositoryImpl.kt b/data/src/main/java/com/teambrake/brake/data/repository/AppGroupRepositoryImpl.kt
new file mode 100644
index 00000000..8ef13a72
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/repository/AppGroupRepositoryImpl.kt
@@ -0,0 +1,141 @@
+package com.teambrake.brake.data.repository
+
+import com.teambrake.brake.core.detection.CachedDatabase
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.data.local.source.AppGroupLocalDataSource
+import com.teambrake.brake.data.local.source.AppLocalDataSource
+import com.teambrake.brake.data.remote.source.AppGroupRemoteDataSource
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import java.time.LocalDateTime
+import javax.inject.Inject
+
+internal class AppGroupRepositoryImpl @Inject constructor(
+ private val appGroupLocalDataSource: AppGroupLocalDataSource,
+ private val appGroupRemoteDataSource: AppGroupRemoteDataSource,
+ private val appLocalDataSource: AppLocalDataSource,
+ private val cachedDatabase: CachedDatabase,
+) : AppGroupRepository {
+
+ override suspend fun insertAppGroup(appGroup: AppGroup): AppGroup = if (appGroupLocalDataSource.isAppGroupExists(appGroup.id)) {
+ appGroupRemoteDataSource.updateAppGroup(
+ appGroup = appGroup,
+ ).map { updatedGroup ->
+ appGroupLocalDataSource.insertAppGroup(updatedGroup)
+ // API 성공 후에 캐시 업데이트
+ cachedDatabase.updateAppGroupInCache(updatedGroup)
+ updatedGroup
+ }
+ } else {
+ appGroupRemoteDataSource.createAppGroup(
+ appGroup = appGroup,
+ ).map { newGroup ->
+ appGroupLocalDataSource.insertAppGroup(newGroup)
+ // API 성공 후에 캐시에 추가
+ cachedDatabase.addAppGroupToCache(newGroup)
+ newGroup
+ }
+ }.first()
+
+ override suspend fun getAvailableMinGroupId(): Long =
+ appGroupLocalDataSource.getAvailableMinGroupId()
+
+ override suspend fun deleteAppGroupByGroupId(groupId: Long) {
+ appGroupRemoteDataSource.deleteAppGroup(
+ groupId = groupId,
+ onSuccess = {
+ appGroupLocalDataSource.deleteAppGroupById(groupId = groupId)
+ cachedDatabase.removeAppGroupFromCache(groupId)
+ },
+ )
+ }
+
+ override suspend fun clearAppGroup() {
+ appGroupLocalDataSource.clearAppGroup()
+ cachedDatabase.clearCache()
+ }
+
+ override fun observeAppGroup(): Flow> = appGroupLocalDataSource.observeAppGroup()
+ .onEach { localList ->
+ if (localList.isEmpty()) {
+ appGroupRemoteDataSource.getAppGroups().collect { remoteList ->
+ appGroupLocalDataSource.insertAppGroups(remoteList)
+ remoteList.forEach {
+ appLocalDataSource.insertApps(it.id, it.apps)
+ }
+ // 원격에서 가져온 데이터로 캐시 초기화
+ cachedDatabase.initializeCachedState(remoteList)
+ }
+ } else {
+ // DB 변경 시마다 캐시 업데이트
+ cachedDatabase.initializeCachedState(localList)
+ }
+ }
+
+ override suspend fun getAppGroupById(groupId: Long): AppGroup? = appGroupLocalDataSource.getAppGroupById(groupId = groupId)
+
+ override suspend fun updateAppGroupState(
+ groupId: Long,
+ appGroupState: AppGroupState,
+ startTime: LocalDateTime?,
+ endTime: LocalDateTime?,
+ ): Result {
+ cachedDatabase.updateCachedState(groupId = groupId, appGroupState = appGroupState)
+ return try {
+ appGroupLocalDataSource.updateAppGroupState(
+ groupId = groupId,
+ appGroupState = appGroupState,
+ startTime = startTime,
+ endTime = endTime,
+ )
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun updateGroupSessionInfo(
+ groupId: Long,
+ goalMinutes: Int?,
+ sessionStartTime: LocalDateTime?,
+ ): Result = try {
+ appGroupLocalDataSource.updateGroupSessionInfo(
+ groupId = groupId,
+ goalMinutes = goalMinutes,
+ sessionStartTime = sessionStartTime,
+ )
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+
+ override suspend fun insertSnooze(groupId: Long): Result = try {
+ appGroupLocalDataSource.insertSnooze(
+ parentGroupId = groupId,
+ snoozeTime = LocalDateTime.now(),
+ )
+ // Snooze 삽입 후 캐시의 snoozesCount 업데이트
+ val updatedGroup = appGroupLocalDataSource.getAppGroupById(groupId)
+ updatedGroup?.let {
+ cachedDatabase.updateSnoozeCount(groupId, it.snoozesCount)
+ }
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+
+ override suspend fun resetSnooze(groupId: Long): Result = try {
+ appGroupLocalDataSource.resetSnooze(
+ groupId = groupId,
+ )
+ // Snooze 리셋 후 캐시의 snoozesCount를 0으로 업데이트
+ cachedDatabase.updateSnoozeCount(groupId, 0)
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/repository/NicknameRepositoryImpl.kt b/data/src/main/java/com/teambrake/brake/data/repository/NicknameRepositoryImpl.kt
new file mode 100644
index 00000000..11bc2e14
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/repository/NicknameRepositoryImpl.kt
@@ -0,0 +1,48 @@
+package com.teambrake.brake.data.repository
+
+import com.teambrake.brake.core.model.user.UserName
+import com.teambrake.brake.data.local.source.UserLocalDataSource
+import com.teambrake.brake.data.remote.source.NameRemoteDataSource
+import com.teambrake.brake.data.repository.mapper.toData
+import com.teambrake.brake.domain.repository.NicknameRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import javax.inject.Inject
+
+internal class NicknameRepositoryImpl @Inject constructor(
+ private val nameRemoteDataSource: NameRemoteDataSource,
+ private val userLocalDataSource: UserLocalDataSource,
+) : NicknameRepository {
+
+ override fun getRemoteUserName(onError: suspend (Throwable) -> Unit): Flow =
+ nameRemoteDataSource.getUserName(onError = onError).map { it.toData() }
+
+ override fun getLocalUserName(onError: suspend (Throwable) -> Unit): Flow =
+ userLocalDataSource.getNickname(onError = onError)
+
+ override suspend fun saveLocalUserName(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ userLocalDataSource.updateNickname(
+ nickname = nickname,
+ onError = onError,
+ )
+ }
+
+ override fun updateUserName(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = nameRemoteDataSource.updateUserName(
+ nickname = nickname,
+ onError = onError,
+ ).onEach {
+ // 새로운 닉네임을 로컬에 저장
+ userLocalDataSource.updateNickname(nickname, onError = onError)
+ }.map { it.toData() }
+
+ override suspend fun clearLocalName(onError: suspend (Throwable) -> Unit) {
+ userLocalDataSource.clearNickname(onError = onError)
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/repository/SessionRepositoryImpl.kt b/data/src/main/java/com/teambrake/brake/data/repository/SessionRepositoryImpl.kt
new file mode 100644
index 00000000..6a115aa7
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/repository/SessionRepositoryImpl.kt
@@ -0,0 +1,37 @@
+package com.teambrake.brake.data.repository
+
+import com.teambrake.brake.data.local.source.TokenLocalDataSource
+import com.teambrake.brake.data.local.source.UserLocalDataSource
+import com.teambrake.brake.data.remote.source.AccountRemoteDataSource
+import com.teambrake.brake.domain.repository.SessionRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class SessionRepositoryImpl @Inject constructor(
+ private val userLocalDataSource: UserLocalDataSource,
+ private val tokenLocalDataSource: TokenLocalDataSource,
+ private val accountRemoteDataSource: AccountRemoteDataSource,
+) : SessionRepository {
+
+ override suspend fun updateLocalOnboardingFlag(
+ isComplete: Boolean,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ userLocalDataSource.updateOnboardingFlag(
+ isComplete = isComplete,
+ onError = onError,
+ )
+ }
+
+ override fun getOnboardingFlag(onError: suspend (Throwable) -> Unit): Flow =
+ userLocalDataSource.getOnboardingFlag(onError = onError)
+
+ override suspend fun clearEntireDataStore(onError: suspend (Throwable) -> Unit) {
+ userLocalDataSource.clearUserInfo(onError = onError)
+ tokenLocalDataSource.clearUserToken(onError = onError)
+ }
+
+ override suspend fun clearRemoteAccount(onError: suspend (Throwable) -> Unit) {
+ accountRemoteDataSource.deleteAccount(onError = onError)
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/repository/StatisticRepositoryImpl.kt b/data/src/main/java/com/teambrake/brake/data/repository/StatisticRepositoryImpl.kt
new file mode 100644
index 00000000..b84ef44a
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/repository/StatisticRepositoryImpl.kt
@@ -0,0 +1,43 @@
+package com.teambrake.brake.data.repository
+
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.Statistics
+import com.teambrake.brake.data.remote.source.StatisticRemoteDataSource
+import com.teambrake.brake.domain.repository.StatisticRepository
+import kotlinx.coroutines.flow.Flow
+import java.time.DayOfWeek
+import java.time.LocalDate
+import java.time.temporal.TemporalAdjusters
+import javax.inject.Inject
+
+internal class StatisticRepositoryImpl @Inject constructor(
+ private val statisticRemoteDataSource: StatisticRemoteDataSource,
+) : StatisticRepository {
+
+ override suspend fun pushSession(appGroup: AppGroup): Result = try {
+ statisticRemoteDataSource.pushSession(
+ appGroup = appGroup,
+ onSuccess = {
+ Result.success(Unit)
+ },
+ )
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+
+ override fun getStatistics(
+ onError: suspend (Throwable) -> Unit,
+ ): Flow?> {
+ val today = LocalDate.now()
+ val startDate = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
+ val endDate = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY))
+
+ return statisticRemoteDataSource.getStatistic(
+ startDate = startDate,
+ endDate = endDate,
+ onError = onError,
+ )
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/repository/TokenRepositoryImpl.kt b/data/src/main/java/com/teambrake/brake/data/repository/TokenRepositoryImpl.kt
new file mode 100644
index 00000000..a9e91a8a
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/repository/TokenRepositoryImpl.kt
@@ -0,0 +1,141 @@
+package com.teambrake.brake.data.repository
+
+import com.teambrake.brake.core.auth.google.GoogleAuthManager
+import com.teambrake.brake.core.model.user.UserStatus
+import com.teambrake.brake.core.model.user.UserToken
+import com.teambrake.brake.data.local.source.AuthLocalDataSource
+import com.teambrake.brake.data.local.source.TokenLocalDataSource
+import com.teambrake.brake.data.remote.source.TokenRemoteDataSource
+import com.teambrake.brake.data.repository.mapper.toData
+import com.teambrake.brake.domain.repository.TokenRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+internal class TokenRepositoryImpl @Inject constructor(
+ private val tokenRemoteDataSource: TokenRemoteDataSource,
+ private val tokenLocalDataSource: TokenLocalDataSource,
+ private val authLocalDataSource: AuthLocalDataSource,
+ private val googleAuthManager: GoogleAuthManager,
+) : TokenRepository {
+
+ override fun getRemoteTokens(
+ provider: String,
+ authorizationCode: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = tokenRemoteDataSource.getTokens(
+ provider = provider,
+ authorizationCode = authorizationCode,
+ onError = onError,
+ ).map {
+ it.toData()
+ }.onEach {
+ // authCode 습득 성공 시 토큰과 유저 상태 (회원, 비회원) 저장
+ Timber.d("accessToken: ${it.accessToken}, refreshToken: ${it.refreshToken}, status: ${it.status}")
+ tokenLocalDataSource.updateUserToken(
+ userAccessToken = it.accessToken,
+ userRefreshToken = it.refreshToken,
+ userStatus = it.status,
+ onError = onError,
+ )
+ }.onEach {
+ // 만약 유저 토큰 상태가 HALF_SIGNUP이라면 authCode를 로컬에 저장
+ if (it.status == UserStatus.HALF_SIGNUP) {
+ authLocalDataSource.updateAuthCode(
+ authCode = authorizationCode,
+ onError = onError,
+ )
+ // 5분 후에 authCode 자동 삭제
+ @OptIn(DelicateCoroutinesApi::class)
+ GlobalScope.launch(Dispatchers.IO) {
+ delay(5 * 60 * 1000L)
+ authLocalDataSource.clearAuthCode(onError = onError)
+ }
+ }
+ }
+
+ override fun getRemoteTokensRetry(
+ provider: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = flow {
+ authLocalDataSource.getAuthCode(onError = onError).collect { authCode ->
+ getRemoteTokens(
+ provider = provider,
+ authorizationCode = authCode,
+ onError = onError,
+ ).collect { token ->
+ emit(token)
+ }
+ }
+ }
+
+ override suspend fun clearLocalTokens(onError: suspend (Throwable) -> Unit) {
+ authLocalDataSource.updateAuthCode(
+ authCode = null,
+ onError = onError,
+ )
+ tokenLocalDataSource.updateUserToken(
+ userAccessToken = null,
+ userRefreshToken = null,
+ userStatus = UserStatus.INACTIVE,
+ onError = onError,
+ )
+ }
+
+ override suspend fun refreshTokens(onError: suspend (Throwable) -> Unit) {
+ val refreshToken = tokenLocalDataSource.getUserRefreshToken(onError).firstOrNull()
+ refreshToken?.let {
+ tokenRemoteDataSource.refreshTokens(
+ refreshToken = refreshToken,
+ onError = onError,
+ ).map {
+ it.toData()
+ }.onEach {
+ // 토큰 갱신 성공 시 로컬에 저장
+ tokenLocalDataSource.updateUserToken(
+ userAccessToken = it.accessToken,
+ userRefreshToken = it.refreshToken,
+ userStatus = it.status,
+ onError = onError,
+ )
+ Timber.d("refreshToken: 토큰 갱신 성공 - ${it.accessToken}, ${it.refreshToken}, ${it.status}")
+ }.catch {
+ Timber.e("알 수 없는 오류")
+ onError(it)
+ }.collect()
+ } ?: run {
+ Timber.e("리프레시 토큰이 로컬에 없습니다")
+ onError(Throwable("리프레시 토큰이 없습니다. 다시 로그인 해주세요."))
+ }
+ }
+
+ override suspend fun clearLocalAuthCode(onError: suspend (Throwable) -> Unit) {
+ authLocalDataSource.clearAuthCode(onError = onError)
+ }
+
+ override fun logoutRemoteAccount() {
+ CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
+ tokenRemoteDataSource.logoutAccount(
+ // 해당 함수 호출부 다음 코드 라인의 Main Thread에서 접근하여 비우는 로직보다 먼저 접근
+ accessToken = tokenLocalDataSource.getUserAccessToken({
+ Timber.e("서버에 로그아웃 요청 실패: $it")
+ }).firstOrNull() ?: "",
+ onError = { Timber.e("서버에 로그아웃 요청 실패: $it") },
+ )
+ googleAuthManager.signOutGoogleAuth()
+ }
+ }
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/repository/di/RepositoryModule.kt b/data/src/main/java/com/teambrake/brake/data/repository/di/RepositoryModule.kt
new file mode 100644
index 00000000..d41979cc
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/repository/di/RepositoryModule.kt
@@ -0,0 +1,60 @@
+package com.teambrake.brake.data.repository.di
+
+import com.teambrake.brake.data.repository.AppGroupRepositoryImpl
+import com.teambrake.brake.data.repository.SessionRepositoryImpl
+import com.teambrake.brake.data.repository.StatisticRepositoryImpl
+import com.teambrake.brake.data.repository.TokenRepositoryImpl
+import com.teambrake.brake.data.repository.NicknameRepositoryImpl
+import com.teambrake.brake.data.repositoryImpl.AppRepositoryImpl
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.repository.AppRepository
+import com.teambrake.brake.domain.repository.SessionRepository
+import com.teambrake.brake.domain.repository.StatisticRepository
+import com.teambrake.brake.domain.repository.TokenRepository
+import com.teambrake.brake.domain.repository.NicknameRepository
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Named
+import javax.inject.Singleton
+
+@InstallIn(SingletonComponent::class)
+@Module
+internal abstract class RepositoryModule {
+
+ @Binds
+ @Named("TokenRepo")
+ abstract fun bindTokenRepository(
+ remoteTokenRepository: TokenRepositoryImpl,
+ ): TokenRepository
+
+ @Binds
+ @Named("NicknameRepo")
+ abstract fun bindNicknameRepository(
+ nicknameRepository: NicknameRepositoryImpl,
+ ): NicknameRepository
+
+ @Binds
+ abstract fun bindSessionRepository(
+ sessionRepository: SessionRepositoryImpl,
+ ): SessionRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindAppGroupRepository(
+ appGroupRepositoryImpl: AppGroupRepositoryImpl,
+ ): AppGroupRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindAppRepository(
+ appRepositoryImpl: AppRepositoryImpl,
+ ): AppRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindStatisticRepository(
+ statisticRepositoryImpl: StatisticRepositoryImpl,
+ ): StatisticRepository
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/repository/mapper/LoginStatusMapper.kt b/data/src/main/java/com/teambrake/brake/data/repository/mapper/LoginStatusMapper.kt
new file mode 100644
index 00000000..16ca7a55
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/repository/mapper/LoginStatusMapper.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.data.repository.mapper
+
+import com.teambrake.brake.core.model.user.UserStatus
+
+internal fun String.toLoginStatus(): UserStatus = when (this) {
+ "ACTIVE" -> UserStatus.ACTIVE
+ "HOLD" -> UserStatus.HALF_SIGNUP
+ else -> UserStatus.INACTIVE
+}
diff --git a/data/src/main/java/com/teambrake/brake/data/repository/mapper/LoginTokenMapper.kt b/data/src/main/java/com/teambrake/brake/data/repository/mapper/LoginTokenMapper.kt
new file mode 100644
index 00000000..2ea9abef
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/repository/mapper/LoginTokenMapper.kt
@@ -0,0 +1,15 @@
+package com.teambrake.brake.data.repository.mapper
+
+import com.teambrake.brake.core.model.user.UserToken
+import com.teambrake.brake.core.model.user.UserStatus
+import com.teambrake.brake.data.remote.model.LoginResponse
+
+internal fun LoginResponse.toData(): UserToken = UserToken(
+ accessToken = this.data.accessToken,
+ refreshToken = this.data.refreshToken,
+ status = when (this.data.memberState) {
+ "ACTIVE" -> UserStatus.ACTIVE
+ "HOLD" -> UserStatus.HALF_SIGNUP
+ else -> UserStatus.INACTIVE
+ },
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/repository/mapper/MemberNameMapper.kt b/data/src/main/java/com/teambrake/brake/data/repository/mapper/MemberNameMapper.kt
new file mode 100644
index 00000000..16ed8744
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/repository/mapper/MemberNameMapper.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.data.repository.mapper
+
+import com.teambrake.brake.core.model.user.UserName
+import com.teambrake.brake.data.remote.model.MemberResponse
+
+internal fun MemberResponse.toData(): UserName = UserName(
+ nickname = this.data.nickname,
+ state = this.data.state.toLoginStatus(),
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/repository/mapper/RefreshTokenMapper.kt b/data/src/main/java/com/teambrake/brake/data/repository/mapper/RefreshTokenMapper.kt
new file mode 100644
index 00000000..f440bc03
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/repository/mapper/RefreshTokenMapper.kt
@@ -0,0 +1,12 @@
+package com.teambrake.brake.data.repository.mapper
+
+import com.teambrake.brake.core.model.user.UserStatus
+import com.teambrake.brake.core.model.user.UserToken
+import com.teambrake.brake.data.remote.model.RefreshResponse
+
+internal fun RefreshResponse.toData(): UserToken = UserToken(
+ accessToken = this.data.accessToken,
+ refreshToken = this.data.refreshToken,
+ // 자동 로그인은 유저 이름까지 등록된 상태로 가정
+ status = UserStatus.ACTIVE,
+)
diff --git a/data/src/main/java/com/teambrake/brake/data/repositoryImpl/AppRepositoryImpl.kt b/data/src/main/java/com/teambrake/brake/data/repositoryImpl/AppRepositoryImpl.kt
new file mode 100644
index 00000000..f21e7d03
--- /dev/null
+++ b/data/src/main/java/com/teambrake/brake/data/repositoryImpl/AppRepositoryImpl.kt
@@ -0,0 +1,32 @@
+package com.teambrake.brake.data.repositoryImpl
+
+import com.teambrake.brake.core.model.app.App
+import com.teambrake.brake.data.local.source.AppLocalDataSource
+import com.teambrake.brake.domain.repository.AppRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class AppRepositoryImpl @Inject constructor(
+ private val appLocalDataSource: AppLocalDataSource,
+) : AppRepository {
+
+ override suspend fun insertApp(parentGroupId: Long, app: App) {
+ appLocalDataSource.insertApp(parentGroupId = parentGroupId, app = app)
+ }
+
+ override suspend fun insertApps(parentGroupId: Long, apps: List) {
+ appLocalDataSource.insertApps(parentGroupId = parentGroupId, apps = apps)
+ }
+
+ override fun observeApp(): Flow> = appLocalDataSource.observeApp()
+
+ override suspend fun getAppGroupIdByPackage(packageName: String): Long? = appLocalDataSource.getAppGroupIdByPackage(packageName = packageName)
+
+ override suspend fun deleteAppByParentGroupId(parentGroupId: Long) {
+ appLocalDataSource.deleteAppByParentGroupId(parentGroupId = parentGroupId)
+ }
+
+ override suspend fun clearApps() {
+ appLocalDataSource.clearApps()
+ }
+}
diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts
index 5414af02..8dcf6108 100644
--- a/domain/build.gradle.kts
+++ b/domain/build.gradle.kts
@@ -1,7 +1,10 @@
plugins {
- alias(libs.plugins.breake.kotlin.library)
+ alias(libs.plugins.brake.kotlin.library)
}
dependencies {
implementation(projects.core.model)
+ implementation(projects.core.common)
+
+ implementation(libs.inject)
}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/etc/ConstTimeProvider.kt b/domain/src/main/java/com/teambrake/brake/domain/etc/ConstTimeProvider.kt
new file mode 100644
index 00000000..2bd37bef
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/etc/ConstTimeProvider.kt
@@ -0,0 +1,7 @@
+package com.teambrake.brake.domain.etc
+
+interface ConstTimeProvider {
+ val snoozeTime: Long
+ val blockingTime: Long
+ fun getTime(seconds: Long): Long
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/repository/AlarmScheduler.kt b/domain/src/main/java/com/teambrake/brake/domain/repository/AlarmScheduler.kt
new file mode 100644
index 00000000..4ee8f374
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/repository/AlarmScheduler.kt
@@ -0,0 +1,19 @@
+package com.teambrake.brake.domain.repository
+
+import com.teambrake.brake.core.common.AlarmAction
+import java.time.LocalDateTime
+
+interface AlarmScheduler {
+
+ fun scheduleAlarm(
+ groupId: Long,
+ groupName: String,
+ triggerTime: LocalDateTime,
+ action: AlarmAction,
+ ): Result
+
+ fun cancelAlarm(
+ groupId: Long,
+ action: AlarmAction,
+ )
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/repository/AppGroupRepository.kt b/domain/src/main/java/com/teambrake/brake/domain/repository/AppGroupRepository.kt
new file mode 100644
index 00000000..25f7cb3e
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/repository/AppGroupRepository.kt
@@ -0,0 +1,42 @@
+package com.teambrake.brake.domain.repository
+
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import kotlinx.coroutines.flow.Flow
+import java.time.LocalDateTime
+
+interface AppGroupRepository {
+
+ suspend fun insertAppGroup(appGroup: AppGroup): AppGroup
+
+ suspend fun getAvailableMinGroupId(): Long
+
+ suspend fun deleteAppGroupByGroupId(groupId: Long)
+
+ suspend fun clearAppGroup()
+
+ fun observeAppGroup(): Flow>
+
+ suspend fun getAppGroupById(groupId: Long): AppGroup?
+
+ suspend fun updateAppGroupState(
+ groupId: Long,
+ appGroupState: AppGroupState,
+ startTime: LocalDateTime? = null,
+ endTime: LocalDateTime? = null,
+ ): Result
+
+ suspend fun updateGroupSessionInfo(
+ groupId: Long,
+ goalMinutes: Int?,
+ sessionStartTime: LocalDateTime?,
+ ): Result
+
+ suspend fun insertSnooze(
+ groupId: Long,
+ ): Result
+
+ suspend fun resetSnooze(
+ groupId: Long,
+ ): Result
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/repository/AppRepository.kt b/domain/src/main/java/com/teambrake/brake/domain/repository/AppRepository.kt
new file mode 100644
index 00000000..d38d1e25
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/repository/AppRepository.kt
@@ -0,0 +1,19 @@
+package com.teambrake.brake.domain.repository
+
+import com.teambrake.brake.core.model.app.App
+import kotlinx.coroutines.flow.Flow
+
+interface AppRepository {
+
+ suspend fun insertApp(parentGroupId: Long, app: App)
+
+ suspend fun insertApps(parentGroupId: Long, apps: List)
+
+ fun observeApp(): Flow>
+
+ suspend fun getAppGroupIdByPackage(packageName: String): Long?
+
+ suspend fun deleteAppByParentGroupId(parentGroupId: Long)
+
+ suspend fun clearApps()
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/repository/NicknameRepository.kt b/domain/src/main/java/com/teambrake/brake/domain/repository/NicknameRepository.kt
new file mode 100644
index 00000000..e0ef9572
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/repository/NicknameRepository.kt
@@ -0,0 +1,40 @@
+package com.teambrake.brake.domain.repository
+
+import com.teambrake.brake.core.model.user.UserName
+import kotlinx.coroutines.flow.Flow
+
+interface NicknameRepository {
+ /**
+ * 서버에서 사용자 이름을 가져오는 메서드
+ *
+ * @param onError 오류 발생 시 호출되는 콜백
+ * @return [Flow]로 감싸진 [UserName] 객체
+ */
+ fun getRemoteUserName(onError: suspend (Throwable) -> Unit): Flow
+
+ fun getLocalUserName(onError: suspend (Throwable) -> Unit): Flow
+
+ suspend fun saveLocalUserName(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ )
+
+ /**
+ * 사용자 이름을 업데이트하고 로컬에 저장하는 메서드
+ *
+ * @param nickname 새로 설정할 사용자 이름
+ * @param onError 오류 발생 시 호출되는 콜백
+ * @return [Flow]로 감싸진 [UserName] 객체
+ */
+ fun updateUserName(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow
+
+ /**
+ * 로컬 사용자 저장소를 비우는 메서드
+ *
+ * @param onError 오류 발생 시 호출되는 콜백
+ */
+ suspend fun clearLocalName(onError: suspend (Throwable) -> Unit)
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/repository/SessionRepository.kt b/domain/src/main/java/com/teambrake/brake/domain/repository/SessionRepository.kt
new file mode 100644
index 00000000..0d2edde0
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/repository/SessionRepository.kt
@@ -0,0 +1,16 @@
+package com.teambrake.brake.domain.repository
+
+import kotlinx.coroutines.flow.Flow
+
+interface SessionRepository {
+ suspend fun updateLocalOnboardingFlag(
+ isComplete: Boolean,
+ onError: suspend (Throwable) -> Unit,
+ )
+
+ fun getOnboardingFlag(onError: suspend (Throwable) -> Unit): Flow
+
+ suspend fun clearEntireDataStore(onError: suspend (Throwable) -> Unit)
+
+ suspend fun clearRemoteAccount(onError: suspend (Throwable) -> Unit)
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/repository/StatisticRepository.kt b/domain/src/main/java/com/teambrake/brake/domain/repository/StatisticRepository.kt
new file mode 100644
index 00000000..c59bd240
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/repository/StatisticRepository.kt
@@ -0,0 +1,14 @@
+package com.teambrake.brake.domain.repository
+
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.Statistics
+import kotlinx.coroutines.flow.Flow
+
+interface StatisticRepository {
+
+ suspend fun pushSession(appGroup: AppGroup): Result
+
+ fun getStatistics(
+ onError: suspend (Throwable) -> Unit,
+ ): Flow?>
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/repository/TokenRepository.kt b/domain/src/main/java/com/teambrake/brake/domain/repository/TokenRepository.kt
new file mode 100644
index 00000000..4a4bffcb
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/repository/TokenRepository.kt
@@ -0,0 +1,70 @@
+package com.teambrake.brake.domain.repository
+
+import com.teambrake.brake.core.model.user.UserToken
+import kotlinx.coroutines.flow.Flow
+
+interface TokenRepository {
+
+ /**
+ * 서버에서 로그인 토큰을 가져오는 메서드
+ *
+ * 카카오 인가 코드 발급 직후 사용되는 메서드
+ *
+ * @param provider 로그인 제공자 (현재 Kakao 고정)
+ * @param authorizationCode 인증 코드
+ * @param onError 오류 발생 시 호출되는 콜백
+ * @return [Flow]로 감싸진 [UserToken] 객체
+ */
+ fun getRemoteTokens(
+ provider: String,
+ authorizationCode: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow
+
+ /**
+ * 서버에서 로그인 토큰을 재시도하여 가져오는 메서드
+ *
+ * 카카오 인가 코드 발급 후 최초 로그인 실패 시 사용되는 메서드
+ *
+ * @param provider 로그인 제공자 (현재 Kakao 고정)
+ * @param onError 오류 발생 시 호출되는 콜백
+ * @return [Flow]로 감싸진 [UserToken] 객체
+ */
+ fun getRemoteTokensRetry(
+ provider: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow
+
+ /**
+ * 로컬에 저장된 토큰을 모두 초기화하는 메서드
+ *
+ * 로컬에 저장된 AuthCode, AccessToken, RefreshToken 모두 초기화
+ *
+ * @param onError 오류 발생 시 호출되는 콜백
+ */
+ suspend fun clearLocalTokens(
+ onError: suspend (Throwable) -> Unit,
+ )
+
+ suspend fun refreshTokens(
+ onError: suspend (Throwable) -> Unit,
+ )
+
+ /**
+ * AuthCode 비우기 메서드
+ *
+ * 로그인 성공 후 로컬에 AuthCode를 삭제
+ *
+ * @param onError 오류 발생 시 호출되는 콜백
+ */
+ suspend fun clearLocalAuthCode(
+ onError: suspend (Throwable) -> Unit,
+ )
+
+ /**
+ * 원격 계정 로그아웃 메서드
+ *
+ * 저장된 AccessToken을 이용하여 서버에 로그아웃 요청
+ */
+ fun logoutRemoteAccount()
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/CreateNewGroupUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/CreateNewGroupUseCase.kt
new file mode 100644
index 00000000..0476f4dd
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/CreateNewGroupUseCase.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.domain.usecase
+
+import com.teambrake.brake.core.model.app.AppGroup
+
+interface CreateNewGroupUseCase {
+ suspend operator fun invoke(
+ onError: suspend (Throwable) -> Unit,
+ group: AppGroup,
+ )
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/DecideNextDestinationFromPermissionUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/DecideNextDestinationFromPermissionUseCase.kt
new file mode 100644
index 00000000..88a99d0c
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/DecideNextDestinationFromPermissionUseCase.kt
@@ -0,0 +1,7 @@
+package com.teambrake.brake.domain.usecase
+
+import com.teambrake.brake.core.model.user.Destination
+
+interface DecideNextDestinationFromPermissionUseCase {
+ operator fun invoke(onError: suspend (Throwable) -> Unit): Destination
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/DecideStartDestinationUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/DecideStartDestinationUseCase.kt
new file mode 100644
index 00000000..c54c8c90
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/DecideStartDestinationUseCase.kt
@@ -0,0 +1,7 @@
+package com.teambrake.brake.domain.usecase
+
+import com.teambrake.brake.core.model.user.Destination
+
+interface DecideStartDestinationUseCase {
+ suspend operator fun invoke(): Destination
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/DeleteAccountUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/DeleteAccountUseCase.kt
new file mode 100644
index 00000000..08f32d6c
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/DeleteAccountUseCase.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.domain.usecase
+
+import com.teambrake.brake.core.model.user.Destination
+
+interface DeleteAccountUseCase {
+ suspend operator fun invoke(
+ onError: suspend (Throwable) -> Unit,
+ ): Destination
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/DeleteGroupUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/DeleteGroupUseCase.kt
new file mode 100644
index 00000000..92a2aaa9
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/DeleteGroupUseCase.kt
@@ -0,0 +1,8 @@
+package com.teambrake.brake.domain.usecase
+
+interface DeleteGroupUseCase {
+ suspend operator fun invoke(
+ onError: suspend (Throwable) -> Unit,
+ groupId: Long,
+ )
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/FindAppGroupUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/FindAppGroupUseCase.kt
new file mode 100644
index 00000000..963af532
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/FindAppGroupUseCase.kt
@@ -0,0 +1,7 @@
+package com.teambrake.brake.domain.usecase
+
+import com.teambrake.brake.core.model.app.AppGroup
+
+interface FindAppGroupUseCase {
+ suspend operator fun invoke(packageName: String): AppGroup?
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/GetNicknameUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/GetNicknameUseCase.kt
new file mode 100644
index 00000000..6892b9c3
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/GetNicknameUseCase.kt
@@ -0,0 +1,12 @@
+package com.teambrake.brake.domain.usecase
+
+import kotlinx.coroutines.flow.Flow
+
+interface GetNicknameUseCase {
+ /**
+ * Retrieves the nickname of the user.
+ *
+ * @return The user's nickname as a String.
+ */
+ operator fun invoke(onError: suspend (Throwable) -> Unit): Flow
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/GrantNewGroupIdUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/GrantNewGroupIdUseCase.kt
new file mode 100644
index 00000000..94b57f4b
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/GrantNewGroupIdUseCase.kt
@@ -0,0 +1,7 @@
+package com.teambrake.brake.domain.usecase
+
+interface GrantNewGroupIdUseCase {
+ suspend operator fun invoke(
+ onError: suspend (Throwable) -> Unit,
+ ): Long
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/LoginUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/LoginUseCase.kt
new file mode 100644
index 00000000..dabbae24
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/LoginUseCase.kt
@@ -0,0 +1,17 @@
+package com.teambrake.brake.domain.usecase
+
+import com.teambrake.brake.core.model.user.UserStatus
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 카카오 로그인 이후 AuthCode를 이용하여 로그인하는 UseCase
+ *
+ * 로그인 성공 시 UserStatus를 반환하며, 실패 시 onError 콜백을 호출
+ */
+interface LoginUseCase {
+ operator fun invoke(
+ authCode: String,
+ provider: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/LogoutUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/LogoutUseCase.kt
new file mode 100644
index 00000000..bda0f312
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/LogoutUseCase.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.domain.usecase
+
+import com.teambrake.brake.core.model.user.Destination
+
+interface LogoutUseCase {
+ suspend operator fun invoke(
+ onError: suspend (Throwable) -> Unit,
+ ): Destination
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/ResetAppGroupUsecase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/ResetAppGroupUsecase.kt
new file mode 100644
index 00000000..64a2604c
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/ResetAppGroupUsecase.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.domain.usecase
+
+import com.teambrake.brake.core.model.app.AppGroup
+
+interface ResetAppGroupUsecase {
+ suspend operator fun invoke(
+ appGroup: AppGroup,
+ ): Result
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/SetAlarmUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/SetAlarmUseCase.kt
new file mode 100644
index 00000000..5561ae76
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/SetAlarmUseCase.kt
@@ -0,0 +1,14 @@
+package com.teambrake.brake.domain.usecase
+
+import com.teambrake.brake.core.model.app.AppGroupState
+import java.time.LocalDateTime
+
+interface SetAlarmUseCase {
+ suspend operator fun invoke(
+ groupId: Long,
+ groupName: String,
+ appGroupState: AppGroupState,
+ second: Int = 0,
+ isUsingApp: Boolean = false,
+ ): Result
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/SetBlockingAlarmUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/SetBlockingAlarmUseCase.kt
new file mode 100644
index 00000000..49ffb571
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/SetBlockingAlarmUseCase.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.domain.usecase
+
+import java.time.LocalDateTime
+
+interface SetBlockingAlarmUseCase {
+ suspend operator fun invoke(
+ groupId: Long,
+ ): Result
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/SetSnoozeAlarmUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/SetSnoozeAlarmUseCase.kt
new file mode 100644
index 00000000..25033870
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/SetSnoozeAlarmUseCase.kt
@@ -0,0 +1,10 @@
+package com.teambrake.brake.domain.usecase
+
+import java.time.LocalDateTime
+
+interface SetSnoozeAlarmUseCase {
+ suspend operator fun invoke(
+ groupId: Long,
+ groupName: String,
+ ): Result
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/StoreOnboardingCompletionUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/StoreOnboardingCompletionUseCase.kt
new file mode 100644
index 00000000..6211835d
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/StoreOnboardingCompletionUseCase.kt
@@ -0,0 +1,5 @@
+package com.teambrake.brake.domain.usecase
+
+interface StoreOnboardingCompletionUseCase {
+ suspend operator fun invoke(isComplete: Boolean, onError: suspend (Throwable) -> Unit)
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecase/UpdateNicknameUseCase.kt b/domain/src/main/java/com/teambrake/brake/domain/usecase/UpdateNicknameUseCase.kt
new file mode 100644
index 00000000..989cbb66
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecase/UpdateNicknameUseCase.kt
@@ -0,0 +1,12 @@
+package com.teambrake.brake.domain.usecase
+
+/**
+ * 회원 가입 시 또는 닉네임 변경 시 사용되는 UseCase
+ */
+interface UpdateNicknameUseCase {
+ suspend operator fun invoke(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ onSuccess: suspend () -> Unit,
+ )
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/CreateNewGroupUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/CreateNewGroupUseCaseImpl.kt
new file mode 100644
index 00000000..29872f00
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/CreateNewGroupUseCaseImpl.kt
@@ -0,0 +1,26 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.repository.AppRepository
+import com.teambrake.brake.domain.usecase.CreateNewGroupUseCase
+import javax.inject.Inject
+
+class CreateNewGroupUseCaseImpl @Inject constructor(
+ private val appRepository: AppRepository,
+ private val appGroupRepository: AppGroupRepository,
+) : CreateNewGroupUseCase {
+
+ override suspend fun invoke(
+ onError: suspend (Throwable) -> Unit,
+ group: AppGroup,
+ ) {
+ try {
+ val appGroup = appGroupRepository.insertAppGroup(group)
+
+ appRepository.insertApps(appGroup.id, appGroup.apps)
+ } catch (e: Exception) {
+ onError(e)
+ }
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DecideNextDestinationFromPermissionUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DecideNextDestinationFromPermissionUseCaseImpl.kt
new file mode 100644
index 00000000..be67c361
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DecideNextDestinationFromPermissionUseCaseImpl.kt
@@ -0,0 +1,23 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.model.user.Destination
+import com.teambrake.brake.domain.repository.SessionRepository
+import com.teambrake.brake.domain.usecase.DecideNextDestinationFromPermissionUseCase
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+
+class DecideNextDestinationFromPermissionUseCaseImpl @Inject constructor(
+ private val sessionRepository: SessionRepository,
+) : DecideNextDestinationFromPermissionUseCase {
+ override fun invoke(onError: suspend (Throwable) -> Unit): Destination = runBlocking {
+ sessionRepository.getOnboardingFlag(onError = onError).firstOrNull()
+ ?.let { isOnboardingCompleted ->
+ if (isOnboardingCompleted) {
+ Destination.PermissionOrHome
+ } else {
+ Destination.Onboarding
+ }
+ } ?: Destination.Onboarding
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DecideStartDestinationUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DecideStartDestinationUseCaseImpl.kt
new file mode 100644
index 00000000..22a30958
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DecideStartDestinationUseCaseImpl.kt
@@ -0,0 +1,51 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.model.user.Destination
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.repository.AppRepository
+import com.teambrake.brake.domain.repository.NicknameRepository
+import com.teambrake.brake.domain.repository.SessionRepository
+import com.teambrake.brake.domain.usecase.DecideStartDestinationUseCase
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+import javax.inject.Named
+
+class DecideStartDestinationUseCaseImpl @Inject constructor(
+ private val sessionRepository: SessionRepository,
+ @Named("NicknameRepo") private val nicknameRepository: NicknameRepository,
+ private val appGroupRepository: AppGroupRepository,
+ private val appRepository: AppRepository,
+) : DecideStartDestinationUseCase {
+
+ override suspend fun invoke(): Destination = try {
+ val userName = nicknameRepository.getRemoteUserName(
+ onError = {},
+ ).first()
+ nicknameRepository.saveLocalUserName(
+ nickname = userName.nickname,
+ onError = {},
+ )
+ val isOnboardingCompleted = runBlocking {
+ sessionRepository.getOnboardingFlag(
+ onError = { throw LocalStorageException() },
+ ).firstOrNull() == true
+ }
+ if (isOnboardingCompleted) {
+ Destination.PermissionOrHome
+ } else {
+ Destination.Onboarding
+ }
+ } catch (_: LocalStorageException) {
+ Destination.Onboarding
+ } catch (_: Exception) {
+ appGroupRepository.clearAppGroup()
+ appRepository.clearApps()
+ Destination.Login
+ }
+
+ companion object {
+ class LocalStorageException : Exception()
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DeleteAccountUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DeleteAccountUseCaseImpl.kt
new file mode 100644
index 00000000..cabae39f
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DeleteAccountUseCaseImpl.kt
@@ -0,0 +1,37 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.model.user.Destination
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.repository.AppRepository
+import com.teambrake.brake.domain.repository.SessionRepository
+import com.teambrake.brake.domain.usecase.DeleteAccountUseCase
+import javax.inject.Inject
+
+class DeleteAccountUseCaseImpl @Inject constructor(
+ private val sessionRepository: SessionRepository,
+ private val appGroupRepository: AppGroupRepository,
+ private val appRepository: AppRepository,
+) : DeleteAccountUseCase {
+ override suspend fun invoke(onError: suspend (Throwable) -> Unit): Destination = try {
+ sessionRepository.clearRemoteAccount(
+ onError = { throwable ->
+ onError(throwable)
+ throw ServerException()
+ },
+ )
+ sessionRepository.clearEntireDataStore(onError = { throwable ->
+ onError(throwable)
+ throw LocalException()
+ })
+ appGroupRepository.clearAppGroup()
+ appRepository.clearApps()
+ Destination.Login
+ } catch (_: Exception) {
+ Destination.NotChanged
+ }
+
+ companion object {
+ class ServerException : Exception()
+ class LocalException : Exception()
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DeleteGroupUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DeleteGroupUseCaseImpl.kt
new file mode 100644
index 00000000..270391dd
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/DeleteGroupUseCaseImpl.kt
@@ -0,0 +1,24 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.repository.AppRepository
+import com.teambrake.brake.domain.usecase.DeleteGroupUseCase
+import javax.inject.Inject
+
+class DeleteGroupUseCaseImpl @Inject constructor(
+ private val appRepository: AppRepository,
+ private val appGroupRepository: AppGroupRepository,
+) : DeleteGroupUseCase {
+
+ override suspend fun invoke(
+ onError: suspend (Throwable) -> Unit,
+ groupId: Long,
+ ) {
+ try {
+ appGroupRepository.deleteAppGroupByGroupId(groupId)
+ appRepository.deleteAppByParentGroupId(groupId)
+ } catch (e: Exception) {
+ onError(e)
+ }
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/FindAppGroupUsecaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/FindAppGroupUsecaseImpl.kt
new file mode 100644
index 00000000..094c3ae8
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/FindAppGroupUsecaseImpl.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.repository.AppRepository
+import com.teambrake.brake.domain.usecase.FindAppGroupUseCase
+import javax.inject.Inject
+
+class FindAppGroupUsecaseImpl @Inject constructor(
+ private val appGroupRepository: AppGroupRepository,
+ private val appRepository: AppRepository,
+) : FindAppGroupUseCase {
+
+ override suspend operator fun invoke(packageName: String): AppGroup? {
+ val groupId = appRepository.getAppGroupIdByPackage(packageName) ?: return null
+ return appGroupRepository.getAppGroupById(groupId)
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/GetNicknameUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/GetNicknameUseCaseImpl.kt
new file mode 100644
index 00000000..6401dcf2
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/GetNicknameUseCaseImpl.kt
@@ -0,0 +1,15 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.domain.repository.NicknameRepository
+import com.teambrake.brake.domain.usecase.GetNicknameUseCase
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+import javax.inject.Named
+
+class GetNicknameUseCaseImpl @Inject constructor(
+ @Named("NicknameRepo") private val nicknameRepository: NicknameRepository,
+) : GetNicknameUseCase {
+ override fun invoke(
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = nicknameRepository.getLocalUserName(onError = onError)
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/GrantNewGroupIdUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/GrantNewGroupIdUseCaseImpl.kt
new file mode 100644
index 00000000..4e8dd58f
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/GrantNewGroupIdUseCaseImpl.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.usecase.GrantNewGroupIdUseCase
+import javax.inject.Inject
+
+class GrantNewGroupIdUseCaseImpl @Inject constructor(
+ private val appGroupRepository: AppGroupRepository,
+) : GrantNewGroupIdUseCase {
+ override suspend fun invoke(onError: suspend (Throwable) -> Unit): Long = appGroupRepository.getAvailableMinGroupId()
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/LoginUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/LoginUseCaseImpl.kt
new file mode 100644
index 00000000..7bf3dd74
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/LoginUseCaseImpl.kt
@@ -0,0 +1,53 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.model.user.UserStatus
+import com.teambrake.brake.domain.repository.NicknameRepository
+import com.teambrake.brake.domain.repository.SessionRepository
+import com.teambrake.brake.domain.repository.TokenRepository
+import com.teambrake.brake.domain.usecase.LoginUseCase
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Named
+
+/**
+ * 카카오 로그인 이후 AuthCode를 이용하여 로그인하는 UseCase
+ *
+ * 로그인 성공 시 UserStatus를 반환하며, 실패 시 onError 콜백을 호출
+ */
+class LoginUseCaseImpl @Inject constructor(
+ @Named("TokenRepo") private val tokenRepository: TokenRepository,
+ @Named("NicknameRepo") private val nicknameRepository: NicknameRepository,
+ private val sessionRepository: SessionRepository,
+) : LoginUseCase {
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ override operator fun invoke(
+ authCode: String,
+ provider: String,
+ onError: suspend (Throwable) -> Unit,
+ ): Flow = tokenRepository.getRemoteTokens(
+ provider = provider,
+ authorizationCode = authCode,
+ onError = onError,
+ )
+ .map { userToken ->
+ if (userToken.status == UserStatus.ACTIVE) {
+ nicknameRepository.getRemoteUserName(
+ onError = onError,
+ ).collect { userName ->
+ nicknameRepository.saveLocalUserName(
+ nickname = userName.nickname,
+ onError = onError,
+ )
+ }
+ } else {
+ sessionRepository.updateLocalOnboardingFlag(
+ isComplete = false,
+ onError = onError,
+ )
+ }
+ userToken.status
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/LogoutUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/LogoutUseCaseImpl.kt
new file mode 100644
index 00000000..405b8808
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/LogoutUseCaseImpl.kt
@@ -0,0 +1,36 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.model.user.Destination
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.repository.AppRepository
+import com.teambrake.brake.domain.repository.SessionRepository
+import com.teambrake.brake.domain.repository.TokenRepository
+import com.teambrake.brake.domain.usecase.LogoutUseCase
+import javax.inject.Inject
+import javax.inject.Named
+
+class LogoutUseCaseImpl @Inject constructor(
+ @Named("TokenRepo") private val tokenRepository: TokenRepository,
+ private val sessionRepository: SessionRepository,
+ private val appGroupRepository: AppGroupRepository,
+ private val appRepository: AppRepository,
+) : LogoutUseCase {
+ override suspend fun invoke(onError: suspend (Throwable) -> Unit): Destination = try {
+ tokenRepository.logoutRemoteAccount()
+ sessionRepository.clearEntireDataStore(
+ onError = { throwable ->
+ onError(throwable)
+ throw LocalException()
+ },
+ )
+ appGroupRepository.clearAppGroup()
+ appRepository.clearApps()
+ Destination.Login
+ } catch (_: LocalException) {
+ Destination.PermissionOrHome
+ }
+
+ companion object {
+ class LocalException : Exception()
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/ResetAppGroupUsecaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/ResetAppGroupUsecaseImpl.kt
new file mode 100644
index 00000000..6de0641e
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/ResetAppGroupUsecaseImpl.kt
@@ -0,0 +1,32 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.repository.StatisticRepository
+import com.teambrake.brake.domain.usecase.ResetAppGroupUsecase
+import javax.inject.Inject
+
+class ResetAppGroupUsecaseImpl @Inject constructor(
+ private val appGroupRepository: AppGroupRepository,
+ private val statisticRepository: StatisticRepository,
+) : ResetAppGroupUsecase {
+
+ override suspend fun invoke(appGroup: AppGroup): Result {
+ statisticRepository.pushSession(appGroup)
+
+ appGroupRepository.updateGroupSessionInfo(
+ groupId = appGroup.id,
+ goalMinutes = null,
+ sessionStartTime = null,
+ )
+
+ return appGroupRepository.updateAppGroupState(
+ groupId = appGroup.id,
+ appGroupState = AppGroupState.NeedSetting,
+ ).also {
+
+ appGroupRepository.resetSnooze(appGroup.id)
+ }
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/SetAlarmUsecaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/SetAlarmUsecaseImpl.kt
new file mode 100644
index 00000000..aca59244
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/SetAlarmUsecaseImpl.kt
@@ -0,0 +1,62 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.common.AlarmAction
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.domain.repository.AlarmScheduler
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.etc.ConstTimeProvider
+import com.teambrake.brake.domain.usecase.SetAlarmUseCase
+import java.time.LocalDateTime
+import javax.inject.Inject
+
+class SetAlarmUsecaseImpl @Inject constructor(
+ private val alarmScheduler: AlarmScheduler,
+ private val appGroupRepository: AppGroupRepository,
+ private val constTimeProvider: ConstTimeProvider,
+) : SetAlarmUseCase {
+
+ override suspend operator fun invoke(
+ groupId: Long,
+ groupName: String,
+ appGroupState: AppGroupState,
+ second: Int,
+ isUsingApp: Boolean,
+ ): Result {
+ val (action, time) = when (appGroupState) {
+ AppGroupState.Using -> AlarmAction.ACTION_USING to constTimeProvider.getTime(second.toLong())
+ AppGroupState.Blocking -> AlarmAction.ACTION_BLOCKING to constTimeProvider.blockingTime
+ else -> {
+ return Result.failure(IllegalStateException("알람을 예약하지 않는 상태입니다."))
+ }
+ }
+
+ val startTime = LocalDateTime.now()
+ val triggerTime = startTime.plusSeconds(time)
+
+ return alarmScheduler.scheduleAlarm(
+ groupId = groupId,
+ groupName = groupName,
+ triggerTime = triggerTime,
+ action = action,
+ ).onSuccess {
+ if (appGroupState == AppGroupState.Using) {
+ appGroupRepository.updateGroupSessionInfo(
+ groupId = groupId,
+ goalMinutes = second,
+ sessionStartTime = startTime,
+ )
+ }
+
+ appGroupRepository.updateAppGroupState(
+ groupId = groupId,
+ appGroupState = if (isUsingApp) {
+ AppGroupState.SnoozeBlocking
+ } else {
+ appGroupState
+ },
+ startTime = startTime,
+ endTime = triggerTime,
+ )
+ }
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/SetBlockingAlarmUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/SetBlockingAlarmUseCaseImpl.kt
new file mode 100644
index 00000000..2600a77c
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/SetBlockingAlarmUseCaseImpl.kt
@@ -0,0 +1,43 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.common.AlarmAction
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.domain.repository.AlarmScheduler
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.etc.ConstTimeProvider
+import com.teambrake.brake.domain.usecase.SetBlockingAlarmUseCase
+import java.time.LocalDateTime
+import javax.inject.Inject
+
+class SetBlockingAlarmUseCaseImpl @Inject constructor(
+ private val alarmScheduler: AlarmScheduler,
+ private val appGroupRepository: AppGroupRepository,
+ private val constTimeProvider: ConstTimeProvider,
+) : SetBlockingAlarmUseCase {
+
+ override suspend operator fun invoke(
+ groupId: Long,
+ ): Result {
+ alarmScheduler.cancelAlarm(
+ groupId = groupId,
+ action = AlarmAction.ACTION_USING,
+ )
+
+ val startTime = LocalDateTime.now()
+ val triggerTime = startTime.plusSeconds(constTimeProvider.blockingTime)
+
+ return alarmScheduler.scheduleAlarm(
+ groupId = groupId,
+ groupName = "",
+ triggerTime = triggerTime,
+ action = AlarmAction.ACTION_BLOCKING,
+ ).onSuccess {
+ appGroupRepository.updateAppGroupState(
+ groupId = groupId,
+ appGroupState = AppGroupState.Blocking,
+ startTime = startTime,
+ endTime = triggerTime,
+ )
+ }
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/SetSnoozeAlarmUsecaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/SetSnoozeAlarmUsecaseImpl.kt
new file mode 100644
index 00000000..46403bca
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/SetSnoozeAlarmUsecaseImpl.kt
@@ -0,0 +1,47 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.common.AlarmAction
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.domain.repository.AlarmScheduler
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.etc.ConstTimeProvider
+import com.teambrake.brake.domain.usecase.SetSnoozeAlarmUseCase
+import java.time.LocalDateTime
+import javax.inject.Inject
+
+class SetSnoozeAlarmUsecaseImpl @Inject constructor(
+ private val alarmScheduler: AlarmScheduler,
+ private val appGroupRepository: AppGroupRepository,
+ private val constTimeProvider: ConstTimeProvider,
+) : SetSnoozeAlarmUseCase {
+
+ override suspend operator fun invoke(
+ groupId: Long,
+ groupName: String,
+ ): Result {
+ alarmScheduler.cancelAlarm(
+ groupId = groupId,
+ action = AlarmAction.ACTION_BLOCKING,
+ )
+
+ val startTime = LocalDateTime.now()
+ val triggerTime = startTime.plusSeconds(constTimeProvider.snoozeTime)
+
+ return alarmScheduler.scheduleAlarm(
+ groupId = groupId,
+ groupName = groupName,
+ triggerTime = triggerTime,
+ action = AlarmAction.ACTION_USING,
+ ).onSuccess {
+ appGroupRepository.updateAppGroupState(
+ groupId = groupId,
+ appGroupState = AppGroupState.Using,
+ startTime = startTime,
+ endTime = triggerTime,
+ )
+ appGroupRepository.insertSnooze(
+ groupId = groupId,
+ )
+ }
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/StoreOnboardingCompletionUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/StoreOnboardingCompletionUseCaseImpl.kt
new file mode 100644
index 00000000..103122d6
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/StoreOnboardingCompletionUseCaseImpl.kt
@@ -0,0 +1,19 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.domain.repository.SessionRepository
+import com.teambrake.brake.domain.usecase.StoreOnboardingCompletionUseCase
+import javax.inject.Inject
+
+class StoreOnboardingCompletionUseCaseImpl @Inject constructor(
+ private val sessionRepository: SessionRepository,
+) : StoreOnboardingCompletionUseCase {
+ override suspend fun invoke(
+ isComplete: Boolean,
+ onError: suspend (Throwable) -> Unit,
+ ) {
+ sessionRepository.updateLocalOnboardingFlag(
+ isComplete = isComplete,
+ onError = onError,
+ )
+ }
+}
diff --git a/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/UpdateNicknameUseCaseImpl.kt b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/UpdateNicknameUseCaseImpl.kt
new file mode 100644
index 00000000..22a3017f
--- /dev/null
+++ b/domain/src/main/java/com/teambrake/brake/domain/usecaseImpl/UpdateNicknameUseCaseImpl.kt
@@ -0,0 +1,42 @@
+package com.teambrake.brake.domain.usecaseImpl
+
+import com.teambrake.brake.core.model.user.UserStatus
+import com.teambrake.brake.domain.repository.TokenRepository
+import com.teambrake.brake.domain.repository.NicknameRepository
+import com.teambrake.brake.domain.usecase.UpdateNicknameUseCase
+import javax.inject.Inject
+import javax.inject.Named
+
+class UpdateNicknameUseCaseImpl @Inject constructor(
+ @Named("NicknameRepo") private val nicknameRepository: NicknameRepository,
+ @Named("TokenRepo") private val tokenRepository: TokenRepository,
+) : UpdateNicknameUseCase {
+
+ override suspend fun invoke(
+ nickname: String,
+ onError: suspend (Throwable) -> Unit,
+ onSuccess: suspend () -> Unit,
+ ) {
+ // AccessToken을 사용하여 닉네임 업데이트, 로컬에 닉네임 저장
+ nicknameRepository.updateUserName(
+ nickname = nickname,
+ onError = onError,
+ ).collect {
+ when (it.state) {
+ // 닉네임 업데이트 성공 시
+ UserStatus.ACTIVE -> {
+ // DataStore에 저장된 authCode 삭제
+ tokenRepository.clearLocalAuthCode(onError = onError)
+ // 닉네임 업데이트 성공 후 콜백 호출
+ onSuccess()
+ }
+
+ // 닉네임 업데이트 실패 시
+ else -> {
+ // 에러 처리
+ onError(Throwable("닉네임 업데이트에 실패했습니다"))
+ }
+ }
+ }
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index 20e2a015..d9aa478d 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx2560m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
@@ -20,4 +20,4 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+android.nonTransitiveRClass=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 78bcff47..f13931f2 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,12 +1,12 @@
[versions]
## SDK version
-compileSdk = "35"
+compileSdk = "36"
minSdk = "28"
targetSdk = "35"
## App version
-versionCode = "1"
-versionName = "0.0.1"
+versionCode = "4"
+versionName = "1.1.8"
## Android gradle plugin
agp = "8.10.1"
@@ -16,17 +16,17 @@ androidDesugarJdkLibs = "2.1.5"
## AndroidX
# https://developer.android.com/jetpack/androidx/releases/core
-androidxCore = "1.16.0"
+androidxCore = "1.17.0"
# https://developer.android.com/jetpack/androidx/releases/appcompat
androidxAppCompat = "1.7.1"
# https://developer.android.com/jetpack/androidx/releases/lifecycle
-androidxLifecycle = "2.9.1"
+androidxLifecycle = "2.9.4"
# https://developer.android.com/jetpack/androidx/releases/activity
-androidxActivity = "1.10.1"
+androidxActivity = "1.11.0"
# https://developer.android.com/jetpack/androidx/releases/datastore
androidxDatastore = "1.1.7"
# https://developer.android.com/jetpack/androidx/releases/room
-room = "2.7.1"
+room = "2.8.3"
# https://developer.android.com/jetpack/androidx/releases/glance
androidxGlance = "1.1.1"
glanceExperimentalTools = "0.2.2"
@@ -34,14 +34,20 @@ glanceExperimentalTools = "0.2.2"
profileinstaller = "1.4.1"
# https://androidx.tech/artifacts/benchmark/benchmark-baseline-profile-gradle-plugin/index.html
baselineprofile = "1.3.4"
+# https://developer.android.com/jetpack/androidx/releases/core#core-splashscreen
+coreSplashscreen = "1.2.0"
+
+## Credentials
+# https://developer.android.com/jetpack/androidx/releases/credentials
+androidxCredentials = "1.5.0"
## Compose
# https://developer.android.com/develop/ui/compose/bom/bom-mapping
-androidxComposeBom = "2025.06.00"
+androidxComposeBom = "2025.11.00"
# https://developer.android.com/jetpack/androidx/releases/navigation
-androidxComposeNavigation = "2.9.0"
+androidxComposeNavigation = "2.9.6"
# https://developer.android.com/jetpack/androidx/releases/compose-material3
-androidxComposeMaterial3 = "1.3.2"
+androidxComposeMaterial3 = "1.4.0"
# https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive
androidxComposeMaterial3Adaptive = "1.1.0" # 다양한 스크린 사이즈 대응
# https://developer.android.com/develop/ui/compose/layouts/constraintlayout?hl=ko
@@ -53,41 +59,66 @@ composeShimmer = "1.3.2" # Compose에서의 로딩 애니메이션 효과
# https://github.com/ehsannarmani/ComposeCharts/releases
composeCharts = "0.1.7" # Compose에서 차트 그리기
+## Google Services
+# https://developers.google.com/android/guides/releases
+googleServices = "4.4.4" # Google Play Services 및 Firebase 통합을 위한 Gradle 플러그인
+
+## Firebase
+# https://firebase.google.com/support/release-notes/android
+# 34.0.0 버전부터 Firebase KTX 라이브러리 없이 DSL 사용 가능
+firebaseBom = "34.5.0" # Firebase BOM (Bill of Materials)
+firebaseCrashlytics = "3.0.6" # Firebase Crashlytics NDK 지원
+
+## Google Auth
+# https://mvnrepository.com/artifact/com.google.android.gms/play-services-auth
+googleAuth = "21.4.0"
+
## Kotlin Symbol Processing
# https://github.com/google/ksp/
-ksp = "2.1.21-2.0.2"
+ksp = "2.2.21-2.0.4"
## Hilt
# https://github.com/google/dagger/releases
-hilt = "2.56.2"
+hilt = "2.57.2"
# https://developer.android.com/jetpack/androidx/releases/hilt
-hiltNavigationCompose = "1.2.0"
+hiltNavigationCompose = "1.3.0"
+
+# WorkManager
+# https://developer.android.com/jetpack/androidx/releases/work
+work-ktx = "2.11.0"
+# https://developer.android.com/jetpack/androidx/releases/hilt
+work-hilt = "1.3.0"
+androidxHilt = "1.3.0" # androidx.hilt 라이브러리 버전
## Network
# okhttp
# # https://square.github.io/okhttp/
-okhttp = "4.12.0"
+okhttp = "5.3.0"
# Retrofit
# # https://github.com/square/retrofit
retrofit = "3.0.0"
+## Auth
+# https://developers.kakao.com/docs/latest/ko/android/download#latest
+kakao = "2.22.0"
+
## Landscapist
# https://github.com/skydoves/landscapist
-landscapist = "2.4.6" # 이미지 로딩을 위한 Compose 전용 라이브러리
+landscapist = "2.6.1" # 이미지 로딩을 위한 Compose 전용 라이브러리
## Kotlin
# https://github.com/JetBrains/kotlin
-kotlin = "2.1.21"
+kotlin = "2.2.21"
# https://github.com/Kotlin/kotlinx.serialization
-kotlinxSerializationJson = "1.8.1"
+kotlinxSerializationJson = "1.9.0"
# https://github.com/Kotlin/kotlinx-datetime/releases
-kotlinxDatetime = "0.6.2"
+kotlinxDatetime = "0.7.1"
# https://github.com/Kotlin/kotlinx.collections.immutable
kotlinxImmutable = "0.4.0"
## Ktlint
# https://github.com/JLLeitschuh/ktlint-gradle/releases
-ktlint = "12.2.0"
+ktlint = "13.1.0"
## Coroutine
# https://github.com/cashapp/turbine
@@ -99,12 +130,21 @@ coroutine = "1.10.2"
# 라이선스 정보 표시
# https://developers.google.com/android/guides/opensource
ossLicenses = "17.1.0"
-ossLicensesPlugin = "0.10.6"
+ossLicensesPlugin = "0.10.9"
## Timber
# https://github.com/JakeWharton/timber/releases
timber = "5.0.1"
+## Sandwich
+# https://github.com/skydoves/sandwich/releases
+sandwich = "2.1.3" # 네트워크 응답을 처리하기 위한 라이브러리
+
+## Browser
+# https://developer.android.com/jetpack/androidx/releases/browser
+# 2025/08/01 기준 1.9.0 버전은 API 36만 지원
+androidxBrowser = "1.9.0" # WebView 대신 Chrome Custom Tabs 사용
+
## Test
# https://github.com/junit-team/junit4
junit4 = "4.13.2"
@@ -129,10 +169,12 @@ robolectric = "4.14.1" # JVM 환경에서 Android 테스트 실행 가능하게
# https://kotest.io/
kotest = "5.9.0" # 다양한 스타일의 테스트를 위한 Kotlin 테스트 프레임워크
# https://github.com/detekt/detekt
-detekt = "1.23.7" # Kotlin 정적 분석 툴
+detekt = "1.23.8" # Kotlin 정적 분석 툴
# https://mockk.io/
mockk = "1.13.16"
material = "1.12.0"
+accompanistPermissions = "0.37.3"
+snapper = "0.3.0"
[libraries]
# Gradle
@@ -161,6 +203,10 @@ androidx-compose-material-icon = { module = "androidx.compose.material:material-
androidx-constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "androidxComposeConstraintlayout" }
compose-compiler-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }
+# Credentials
+androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "androidxCredentials" }
+androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "androidxCredentials" }
+
# Design
compose-charts = { module = "io.github.ehsannarmani:compose-charts", version.ref = "composeCharts" }
compose-shimmer = { group = "com.valentinilk.shimmer", name = "compose-shimmer", version.ref = "composeShimmer" }
@@ -173,6 +219,19 @@ room-compiler ={module="androidx.room:room-compiler",version.ref = "room" }
# DataStore
datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastore" }
+# WorkManager
+work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work-ktx" }
+work-hilt = { group = "androidx.hilt", name = "hilt-work", version.ref = "work-hilt" }
+androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" }
+
+# Firebase
+firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
+firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
+firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ndk" }
+
+# Google Auth
+google-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "googleAuth" }
+
# Hilt
hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
@@ -196,6 +255,9 @@ landscapist-coil = { group = "com.github.skydoves", name = "landscapist-coil", v
landscapist-placeholder = { group = "com.github.skydoves", name = "landscapist-placeholder" }
landscapist-animation = { group = "com.github.skydoves", name = "landscapist-animation", version.ref = "landscapist" }
+# SplashScreen
+core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }
+
# Test
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
@@ -226,6 +288,7 @@ coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
# plugin
+kakao-user = { module = "com.kakao.sdk:v2-user", version.ref = "kakao" }
verify-detektPlugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
oss-licenses-plugin = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "ossLicensesPlugin" }
@@ -234,10 +297,22 @@ androidx-glance = { group = "androidx.glance", name = "glance", version.ref = "a
androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "androidxGlance" }
glance-tools-appwidget-host = { group = "com.google.android.glance.tools", name = "appwidget-host", version.ref = "glanceExperimentalTools"}
+# Sandwich
+sandwich-retrofit = { group = "com.github.skydoves", name = "sandwich-retrofit", version.ref = "sandwich" }
+
+# Chrome Custom Tabs
+androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" }
+
# verify
verify-detektFormatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+# permissions
+accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
+
+# Snapper
+snapper = { module = "dev.chrisbanes.snapper:snapper", version.ref = "snapper" }
+
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
@@ -251,15 +326,18 @@ ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
verify-detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-test = { id = "com.android.test", version.ref = "agp" }
+google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
+firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlytics" }
baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" }
roborazzi-plugin = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
# Custom Plugins
-breake-android-application = { id = "breake.android.application"}
-breake-android-compose = { id = "breake.android.compose"}
-breake-android-feature = { id = "breake.android.feature"}
-breake-android-library = { id = "breake.android.library"}
-breake-kotlin-library = { id = "breake.kotlin.library"}
-breake-android-room = { id = "breake.android.room"}
-breake-android-hilt = { id = "breake.android.hilt"}
-breake-kotlin-hilt = { id = "breake.kotlin.hilt"}
+brake-android-application = { id = "brake.android.application"}
+brake-android-compose = { id = "brake.android.compose"}
+brake-android-feature = { id = "brake.android.feature"}
+brake-android-library = { id = "brake.android.library"}
+brake-kotlin-library = { id = "brake.kotlin.library"}
+brake-android-room = { id = "brake.android.room"}
+brake-android-hilt = { id = "brake.android.hilt"}
+brake-work-hilt = { id = "brake.work.hilt"}
+brake-kotlin-hilt = { id = "brake.kotlin.hilt"}
diff --git a/overlay/blocking/.gitignore b/overlay/blocking/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/overlay/blocking/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/overlay/blocking/build.gradle.kts b/overlay/blocking/build.gradle.kts
new file mode 100644
index 00000000..d0c4456c
--- /dev/null
+++ b/overlay/blocking/build.gradle.kts
@@ -0,0 +1,16 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.feature)
+}
+
+android {
+ setNamespace("overlay.blocking")
+}
+
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.util)
+
+ implementation(projects.overlay.ui)
+}
diff --git a/core/model/src/main/java/com/yapp/breake/core/model/.gitkeep b/overlay/blocking/consumer-rules.pro
similarity index 100%
rename from core/model/src/main/java/com/yapp/breake/core/model/.gitkeep
rename to overlay/blocking/consumer-rules.pro
diff --git a/overlay/blocking/proguard-rules.pro b/overlay/blocking/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/overlay/blocking/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/overlay/blocking/src/main/AndroidManifest.xml b/overlay/blocking/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..76073216
--- /dev/null
+++ b/overlay/blocking/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/overlay/blocking/src/main/java/com/teambrake/brake/overlay/blocking/BlockingScreen.kt b/overlay/blocking/src/main/java/com/teambrake/brake/overlay/blocking/BlockingScreen.kt
new file mode 100644
index 00000000..308c1be0
--- /dev/null
+++ b/overlay/blocking/src/main/java/com/teambrake/brake/overlay/blocking/BlockingScreen.kt
@@ -0,0 +1,63 @@
+package com.teambrake.brake.overlay.blocking
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.teambrake.brake.core.common.Constants
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.util.addJosaEulReul
+import com.teambrake.brake.overlay.ui.OverlayBase
+import com.teambrake.brake.overlay.ui.R as UiRes
+
+@Composable
+fun BlockingOverlay(
+ appName: String,
+ groupName: String,
+ onStartHome: () -> Unit,
+ onExitManageApp: () -> Unit,
+) {
+ BlockingScreen(
+ appName = appName,
+ groupName = groupName,
+ onStartHome = onStartHome,
+ onExitManageApp = onExitManageApp,
+ )
+}
+
+@Composable
+private fun BlockingScreen(
+ appName: String,
+ groupName: String,
+ onStartHome: () -> Unit,
+ onExitManageApp: () -> Unit,
+) {
+ OverlayBase(
+ imageRes = UiRes.drawable.img_cooldown,
+ title = stringResource(
+ id = UiRes.string.blocking_title,
+ appName.addJosaEulReul(),
+ ),
+ buttonText = stringResource(id = UiRes.string.btn_check_time),
+ onButtonClick = onStartHome,
+ textButtonText = stringResource(id = UiRes.string.btn_exit),
+ onTextButtonClick = onExitManageApp,
+ contentDescriptionRes = stringResource(
+ UiRes.string.blocking_description,
+ Constants.SNOOZE_MINUTES,
+ groupName,
+ ),
+ )
+}
+
+@Preview
+@Composable
+private fun BlockingScreenPreview() {
+ BrakeTheme {
+ BlockingScreen(
+ appName = "Instagram",
+ groupName = "SNS",
+ onStartHome = {},
+ onExitManageApp = {},
+ )
+ }
+}
diff --git a/overlay/main/.gitignore b/overlay/main/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/overlay/main/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/overlay/main/build.gradle.kts b/overlay/main/build.gradle.kts
new file mode 100644
index 00000000..c600a722
--- /dev/null
+++ b/overlay/main/build.gradle.kts
@@ -0,0 +1,22 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.feature)
+}
+
+android {
+ setNamespace("overlay.main")
+}
+
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.util)
+
+ implementation(projects.overlay.timer)
+ implementation(projects.overlay.snooze)
+ implementation(projects.overlay.blocking)
+
+ implementation(libs.accompanist.permissions)
+ implementation(libs.androidx.lifecycle.runtimeCompose)
+
+}
diff --git a/core/navigation/src/main/java/com/yapp/breake/core/navigation/.gitkeep b/overlay/main/consumer-rules.pro
similarity index 100%
rename from core/navigation/src/main/java/com/yapp/breake/core/navigation/.gitkeep
rename to overlay/main/consumer-rules.pro
diff --git a/overlay/main/proguard-rules.pro b/overlay/main/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/overlay/main/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/overlay/main/src/main/AndroidManifest.xml b/overlay/main/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..dee97cdf
--- /dev/null
+++ b/overlay/main/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/overlay/main/src/main/java/com/teambrake/brake/overlay/main/OverlayActivity.kt b/overlay/main/src/main/java/com/teambrake/brake/overlay/main/OverlayActivity.kt
new file mode 100644
index 00000000..db1ad1b1
--- /dev/null
+++ b/overlay/main/src/main/java/com/teambrake/brake/overlay/main/OverlayActivity.kt
@@ -0,0 +1,198 @@
+package com.teambrake.brake.overlay.main
+
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.teambrake.brake.core.common.BlockingConstants
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.core.util.OverlayData
+import com.teambrake.brake.overlay.blocking.BlockingOverlay
+import com.teambrake.brake.overlay.main.utils.OverlayViewHolder
+import com.teambrake.brake.overlay.snooze.SnoozeRoute
+import com.teambrake.brake.overlay.timer.TimerRoute
+import dagger.hilt.android.AndroidEntryPoint
+import timber.log.Timber
+
+@AndroidEntryPoint
+class OverlayActivity : ComponentActivity() {
+
+ private val overlayViewHolder by lazy { OverlayViewHolder(this) }
+
+ /**
+ * Back 버튼을 눌렀을 때, 해당 액티비티 즉각 종료
+ *
+ * Timer
+ */
+ private val callback = object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ onExitManageApp()
+ }
+ }
+
+ private val closeReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == BlockingConstants.ACTION_CLOSE_OVERLAY) {
+ Timber.d("종료 신호를 받았습니다. OverlayActivity를 종료합니다.")
+ removeOverlay()
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ Timber.d("OverlayActivity onCreate called")
+
+ val filter = IntentFilter(BlockingConstants.ACTION_CLOSE_OVERLAY)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(closeReceiver, filter, RECEIVER_NOT_EXPORTED)
+ } else {
+ @SuppressLint("UnspecifiedRegisterReceiverFlag")
+ registerReceiver(closeReceiver, filter)
+ }
+
+ onBackPressedDispatcher.addCallback(this, callback)
+ showOverlay(intent.action)
+ }
+
+ private fun showOverlay(action: String?) {
+ if (action == null) {
+ Timber.w("Action is null, cannot show overlay.")
+ return
+ }
+
+ val overlayData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent?.getParcelableExtra(
+ BlockingConstants.EXTRA_OVERLAY_DATA,
+ OverlayData::class.java,
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ intent?.getParcelableExtra(BlockingConstants.EXTRA_OVERLAY_DATA)
+ }
+
+ Timber.d("showOverlay called with overlayData: $overlayData")
+ when (action) {
+ BlockingConstants.ACTION_SHOW_OVERLAY -> {
+ overlayViewHolder.show(this) {
+ OverlayScreens(overlayData)
+ }
+ }
+
+ else -> Timber.w("Unknown action: $action")
+ }
+ }
+
+ @Composable
+ private fun OverlayScreens(overlayData: OverlayData?) {
+ if (overlayData == null) {
+ Timber.w("OverlayData is null, cannot display overlay.")
+ return
+ }
+
+ BrakeTheme {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ ) {
+ when (overlayData.appGroupState) {
+ AppGroupState.NeedSetting -> {
+ TimerRoute(
+ appName = overlayData.appName,
+ groupName = overlayData.groupName,
+ groupId = overlayData.groupId,
+ onExitManageApp = ::onExitManageApp,
+ onCloseOverlay = ::removeOverlay,
+ )
+ }
+
+ AppGroupState.SnoozeBlocking -> {
+ SnoozeRoute(
+ groupId = overlayData.groupId,
+ groupName = overlayData.groupName,
+ snoozesCount = overlayData.snoozesCount,
+ onCloseOverlay = ::removeOverlay,
+ onStartHome = ::onStartHome,
+ onExitManageApp = ::onExitManageApp,
+ )
+ }
+
+ AppGroupState.Blocking -> {
+ BlockingOverlay(
+ appName = overlayData.appName,
+ groupName = overlayData.groupName,
+ onStartHome = ::onStartHome,
+ onExitManageApp = ::onExitManageApp,
+ )
+ }
+
+ AppGroupState.Using -> {}
+ }
+ }
+ }
+ }
+
+ override fun onUserLeaveHint() {
+ super.onUserLeaveHint()
+ Timber.d("onUserLeaveHint called")
+
+ // Recent Apps 버튼, 홈 버튼, Back 버튼을 눌렀을 때, 즉 오버레이 화면을 벗어나면 해당 액티비티 즉각 종료
+ removeOverlay()
+ }
+
+ // 액티비티가 종료될 때 오버레이 뷰 제거
+ override fun onStop() {
+ super.onStop()
+ Timber.d("onStop called")
+ removeOverlay()
+ }
+
+ private fun removeOverlay() {
+ overlayViewHolder.remove()
+ finishAndRemoveTask()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ Timber.d("onNewIntent called with action: ${intent.action}")
+ setIntent(intent)
+ showOverlay(intent.action)
+ }
+
+ private fun onStartHome() {
+ val homeIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ homeIntent?.let { startActivity(it) }
+ finish()
+ }
+
+ private fun onExitManageApp() {
+ val homeIntent = Intent(Intent.ACTION_MAIN).apply {
+ addCategory(Intent.CATEGORY_HOME)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ removeOverlay()
+ startActivity(homeIntent)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ unregisterReceiver(closeReceiver)
+ }
+}
diff --git a/overlay/main/src/main/java/com/teambrake/brake/overlay/main/OverlayViewModel.kt b/overlay/main/src/main/java/com/teambrake/brake/overlay/main/OverlayViewModel.kt
new file mode 100644
index 00000000..d4ed9a7a
--- /dev/null
+++ b/overlay/main/src/main/java/com/teambrake/brake/overlay/main/OverlayViewModel.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.overlay.main
+
+import androidx.lifecycle.ViewModel
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class OverlayViewModel @Inject constructor(
+ private val appGroupRepository: AppGroupRepository,
+) : ViewModel()
diff --git a/overlay/main/src/main/java/com/teambrake/brake/overlay/main/component/PermissionRequestScreen.kt b/overlay/main/src/main/java/com/teambrake/brake/overlay/main/component/PermissionRequestScreen.kt
new file mode 100644
index 00000000..9259b534
--- /dev/null
+++ b/overlay/main/src/main/java/com/teambrake/brake/overlay/main/component/PermissionRequestScreen.kt
@@ -0,0 +1,118 @@
+package com.teambrake.brake.overlay.main.component
+
+import android.os.Build
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.MultiplePermissionsState
+import com.google.accompanist.permissions.PermissionState
+import com.google.accompanist.permissions.PermissionStatus
+import com.teambrake.brake.core.designsystem.component.BaseScaffold
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun PermissionRequestScreen(
+ multiplePermissionsState: MultiplePermissionsState,
+ onRequestSystemAlertWindow: () -> Unit,
+ systemAlertWindowGranted: Boolean,
+ onRequestScheduleExactAlarm: () -> Unit,
+ scheduleExactAlarmGranted: Boolean,
+ onRequestUsageStatsPermission: () -> Unit,
+ usageStatsPermissionGranted: Boolean,
+ onRequestAccessibilityService: () -> Unit,
+ accessibilityServiceEnabled: Boolean,
+ onRequestIgnoreBatteryOptimizations: () -> Unit,
+ batteryOptimizationIgnored: Boolean,
+ onPermissionsUpdated: () -> Unit,
+) {
+ LaunchedEffect(Unit) {
+ onPermissionsUpdated()
+ }
+
+ BaseScaffold {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ "앱을 사용하기 위해 다음 권한이 필요합니다.",
+ style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
+ )
+ Spacer(modifier = Modifier.height(20.dp))
+
+ if (!multiplePermissionsState.allPermissionsGranted) {
+ multiplePermissionsState.permissions.forEach { perm: PermissionState ->
+ if (perm.status != PermissionStatus.Granted) {
+ Button(onClick = { perm.launchPermissionRequest() }) {
+ Text("${perm.permission.substringAfterLast('.')} 권한 요청")
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+ }
+
+ if (!systemAlertWindowGranted) {
+ Button(onClick = onRequestSystemAlertWindow) {
+ Text("다른 앱 위에 표시 권한 요청 (SYSTEM_ALERT_WINDOW)")
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !scheduleExactAlarmGranted) {
+ Button(onClick = onRequestScheduleExactAlarm) {
+ Text("정확한 알람 설정 권한 요청 (SCHEDULE_EXACT_ALARM)")
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ if (!usageStatsPermissionGranted) {
+ Button(onClick = onRequestUsageStatsPermission) {
+ Text("사용 정보 접근 권한 요청 (PACKAGE_USAGE_STATS)")
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ if (!accessibilityServiceEnabled) {
+ Button(onClick = onRequestAccessibilityService) {
+ Text("접근성 서비스 활성화 요청")
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ if (!batteryOptimizationIgnored) {
+ Button(onClick = onRequestIgnoreBatteryOptimizations) {
+ Text("배터리 최적화 예외 요청 (IGNORE_BATTERY_OPTIMIZATIONS)")
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = onPermissionsUpdated) {
+ Text("권한 상태 새로고침")
+ }
+
+ if (multiplePermissionsState.allPermissionsGranted &&
+ systemAlertWindowGranted &&
+ scheduleExactAlarmGranted &&
+ accessibilityServiceEnabled &&
+ batteryOptimizationIgnored
+ ) {
+ Text("모든 필수 권한이 허용되었습니다. 앱을 다시 시작하거나 이 화면이 자동으로 닫힙니다.")
+ }
+ }
+ }
+}
diff --git a/overlay/main/src/main/java/com/teambrake/brake/overlay/main/utils/OverlayViewHolder.kt b/overlay/main/src/main/java/com/teambrake/brake/overlay/main/utils/OverlayViewHolder.kt
new file mode 100644
index 00000000..49852156
--- /dev/null
+++ b/overlay/main/src/main/java/com/teambrake/brake/overlay/main/utils/OverlayViewHolder.kt
@@ -0,0 +1,146 @@
+package com.teambrake.brake.overlay.main.utils
+
+import android.content.Context
+import android.graphics.PixelFormat
+import android.os.Build
+import android.os.Bundle
+import android.view.Gravity
+import android.view.WindowManager
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import timber.log.Timber
+
+class OverlayViewHolder(private val context: Context) {
+ var view: ComposeView? = null
+ var lifecycleManager: OverlayLifecycleManager? = null
+
+ fun show(
+ viewModelStoreOwner: ViewModelStoreOwner,
+ content: @Composable () -> Unit,
+ ) {
+ val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+
+ if (this.view != null) {
+ return
+ }
+
+ val lifecycleManager = OverlayLifecycleManager()
+ this.lifecycleManager = lifecycleManager
+ lifecycleManager.performCreate(null)
+
+ val composeView = ComposeView(context).apply {
+ setViewTreeLifecycleOwner(lifecycleManager)
+ setViewTreeSavedStateRegistryOwner(lifecycleManager)
+ setViewTreeViewModelStoreOwner(lifecycleManager)
+
+ setContent {
+ CompositionLocalProvider(
+ LocalViewModelStoreOwner provides viewModelStoreOwner,
+ ) {
+ content()
+ }
+ }
+ }
+ this.view = composeView
+
+ val params = WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
+ PixelFormat.TRANSLUCENT,
+ ).apply {
+ gravity = Gravity.CENTER
+ softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ fitInsetsTypes = 0
+ } else {
+ // API 30 미만에서는 deprecated 플래그 사용
+ @Suppress("DEPRECATION")
+ flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR
+ }
+
+ layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
+
+ try {
+ windowManager.addView(composeView, params)
+ lifecycleManager.handleLifecycleEvent(Lifecycle.Event.ON_START)
+ lifecycleManager.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ this.view = null
+ this.lifecycleManager?.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ this.lifecycleManager?.clearViewModelStore()
+ this.lifecycleManager = null
+ }
+ }
+
+ fun remove() {
+ this.view?.let { composeView ->
+ try {
+ val windowManager = context.getSystemService(
+ Context.WINDOW_SERVICE,
+ ) as WindowManager
+ windowManager.removeView(composeView)
+ this.lifecycleManager?.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+ this.lifecycleManager?.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
+ this.lifecycleManager?.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ this.lifecycleManager?.clearViewModelStore()
+ composeView.disposeComposition()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ } finally {
+ this.view = null
+ this.lifecycleManager = null
+ }
+ }
+ }
+}
+
+class OverlayLifecycleManager :
+ LifecycleOwner,
+ SavedStateRegistryOwner,
+ ViewModelStoreOwner {
+ private val lifecycleRegistry = LifecycleRegistry(this)
+ private val savedStateRegistryController = SavedStateRegistryController.create(this)
+ private val _viewModelStore = ViewModelStore()
+
+ override val lifecycle: Lifecycle
+ get() = lifecycleRegistry
+
+ override val savedStateRegistry: SavedStateRegistry
+ get() = savedStateRegistryController.savedStateRegistry
+
+ override val viewModelStore: ViewModelStore
+ get() = _viewModelStore
+
+ fun performCreate(savedState: Bundle?) {
+ savedStateRegistryController.performRestore(savedState)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
+ }
+
+ fun handleLifecycleEvent(event: Lifecycle.Event) {
+ Timber.d("OverlayLifecycleManager - handleLifecycleEvent: $event")
+ lifecycleRegistry.handleLifecycleEvent(event)
+ }
+
+ fun clearViewModelStore() {
+ _viewModelStore.clear()
+ }
+}
diff --git a/overlay/snooze/.gitignore b/overlay/snooze/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/overlay/snooze/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/overlay/snooze/build.gradle.kts b/overlay/snooze/build.gradle.kts
new file mode 100644
index 00000000..d75ea49c
--- /dev/null
+++ b/overlay/snooze/build.gradle.kts
@@ -0,0 +1,16 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.feature)
+}
+
+android {
+ setNamespace("snooze")
+}
+
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.util)
+
+ implementation(projects.overlay.ui)
+}
diff --git a/core/testing/src/main/java/com/yapp/breake/core/testing/.gitkeep b/overlay/snooze/consumer-rules.pro
similarity index 100%
rename from core/testing/src/main/java/com/yapp/breake/core/testing/.gitkeep
rename to overlay/snooze/consumer-rules.pro
diff --git a/overlay/snooze/proguard-rules.pro b/overlay/snooze/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/overlay/snooze/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/overlay/snooze/src/main/AndroidManifest.xml b/overlay/snooze/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..76073216
--- /dev/null
+++ b/overlay/snooze/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/SnoozeRoute.kt b/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/SnoozeRoute.kt
new file mode 100644
index 00000000..3e016f9b
--- /dev/null
+++ b/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/SnoozeRoute.kt
@@ -0,0 +1,65 @@
+package com.teambrake.brake.overlay.snooze
+
+import android.widget.Toast
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.teambrake.brake.core.common.Constants
+import com.teambrake.brake.overlay.snooze.component.SnoozeBlocking
+import com.teambrake.brake.overlay.snooze.component.SnoozeScreen
+
+@Composable
+fun SnoozeRoute(
+ groupId: Long,
+ groupName: String,
+ snoozesCount: Int,
+ onCloseOverlay: () -> Unit,
+ onStartHome: () -> Unit,
+ onExitManageApp: () -> Unit,
+) {
+ SnoozeOverlay(
+ groupId = groupId,
+ groupName = groupName,
+ snoozesCount = snoozesCount,
+ onCloseOverlay = onCloseOverlay,
+ onStartHome = onStartHome,
+ onExitManageApp = onExitManageApp,
+ )
+}
+
+@Composable
+private fun SnoozeOverlay(
+ groupId: Long,
+ groupName: String,
+ snoozesCount: Int,
+ onCloseOverlay: () -> Unit,
+ onStartHome: () -> Unit,
+ onExitManageApp: () -> Unit,
+ viewModel: SnoozeViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+
+ if (snoozesCount < Constants.MAX_SNOOZE_COUNT) {
+ SnoozeScreen(
+ snoozeCount = snoozesCount,
+ onExitManageApp = onExitManageApp,
+ onSnooze = {
+ viewModel.setSnooze(groupId, groupName)
+ onCloseOverlay()
+ },
+ )
+ } else {
+ SnoozeBlocking(
+ groupName = groupName,
+ onExitManageApp = onExitManageApp,
+ onStartHome = onStartHome,
+ )
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.toastEffect.collect { message ->
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+}
diff --git a/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/SnoozeViewModel.kt b/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/SnoozeViewModel.kt
new file mode 100644
index 00000000..79be01e4
--- /dev/null
+++ b/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/SnoozeViewModel.kt
@@ -0,0 +1,37 @@
+package com.teambrake.brake.overlay.snooze
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.teambrake.brake.domain.usecase.SetSnoozeAlarmUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+internal class SnoozeViewModel @Inject constructor(
+ private val setSnoozeAlarmUsecase: SetSnoozeAlarmUseCase,
+) : ViewModel() {
+
+ private val _toastEffect: MutableSharedFlow = MutableSharedFlow()
+ val toastEffect: SharedFlow get() = _toastEffect
+
+ fun setSnooze(groupId: Long, groupName: String) {
+ viewModelScope.launch {
+ setSnoozeAlarmUsecase(
+ groupId = groupId,
+ groupName = groupName,
+ ).onSuccess {
+ }.onFailure {
+ sendToastMessage("알람 설정에 실패했습니다. 정확한 알람 권한을 확인해주세요.")
+ }
+ }
+ }
+
+ private fun sendToastMessage(message: String) {
+ viewModelScope.launch {
+ _toastEffect.emit(message)
+ }
+ }
+}
diff --git a/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/component/SnoozeBlockingScreen.kt b/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/component/SnoozeBlockingScreen.kt
new file mode 100644
index 00000000..9e668fcc
--- /dev/null
+++ b/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/component/SnoozeBlockingScreen.kt
@@ -0,0 +1,44 @@
+package com.teambrake.brake.overlay.snooze.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.teambrake.brake.core.common.Constants
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.overlay.ui.OverlayBase
+import com.teambrake.brake.overlay.ui.R as UiRes
+
+@Composable
+internal fun SnoozeBlocking(
+ groupName: String,
+ onExitManageApp: () -> Unit,
+ onStartHome: () -> Unit,
+) {
+ OverlayBase(
+ imageRes = UiRes.drawable.img_cooldown,
+ title = stringResource(
+ UiRes.string.snooze_blocking_title,
+ Constants.SNOOZE_MINUTES,
+ groupName,
+ ),
+ buttonText = stringResource(id = UiRes.string.btn_check_time),
+ onButtonClick = onStartHome,
+ textButtonText = stringResource(id = UiRes.string.btn_exit),
+ onTextButtonClick = onExitManageApp,
+ contentDescriptionRes = stringResource(
+ id = UiRes.string.snooze_blocking_description,
+ ),
+ )
+}
+
+@Preview
+@Composable
+private fun SnoozeBlockingPreview() {
+ BrakeTheme {
+ SnoozeBlocking(
+ groupName = "SNS",
+ onExitManageApp = { /* Do nothing */ },
+ onStartHome = { /* Do nothing */ },
+ )
+ }
+}
diff --git a/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/component/SnoozeScreen.kt b/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/component/SnoozeScreen.kt
new file mode 100644
index 00000000..16de2b00
--- /dev/null
+++ b/overlay/snooze/src/main/java/com/teambrake/brake/overlay/snooze/component/SnoozeScreen.kt
@@ -0,0 +1,41 @@
+package com.teambrake.brake.overlay.snooze.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.teambrake.brake.core.common.Constants
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.overlay.ui.OverlayBase
+import com.teambrake.brake.overlay.ui.R as UiRes
+
+@Composable
+internal fun SnoozeScreen(
+ snoozeCount: Int,
+ onExitManageApp: () -> Unit,
+ onSnooze: () -> Unit,
+) {
+ OverlayBase(
+ imageRes = UiRes.drawable.img_blocking,
+ title = stringResource(UiRes.string.snooze_title),
+ buttonText = stringResource(id = UiRes.string.btn_exit),
+ onButtonClick = onExitManageApp,
+ textButtonText = stringResource(
+ id = UiRes.string.btn_more_time,
+ snoozeCount,
+ Constants.MAX_SNOOZE_COUNT,
+ ),
+ onTextButtonClick = onSnooze,
+ )
+}
+
+@Preview
+@Composable
+private fun SnoozeScreenPreview() {
+ BrakeTheme {
+ SnoozeScreen(
+ snoozeCount = 2,
+ onExitManageApp = { /* Do nothing */ },
+ onSnooze = { /* Do nothing */ },
+ )
+ }
+}
diff --git a/overlay/timer/.gitignore b/overlay/timer/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/overlay/timer/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/overlay/timer/build.gradle.kts b/overlay/timer/build.gradle.kts
new file mode 100644
index 00000000..546e53dc
--- /dev/null
+++ b/overlay/timer/build.gradle.kts
@@ -0,0 +1,16 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.feature)
+}
+
+android {
+ setNamespace("overlay.timer")
+}
+
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.util)
+ implementation(projects.overlay.ui)
+ implementation(libs.snapper)
+}
diff --git a/data/src/main/java/com/yapp/breake/data/.gitkeep b/overlay/timer/consumer-rules.pro
similarity index 100%
rename from data/src/main/java/com/yapp/breake/data/.gitkeep
rename to overlay/timer/consumer-rules.pro
diff --git a/overlay/timer/proguard-rules.pro b/overlay/timer/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/overlay/timer/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/overlay/timer/src/main/AndroidManifest.xml b/overlay/timer/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..76073216
--- /dev/null
+++ b/overlay/timer/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/TimerRoute.kt b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/TimerRoute.kt
new file mode 100644
index 00000000..e384710a
--- /dev/null
+++ b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/TimerRoute.kt
@@ -0,0 +1,107 @@
+package com.teambrake.brake.overlay.timer
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.teambrake.brake.overlay.timer.component.InitScreen
+import com.teambrake.brake.overlay.timer.component.SetCompleteScreen
+
+@Composable
+fun TimerRoute(
+ appName: String,
+ groupName: String,
+ groupId: Long,
+ onExitManageApp: () -> Unit,
+ onCloseOverlay: () -> Unit,
+) {
+ TimerOverlay(
+ appName = appName,
+ groupName = groupName,
+ groupId = groupId,
+ onExitManageApp = onExitManageApp,
+ onCloseOverlay = onCloseOverlay,
+ )
+}
+
+@Composable
+private fun TimerOverlay(
+ appName: String,
+ groupName: String,
+ groupId: Long,
+ onExitManageApp: () -> Unit,
+ onCloseOverlay: () -> Unit,
+ viewModel: TimerViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val timerUiState by viewModel.timerUiState.collectAsStateWithLifecycle()
+
+ TimerContent(
+ appName = appName,
+ onSetTime = viewModel::setTime,
+ onConfirm = viewModel::initTimeSetting,
+ onCloseOverlay = onCloseOverlay,
+ onExitManageApp = onExitManageApp,
+ onTimerConfirm = {
+ viewModel.setBreakTimeAlarm(groupId, groupName)
+ },
+ onBackPressToInit = viewModel::resetToInitialState,
+ timerUiState = timerUiState,
+ )
+
+ LaunchedEffect(Unit) {
+ viewModel.toastEffect.collect { message ->
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+}
+
+@Composable
+private fun TimerContent(
+ appName: String,
+ onSetTime: (Int) -> Unit,
+ onTimerConfirm: () -> Unit,
+ onConfirm: () -> Unit,
+ onCloseOverlay: () -> Unit,
+ onExitManageApp: () -> Unit,
+ onBackPressToInit: () -> Unit,
+ timerUiState: TimerUiState,
+) {
+ when (timerUiState) {
+ TimerUiState.Init -> {
+ InitScreen(
+ appName = appName,
+ onConfirm = onConfirm,
+ onExitManageApp = onExitManageApp,
+ )
+ }
+
+ is TimerUiState.TimeSetting -> {
+ BackHandler {
+ onBackPressToInit()
+ }
+
+ TimerScreen(
+ appName = appName,
+ onTimeChange = onSetTime,
+ onComplete = onTimerConfirm,
+ )
+ }
+
+ is TimerUiState.SetComplete -> {
+ BackHandler {
+ onCloseOverlay()
+ }
+
+ SetCompleteScreen(
+ durationMinutes = timerUiState.durationMinutes,
+ endTime = timerUiState.endTime,
+ onCloseOverlay = onCloseOverlay,
+ )
+ }
+ }
+}
diff --git a/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/TimerScreen.kt b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/TimerScreen.kt
new file mode 100644
index 00000000..aba663d4
--- /dev/null
+++ b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/TimerScreen.kt
@@ -0,0 +1,118 @@
+package com.teambrake.brake.overlay.timer
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.GradientScaffold
+import com.teambrake.brake.core.designsystem.component.LargeButton
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.LinerGradient
+import com.teambrake.brake.core.util.addJosaEulReul
+import com.teambrake.brake.overlay.timer.component.TimePicker
+import timber.log.Timber
+import com.teambrake.brake.overlay.ui.R as UiRes
+
+@Composable
+internal fun TimerScreen(
+ appName: String,
+ onTimeChange: (Int) -> Unit,
+ onComplete: () -> Unit,
+) {
+ var isScrolling by remember { mutableStateOf(false) }
+
+ GradientScaffold(
+ gradient = LinerGradient,
+ bottomBar = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ LargeButton(
+ text = stringResource(id = UiRes.string.btn_complete),
+ onClick = onComplete,
+ enabled = !isScrolling,
+ modifier = Modifier
+ .padding(horizontal = 16.dp),
+ )
+ VerticalSpacer(28.dp)
+ }
+ },
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ VerticalSpacer(80.dp)
+ Text(
+ text = stringResource(id = UiRes.string.timer_title, appName.addJosaEulReul()),
+ style = BrakeTheme.typography.subtitle24SB,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(16.dp)
+// Text(
+// text = AnnotatedString.fromHtml(
+// stringResource(
+// id = UiRes.string.timer_content,
+// LocalDateTime.now().toLocalizedTime(),
+// ),
+// ),
+// style = BrakeTheme.typography.body16M,
+// textAlign = TextAlign.Center,
+// color = MaterialTheme.colorScheme.onBackground,
+// modifier = Modifier.fillMaxWidth(),
+// )
+ }
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ ) {
+ TimePicker(
+ onSnappedTime = {
+ Timber.d("Snapped time: $it")
+ onTimeChange(it)
+ },
+ onScrollStateChanged = { isScrolling = it },
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun TimerScreenPreview() {
+ BrakeTheme {
+ TimerScreen(
+ appName = "Sample App",
+ onTimeChange = { /* Do nothing */ },
+ onComplete = { /* Do nothing */ },
+ )
+ }
+}
diff --git a/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/TimerViewModel.kt b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/TimerViewModel.kt
new file mode 100644
index 00000000..ecc64998
--- /dev/null
+++ b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/TimerViewModel.kt
@@ -0,0 +1,89 @@
+package com.teambrake.brake.overlay.timer
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.domain.usecase.SetAlarmUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import java.time.LocalDateTime
+import javax.inject.Inject
+
+@HiltViewModel
+internal class TimerViewModel @Inject constructor(
+ private val setAlarmUsecase: SetAlarmUseCase,
+) : ViewModel() {
+
+ private val _timerUiState = MutableStateFlow(TimerUiState.Init)
+ val timerUiState: StateFlow get() = _timerUiState
+
+ private val _toastEffect: MutableSharedFlow = MutableSharedFlow()
+ val toastEffect: SharedFlow get() = _toastEffect
+
+ fun resetToInitialState() {
+ _timerUiState.update {
+ when (it) {
+ is TimerUiState.TimeSetting -> TimerUiState.Init
+ else -> it
+ }
+ }
+ }
+
+ fun setBreakTimeAlarm(groupId: Long, groupName: String) {
+ val uiState = timerUiState.value as? TimerUiState.TimeSetting ?: return
+
+ viewModelScope.launch {
+ setAlarmUsecase(
+ second = uiState.time,
+ groupId = groupId,
+ groupName = groupName,
+ appGroupState = AppGroupState.Using,
+ ).onSuccess {
+ confirmTime(uiState.time, it)
+ }.onFailure {
+ sendToastMessage("알람 설정에 실패했습니다. 정확한 알람 권한을 확인해주세요.")
+ }
+ }
+ }
+
+ fun initTimeSetting() {
+ _timerUiState.update {
+ TimerUiState.TimeSetting(10)
+ }
+ }
+
+ fun setTime(value: Int = 10) {
+ val uiState = timerUiState.value as? TimerUiState.TimeSetting ?: return
+ _timerUiState.update {
+ uiState.copy(
+ time = value,
+ )
+ }
+ }
+
+ private fun confirmTime(durationMinutes: Int, endTime: LocalDateTime) {
+ _timerUiState.update {
+ TimerUiState.SetComplete(durationMinutes, endTime)
+ }
+ }
+
+ private fun sendToastMessage(message: String) {
+ viewModelScope.launch {
+ _toastEffect.emit(message)
+ }
+ }
+}
+
+sealed interface TimerUiState {
+ data object Init : TimerUiState
+ data class TimeSetting(val time: Int) : TimerUiState
+ data class SetComplete(
+ val durationMinutes: Int,
+ val endTime: LocalDateTime,
+ ) : TimerUiState
+}
diff --git a/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/InitScreen.kt b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/InitScreen.kt
new file mode 100644
index 00000000..d1a779f1
--- /dev/null
+++ b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/InitScreen.kt
@@ -0,0 +1,37 @@
+package com.teambrake.brake.overlay.timer.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.util.addJosaEulReul
+import com.teambrake.brake.overlay.ui.OverlayBase
+import com.teambrake.brake.overlay.ui.R as UiRes
+
+@Composable
+internal fun InitScreen(
+ appName: String,
+ onConfirm: () -> Unit,
+ onExitManageApp: () -> Unit,
+) {
+ OverlayBase(
+ imageRes = UiRes.drawable.img_init,
+ title = stringResource(UiRes.string.init_title, appName.addJosaEulReul()),
+ buttonText = stringResource(id = UiRes.string.btn_use),
+ onButtonClick = onConfirm,
+ textButtonText = stringResource(id = UiRes.string.btn_not_use),
+ onTextButtonClick = onExitManageApp,
+ )
+}
+
+@Preview
+@Composable
+private fun InitScreenPreview() {
+ BrakeTheme {
+ InitScreen(
+ appName = "인스타그램",
+ onConfirm = {},
+ onExitManageApp = {},
+ )
+ }
+}
diff --git a/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/SetCompleteScreen.kt b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/SetCompleteScreen.kt
new file mode 100644
index 00000000..4ff68f8d
--- /dev/null
+++ b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/SetCompleteScreen.kt
@@ -0,0 +1,113 @@
+package com.teambrake.brake.overlay.timer.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.teambrake.brake.core.designsystem.component.GradientScaffold
+import com.teambrake.brake.core.designsystem.component.LargeButton
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray400
+import com.teambrake.brake.core.designsystem.theme.LinerGradient
+import com.teambrake.brake.core.util.extensions.toLocalizedTime
+import com.teambrake.brake.overlay.ui.R
+import java.time.LocalDateTime
+import com.teambrake.brake.overlay.ui.R as UiRes
+
+@Composable
+internal fun SetCompleteScreen(
+ durationMinutes: Int,
+ endTime: LocalDateTime,
+ onCloseOverlay: () -> Unit,
+) {
+ GradientScaffold(
+ gradient = LinerGradient,
+ bottomBar = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ LargeButton(
+ text = stringResource(id = UiRes.string.btn_use),
+ onClick = onCloseOverlay,
+ modifier = Modifier
+ .padding(horizontal = 16.dp),
+ )
+ VerticalSpacer(28.dp)
+ }
+ },
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(UiRes.drawable.img_time_set),
+ contentDescription = stringResource(
+ id = R.string.blocking_image_content_description,
+ ),
+ modifier = Modifier
+ .padding(horizontal = 110.dp)
+ .fillMaxWidth()
+ .aspectRatio(1f),
+ )
+ VerticalSpacer(12.dp)
+ Row(
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ Text(
+ text = durationMinutes.toString(),
+ style = BrakeTheme.typography.subtitle24SB,
+ textAlign = TextAlign.Center,
+ fontSize = 64.sp,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ Text(
+ text = stringResource(id = UiRes.string.minute),
+ style = BrakeTheme.typography.subtitle24SB,
+ textAlign = TextAlign.Center,
+ color = Gray400,
+ modifier = Modifier.padding(start = 1.dp, bottom = 12.dp),
+ )
+ }
+ VerticalSpacer(12.dp)
+ Text(
+ text = stringResource(id = UiRes.string.timer_complete_time, endTime.toLocalizedTime()),
+ style = BrakeTheme.typography.subtitle24SB,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun SetCompleteScreenPreview() {
+ BrakeTheme {
+ SetCompleteScreen(
+ durationMinutes = 30,
+ endTime = LocalDateTime.now(),
+ onCloseOverlay = {},
+ )
+ }
+}
diff --git a/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/TextPicker.kt b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/TextPicker.kt
new file mode 100644
index 00000000..1a0cc0d7
--- /dev/null
+++ b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/TextPicker.kt
@@ -0,0 +1,247 @@
+package com.teambrake.brake.overlay.timer.component
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray400
+import com.teambrake.brake.core.designsystem.theme.Gray50
+import com.teambrake.brake.core.designsystem.theme.Gray600
+import com.teambrake.brake.core.designsystem.theme.Gray850
+import com.teambrake.brake.core.designsystem.theme.White
+import dev.chrisbanes.snapper.ExperimentalSnapperApi
+import dev.chrisbanes.snapper.SnapperLayoutInfo
+import dev.chrisbanes.snapper.rememberLazyListSnapperLayoutInfo
+import dev.chrisbanes.snapper.rememberSnapperFlingBehavior
+import kotlinx.collections.immutable.ImmutableList
+import kotlin.math.absoluteValue
+
+@SuppressLint("FrequentlyChangedStateReadInComposition")
+@OptIn(ExperimentalSnapperApi::class)
+@Composable
+internal fun TextPicker(
+ modifier: Modifier = Modifier,
+ texts: ImmutableList,
+ startIndex: Int = Int.MAX_VALUE / 2,
+ count: Int,
+ rowCount: Int,
+ onItemSelected: (String) -> Unit,
+ onScrollStateChanged: (Boolean) -> Unit = {},
+) {
+ val lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = startIndex)
+ val snapperLayoutInfo = rememberLazyListSnapperLayoutInfo(lazyListState = lazyListState)
+ var currentValue by remember { mutableStateOf("") }
+ val isScrollInProgress = lazyListState.isScrollInProgress
+
+ val itemHeight = 80.dp
+ val totalHeight = itemHeight * rowCount
+ val totalWidth = 200.dp
+
+ val colorScheme = BrakeTheme.colorScheme
+
+ LaunchedEffect(isScrollInProgress) {
+ onScrollStateChanged(isScrollInProgress)
+ }
+
+ LaunchedEffect(isScrollInProgress, count) {
+ if (!isScrollInProgress) {
+ val centerItemIndex = snapperLayoutInfo.currentItem?.index
+ if (centerItemIndex != null) {
+ val temp = centerItemIndex % count
+ currentValue = texts[temp]
+ onItemSelected(currentValue)
+ }
+ }
+ }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier.fillMaxWidth(),
+ ) {
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(totalHeight + 16.dp)
+ .padding(horizontal = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier = Modifier
+ .height(itemHeight + 44.dp)
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(Gray850),
+ )
+ Box(
+ modifier = Modifier
+ .height(itemHeight + 12.dp)
+ .width(120.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(colorScheme.onPrimary),
+ )
+ }
+
+ LazyColumn(
+ modifier = Modifier
+ .height(totalHeight)
+ .width(totalWidth),
+ state = lazyListState,
+ contentPadding = PaddingValues(vertical = itemHeight * ((rowCount - 1) / 2)),
+ flingBehavior = rememberSnapperFlingBehavior(
+ lazyListState = lazyListState,
+ ),
+ ) {
+ items(
+ count = Int.MAX_VALUE,
+ ) { index ->
+ val temp = index % count
+
+ Box(
+ modifier = Modifier
+ .height(itemHeight)
+ .width(totalWidth),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = texts[temp],
+ fontSize = calculateAnimatedScale(
+ lazyListState = lazyListState,
+ snapperLayoutInfo = snapperLayoutInfo,
+ index = index,
+ rowCount = rowCount,
+ ).sp,
+ style = BrakeTheme.typography.subtitle22SB,
+ color = calculateAnimatedColor(
+ lazyListState = lazyListState,
+ snapperLayoutInfo = snapperLayoutInfo,
+ index = index,
+ rowCount = rowCount,
+ ),
+ maxLines = 1,
+ )
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(totalHeight),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "분",
+ style = BrakeTheme.typography.body16M,
+ color = Gray50,
+ maxLines = 1,
+ modifier = Modifier.padding(start = 150.dp, top = 40.dp),
+ )
+ }
+
+ }
+}
+
+@OptIn(ExperimentalSnapperApi::class)
+@Composable
+private fun calculateAnimatedScale(
+ lazyListState: LazyListState,
+ snapperLayoutInfo: SnapperLayoutInfo,
+ index: Int,
+ rowCount: Int,
+): Int {
+ val distanceToIndexSnap = snapperLayoutInfo.distanceToIndexSnap(index).absoluteValue
+ val layoutInfo = remember { derivedStateOf { lazyListState.layoutInfo } }.value
+ val viewPortHeight = layoutInfo.viewportSize.height.toFloat()
+ val singleViewPortHeight = viewPortHeight / rowCount
+
+ val normalizedDistance = (distanceToIndexSnap / singleViewPortHeight).coerceIn(0f, 2f)
+
+ val fontSize = when {
+ normalizedDistance <= 0.5f -> {
+ (64 - (normalizedDistance / 0.5f) * 28).toInt()
+ }
+
+ normalizedDistance <= 1.5f -> {
+ (36 - ((normalizedDistance - 0.5f) / 1.0f) * 16).toInt()
+ }
+
+ else -> 20
+ }
+
+ return fontSize.coerceIn(20, 64)
+}
+
+@OptIn(ExperimentalSnapperApi::class)
+@Composable
+private fun calculateAnimatedColor(
+ lazyListState: LazyListState,
+ snapperLayoutInfo: SnapperLayoutInfo,
+ index: Int,
+ rowCount: Int,
+): Color {
+ val distanceToIndexSnap = snapperLayoutInfo.distanceToIndexSnap(index).absoluteValue
+ val layoutInfo = remember { derivedStateOf { lazyListState.layoutInfo } }.value
+ val viewPortHeight = layoutInfo.viewportSize.height.toFloat()
+ val singleViewPortHeight = viewPortHeight / rowCount
+
+ val normalizedDistance = (distanceToIndexSnap / singleViewPortHeight).coerceIn(0f, 2f)
+
+ val centerColor = White
+ val sideColor = Gray400
+ val edgeColor = Gray600
+
+ return when {
+ normalizedDistance <= 0.5f -> {
+ lerp(centerColor, sideColor, normalizedDistance / 0.5f)
+ }
+
+ normalizedDistance <= 1.5f -> {
+ lerp(sideColor, edgeColor, (normalizedDistance - 0.5f) / 1.0f)
+ }
+
+ else -> edgeColor
+ }
+}
+
+@OptIn(ExperimentalSnapperApi::class)
+@Composable
+private fun isCenterItem(
+ lazyListState: LazyListState,
+ snapperLayoutInfo: SnapperLayoutInfo,
+ index: Int,
+ rowCount: Int,
+): Boolean {
+ val distanceToIndexSnap = snapperLayoutInfo.distanceToIndexSnap(index).absoluteValue
+ val layoutInfo = remember { derivedStateOf { lazyListState.layoutInfo } }.value
+ val viewPortHeight = layoutInfo.viewportSize.height.toFloat()
+ val singleViewPortHeight = viewPortHeight / rowCount
+
+ val normalizedDistance = (distanceToIndexSnap / singleViewPortHeight).coerceIn(0f, 2f)
+
+ return normalizedDistance <= 0.3f // 중앙에 가까운 아이템만
+}
diff --git a/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/TimePicker.kt b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/TimePicker.kt
new file mode 100644
index 00000000..191cb8d3
--- /dev/null
+++ b/overlay/timer/src/main/java/com/teambrake/brake/overlay/timer/component/TimePicker.kt
@@ -0,0 +1,76 @@
+package com.teambrake.brake.overlay.timer.component
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import kotlinx.collections.immutable.toImmutableList
+
+@Composable
+fun TimePicker(
+ modifier: Modifier = Modifier,
+ rowCount: Int = 5,
+ onSnappedTime: (snappedTime: Int) -> Unit = {},
+ onScrollStateChanged: (isScrolling: Boolean) -> Unit = {},
+) {
+
+ val minutes = (1..12).map { index ->
+ val value = index * 5
+ Minute(
+ text = value.toString(),
+ value = value,
+ index = index,
+ )
+ }
+
+ Column {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(top = 20.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ modifier = Modifier.padding(vertical = 20.dp),
+ ) {
+ TextPicker(
+ texts = minutes.map { it.text }.toImmutableList(),
+ count = minutes.size,
+ rowCount = rowCount,
+ startIndex = (Int.MAX_VALUE / 2) - 2,
+ onItemSelected = { selectedText ->
+ val selectedMinute = minutes.find { it.text == selectedText }
+ selectedMinute?.let { minute ->
+ onSnappedTime(minute.value)
+ }
+ },
+ onScrollStateChanged = onScrollStateChanged,
+ )
+ }
+ }
+ }
+}
+
+private data class Minute(
+ val text: String,
+ val value: Int,
+ val index: Int,
+)
+
+@Preview
+@Composable
+private fun TimePickerPreview() {
+ BrakeTheme {
+ TimePicker(
+ onSnappedTime = { time ->
+ },
+ )
+ }
+}
diff --git a/overlay/ui/.gitignore b/overlay/ui/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/overlay/ui/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/overlay/ui/build.gradle.kts b/overlay/ui/build.gradle.kts
new file mode 100644
index 00000000..7dfc0d89
--- /dev/null
+++ b/overlay/ui/build.gradle.kts
@@ -0,0 +1,9 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.feature)
+}
+
+android {
+ setNamespace("overlay.ui")
+}
diff --git a/domain/src/main/java/com/yapp/breake/domain/.gitkeep b/overlay/ui/consumer-rules.pro
similarity index 100%
rename from domain/src/main/java/com/yapp/breake/domain/.gitkeep
rename to overlay/ui/consumer-rules.pro
diff --git a/overlay/ui/proguard-rules.pro b/overlay/ui/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/overlay/ui/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/overlay/ui/src/main/AndroidManifest.xml b/overlay/ui/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..76073216
--- /dev/null
+++ b/overlay/ui/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/overlay/ui/src/main/java/com/teambrake/brake/overlay/ui/OverlayBase.kt b/overlay/ui/src/main/java/com/teambrake/brake/overlay/ui/OverlayBase.kt
new file mode 100644
index 00000000..ce7745dc
--- /dev/null
+++ b/overlay/ui/src/main/java/com/teambrake/brake/overlay/ui/OverlayBase.kt
@@ -0,0 +1,143 @@
+package com.teambrake.brake.overlay.ui
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.BaseScaffold
+import com.teambrake.brake.core.designsystem.component.LargeButton
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.core.designsystem.theme.Gray300
+
+@Composable
+fun OverlayBase(
+ @DrawableRes imageRes: Int,
+ title: String,
+ buttonText: String,
+ onButtonClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ textButtonText: String = "",
+ contentDescriptionRes: String? = null,
+ onTextButtonClick: () -> Unit = {},
+) {
+ BaseScaffold(
+ bottomBar = {
+ OverlayButtons(
+ buttonText = buttonText,
+ onButtonClick = onButtonClick,
+ textButtonText = textButtonText,
+ onTextButtonClick = onTextButtonClick,
+ )
+ },
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(imageRes),
+ contentDescription = stringResource(
+ id = R.string.blocking_image_content_description,
+ ),
+ modifier = modifier
+ .size(160.dp),
+ )
+ VerticalSpacer(24.dp)
+ Text(
+ text = title,
+ style = BrakeTheme.typography.subtitle24SB,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(16.dp)
+ if (contentDescriptionRes != null) {
+ Text(
+ text = contentDescriptionRes,
+ style = BrakeTheme.typography.subtitle16SB,
+ textAlign = TextAlign.Center,
+ color = Gray300,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(top = 8.dp),
+ )
+ }
+ VerticalSpacer(30.dp)
+ }
+ }
+}
+
+@Composable
+private fun OverlayButtons(
+ buttonText: String,
+ onButtonClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ textButtonText: String = "",
+ onTextButtonClick: () -> Unit = {},
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ LargeButton(
+ text = buttonText,
+ onClick = onButtonClick,
+ modifier = Modifier
+ .padding(horizontal = 16.dp),
+ )
+ VerticalSpacer(4.dp)
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.background,
+ onClick = onTextButtonClick,
+ enabled = textButtonText.isNotEmpty(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Text(
+ text = textButtonText,
+ style = BrakeTheme.typography.subtitle16SB,
+ color = Gray200,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(vertical = 16.dp),
+ )
+ }
+ VerticalSpacer(28.dp)
+ }
+}
+
+@Preview
+@Composable
+private fun OverlayBasePreview() {
+ BrakeTheme {
+ OverlayBase(
+ imageRes = R.drawable.img_init,
+ title = stringResource(R.string.blocking_description),
+ buttonText = "Exit",
+ onButtonClick = { /* Do nothing */ },
+ textButtonText = "Check Time",
+ onTextButtonClick = { /* Do nothing */ },
+ contentDescriptionRes = stringResource(id = R.string.blocking_title, 3, "AppName"),
+ )
+ }
+}
diff --git a/overlay/ui/src/main/res/drawable/img_blocking.png b/overlay/ui/src/main/res/drawable/img_blocking.png
new file mode 100644
index 00000000..c0cc3f1f
Binary files /dev/null and b/overlay/ui/src/main/res/drawable/img_blocking.png differ
diff --git a/overlay/ui/src/main/res/drawable/img_cooldown.png b/overlay/ui/src/main/res/drawable/img_cooldown.png
new file mode 100644
index 00000000..2198eeb5
Binary files /dev/null and b/overlay/ui/src/main/res/drawable/img_cooldown.png differ
diff --git a/overlay/ui/src/main/res/drawable/img_init.png b/overlay/ui/src/main/res/drawable/img_init.png
new file mode 100644
index 00000000..cf202fee
Binary files /dev/null and b/overlay/ui/src/main/res/drawable/img_init.png differ
diff --git a/overlay/ui/src/main/res/drawable/img_time_set.png b/overlay/ui/src/main/res/drawable/img_time_set.png
new file mode 100644
index 00000000..a8a05062
Binary files /dev/null and b/overlay/ui/src/main/res/drawable/img_time_set.png differ
diff --git a/overlay/ui/src/main/res/values/strings.xml b/overlay/ui/src/main/res/values/strings.xml
new file mode 100644
index 00000000..087105e5
--- /dev/null
+++ b/overlay/ui/src/main/res/values/strings.xml
@@ -0,0 +1,27 @@
+
+
+
+ 분
+
+ blocking image
+ cooldown image
+
+ 지금은 %s\n사용할 수 없어요
+ %d분간 %s 앱을 사용할 수 없어요.
+
+ 약속한 시간이\n지났어요
+ 이제 %d분간 %s 앱을\n 사용할 수 없어요
+ 사용 시간이 모두 끝났어요.
+
+ %s\n꼭 사용하실건가요?
+ %s\n얼마나 사용할까요?
+ %s 사용했어요]]>
+ %s\까지
+
+ 5분 더(%d/%d)
+ 남은 시간 확인
+ 사용하기
+ 안하기
+ 그만하기
+ 완료
+
diff --git a/presentation/home/build.gradle.kts b/presentation/home/build.gradle.kts
index 33a4eac5..0cc2b551 100644
--- a/presentation/home/build.gradle.kts
+++ b/presentation/home/build.gradle.kts
@@ -1,9 +1,14 @@
-import com.yapp.breake.setNamespace
+import com.teambrake.brake.setNamespace
plugins {
- alias(libs.plugins.breake.android.feature)
+ alias(libs.plugins.brake.android.feature)
}
android {
setNamespace("presentation.home")
}
+
+dependencies {
+ implementation(projects.core.util)
+ implementation(projects.core.appscanner)
+}
diff --git a/presentation/home/src/androidTest/java/com/yapp/breake/presentation/home/ExampleInstrumentedTest.kt b/presentation/home/src/androidTest/java/com/android/brake/presentation/home/ExampleInstrumentedTest.kt
similarity index 79%
rename from presentation/home/src/androidTest/java/com/yapp/breake/presentation/home/ExampleInstrumentedTest.kt
rename to presentation/home/src/androidTest/java/com/android/brake/presentation/home/ExampleInstrumentedTest.kt
index 9ad5b7d5..fadafd13 100644
--- a/presentation/home/src/androidTest/java/com/yapp/breake/presentation/home/ExampleInstrumentedTest.kt
+++ b/presentation/home/src/androidTest/java/com/android/brake/presentation/home/ExampleInstrumentedTest.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake.presentation.home
+package com.teambrake.brake.presentation.home
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
@@ -17,6 +17,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- Assert.assertEquals("com.yapp.breake.presentationhome.test", appContext.packageName)
+ Assert.assertEquals("com.teambrake.brake.presentationhome.test", appContext.packageName)
}
-}
\ No newline at end of file
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/HomeScreen.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/HomeScreen.kt
new file mode 100644
index 00000000..40f6e4ea
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/HomeScreen.kt
@@ -0,0 +1,170 @@
+package com.teambrake.brake.presentation.home
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.teambrake.brake.core.designsystem.theme.LinerGradient
+import com.teambrake.brake.core.navigation.compositionlocal.LocalMainAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+import com.teambrake.brake.presentation.home.component.StopUsingDialog
+import com.teambrake.brake.presentation.home.contract.HomeEvent
+import com.teambrake.brake.presentation.home.contract.HomeModalState
+import com.teambrake.brake.presentation.home.contract.HomeUiState
+import com.teambrake.brake.presentation.home.screen.ListScreen
+import com.teambrake.brake.presentation.home.screen.NothingScreen
+import com.teambrake.brake.presentation.home.screen.TickingScreen
+
+@Composable
+internal fun HomeRoute(
+ padding: PaddingValues,
+ viewModel: HomeViewModel = hiltViewModel(),
+) {
+ val homeUiState by viewModel.homeUiState.collectAsStateWithLifecycle()
+ val homeModalState by viewModel.homeModalState.collectAsStateWithLifecycle()
+ val navAction = LocalNavigatorAction.current
+ val mainAction = LocalMainAction.current
+ val context = LocalContext.current
+
+ val hasRequestedNotificationPermission = remember { mutableStateOf(false) }
+ val notificationPermissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ ) { _ ->
+ hasRequestedNotificationPermission.value = true
+ }
+
+ mainAction.OnFinishBackHandler()
+
+ LaunchedEffect(Unit) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val hasNotificationPermission = ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS,
+ ) == PackageManager.PERMISSION_GRANTED
+
+ if (!hasNotificationPermission && !hasRequestedNotificationPermission.value) {
+ notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.4f)
+ .background(brush = LinerGradient),
+ )
+ HomeContent(
+ homeUiState = homeUiState,
+ viewModel = viewModel,
+ onShowAddScreen = viewModel::navigateToRegistry,
+ onShowEditScreen = viewModel::navigateToRegistry,
+ )
+ }
+
+ ModalContent(
+ homeModalState = homeModalState,
+ viewModel = viewModel,
+ )
+
+ LaunchedEffect(Unit) {
+ viewModel.homeEvent.collect { event ->
+ when (event) {
+ is HomeEvent.ShowStopUsingSuccess -> {
+ mainAction.onShowSuccessMessage(
+ context.getString(
+ R.string.home_stop_using_success,
+ event.groupName,
+ event.groupName,
+ ),
+ )
+ }
+
+ is HomeEvent.NavigateToRegistry -> {
+ navAction.navigateToRegistry(groupId = event.groupId)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun HomeContent(
+ homeUiState: HomeUiState,
+ viewModel: HomeViewModel,
+ onShowAddScreen: () -> Unit,
+ onShowEditScreen: (Long) -> Unit,
+) {
+ when (homeUiState) {
+ HomeUiState.Loading -> {}
+
+ HomeUiState.Nothing -> {
+ NothingScreen(
+ onAddClick = onShowAddScreen,
+ )
+ }
+
+ is HomeUiState.GroupList -> {
+ ListScreen(
+ appGroups = homeUiState.appGroups,
+ onEditClick = {
+ onShowEditScreen(it.id)
+ },
+ onAddClick = onShowAddScreen,
+ )
+ }
+
+ is HomeUiState.Ticking -> {
+ TickingScreen(
+ appGroups = homeUiState.appGroups,
+ onAddClick = onShowAddScreen,
+ onEditClick = {
+ onShowEditScreen(it.id)
+ },
+ onStopClick = viewModel::showStopUsingDialog,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ModalContent(
+ homeModalState: HomeModalState,
+ viewModel: HomeViewModel,
+) {
+ when (homeModalState) {
+ HomeModalState.Nothing -> {}
+ is HomeModalState.StopUsingDialog -> {
+ StopUsingDialog(
+ onStopUsing = {
+ viewModel.dismiss()
+ viewModel.stopAppUsing(homeModalState.appGroup)
+ },
+ onDismissRequest = viewModel::dismiss,
+ )
+ }
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/HomeViewModel.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/HomeViewModel.kt
new file mode 100644
index 00000000..4a830df8
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/HomeViewModel.kt
@@ -0,0 +1,113 @@
+package com.teambrake.brake.presentation.home
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.usecase.SetBlockingAlarmUseCase
+import com.teambrake.brake.presentation.home.contract.HomeEvent
+import com.teambrake.brake.presentation.home.contract.HomeModalState
+import com.teambrake.brake.presentation.home.contract.HomeUiState
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.analytics.logEvent
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+internal class HomeViewModel @Inject constructor(
+ appGroupRepository: AppGroupRepository,
+ private val setBlockingAlarmUseCase: SetBlockingAlarmUseCase,
+ private val firebaseAnalytics: FirebaseAnalytics,
+) : ViewModel() {
+
+ val homeUiState: StateFlow = appGroupRepository
+ .observeAppGroup()
+ .map(::returnHomeUiState)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly,
+ initialValue = HomeUiState.Loading,
+ )
+
+ private val _homeModalState: MutableStateFlow =
+ MutableStateFlow(HomeModalState.Nothing)
+ val homeModalState: StateFlow get() = _homeModalState
+
+ private val _homeEvent: MutableSharedFlow = MutableSharedFlow()
+ val homeEvent: MutableSharedFlow get() = _homeEvent
+
+ private fun returnHomeUiState(appGroups: List): HomeUiState {
+ if (appGroups.isEmpty()) {
+ return HomeUiState.Nothing
+ }
+
+ appGroups.forEach { appGroup ->
+ when (appGroup.appGroupState) {
+ AppGroupState.Blocking, AppGroupState.SnoozeBlocking, AppGroupState.Using -> return HomeUiState.Ticking(
+ appGroups.toPersistentList(),
+ )
+
+ AppGroupState.NeedSetting -> {}
+ }
+ }
+
+ return HomeUiState.GroupList(appGroups.toPersistentList())
+ }
+
+ fun showStopUsingDialog(appGroup: AppGroup) {
+ _homeModalState.update {
+ HomeModalState.StopUsingDialog(appGroup)
+ }
+ firebaseAnalytics.logEvent("try_stop_session") {
+ param("where", "home_screen")
+ }
+ }
+
+ fun stopAppUsing(appGroup: AppGroup) {
+ viewModelScope.launch {
+ setBlockingAlarmUseCase(
+ groupId = appGroup.id,
+ ).onSuccess {
+ showStopUsingSuccess(appGroup.name)
+ }
+ }
+ }
+
+ private fun showStopUsingSuccess(groupName: String) {
+ viewModelScope.launch {
+ _homeEvent.emit(HomeEvent.ShowStopUsingSuccess(groupName))
+ }
+ firebaseAnalytics.logEvent("stop_session") {
+ param("reason", "user_stop")
+ }
+ }
+
+ fun navigateToRegistry(groupId: Long? = null) {
+ viewModelScope.launch {
+ _homeEvent.emit(HomeEvent.NavigateToRegistry(groupId))
+ }
+ if (groupId != null) {
+ firebaseAnalytics.logEvent("try_edit_existed_group") {
+ param("group_id", groupId)
+ }
+ } else {
+ firebaseAnalytics.logEvent("try_add_new_group") {
+ param("group_id", "null")
+ }
+ }
+ }
+
+ fun dismiss() {
+ _homeModalState.update { HomeModalState.Nothing }
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AddButton.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AddButton.kt
new file mode 100644
index 00000000..6868b19c
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AddButton.kt
@@ -0,0 +1,69 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.ButtonYellow
+import com.teambrake.brake.core.designsystem.theme.Gray800
+import com.teambrake.brake.core.designsystem.util.MultipleEventsCutter
+import com.teambrake.brake.core.designsystem.util.get
+import com.teambrake.brake.presentation.home.R
+
+@Composable
+internal fun AddButton(
+ onAddClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter.get() }
+
+ Button(
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = ButtonYellow,
+ contentColor = Gray800,
+ ),
+ contentPadding = PaddingValues(vertical = 10.5.dp, horizontal = 18.5.dp),
+ onClick = { multipleEventsCutter.processEvent(onAddClick) },
+ modifier = modifier,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_plus),
+ contentDescription = stringResource(R.string.add_button_content_description),
+ modifier = Modifier.size(16.dp),
+ )
+ HorizontalSpacer(8.dp)
+ Text(
+ text = stringResource(R.string.add),
+ style = BrakeTheme.typography.subtitle16B,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
+
+@Composable
+@Preview
+private fun AddButtonPreview() {
+ BrakeTheme {
+ AddButton(onAddClick = {})
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppGroupComponent.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppGroupComponent.kt
new file mode 100644
index 00000000..c64783a3
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppGroupComponent.kt
@@ -0,0 +1,75 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.presentation.home.R
+
+@Composable
+internal fun AppGroupBox(
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(
+ content = content,
+ modifier = Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .then(modifier),
+ )
+}
+
+@Composable
+internal fun AppGroupTitle(
+ name: String,
+ appGroupState: AppGroupState,
+ clickable: Boolean,
+ onEditClick: () -> Unit,
+) {
+ Row(
+ verticalAlignment = Alignment.Top,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = name,
+ style = BrakeTheme.typography.body16M,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ modifier = Modifier.weight(1f),
+ overflow = TextOverflow.Ellipsis,
+ )
+ HorizontalSpacer(8.dp)
+ GroupStateIcon.entries.find { it.groupState == appGroupState }?.icon?.invoke(Modifier)
+ HorizontalSpacer(8.dp)
+ Image(
+ painter = painterResource(R.drawable.ic_edit),
+ contentDescription = stringResource(R.string.edit_app_group_icon_content_description),
+ modifier = Modifier
+ .size(24.dp)
+ .clip(CircleShape)
+ .clickable(
+ enabled = clickable,
+ onClick = onEditClick,
+ ),
+ alpha = if (clickable) 1f else 0.4f,
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppGroupItem.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppGroupItem.kt
new file mode 100644
index 00000000..2ec08f7f
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppGroupItem.kt
@@ -0,0 +1,71 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.model.app.AppGroup
+
+@Composable
+internal fun AppGroupItem(
+ appGroup: AppGroup,
+ onEditClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ clickable: Boolean = true,
+ showSummary: Boolean = false,
+) {
+ AppGroupBox(
+ modifier = modifier,
+ ) {
+ AppGroupItemContent(
+ appGroup = appGroup,
+ clickable = clickable,
+ showSummary = showSummary,
+ onEditClick = onEditClick,
+ )
+ }
+}
+
+@Composable
+internal fun AppGroupItemContent(
+ appGroup: AppGroup,
+ onEditClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ clickable: Boolean = true,
+ isDimmed: Boolean = false,
+ showSummary: Boolean = false,
+) {
+ Column(
+ modifier = modifier,
+ ) {
+ AppGroupTitle(
+ name = appGroup.name,
+ appGroupState = appGroup.appGroupState,
+ clickable = clickable,
+ onEditClick = onEditClick,
+ )
+ VerticalSpacer(12.dp)
+ AppsList(
+ apps = appGroup.apps,
+ isDimmed = isDimmed,
+ showSummary = showSummary,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun AppGroupItemPreview() {
+ BrakeTheme {
+ AppGroupItem(
+ appGroup = AppGroup.sample,
+ onEditClick = { /* TODO: Handle edit click */ },
+ modifier = Modifier
+ .fillMaxWidth(),
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppGroupSubtitle.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppGroupSubtitle.kt
new file mode 100644
index 00000000..bfdb6ebf
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppGroupSubtitle.kt
@@ -0,0 +1,57 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.BrakeYellow
+import com.teambrake.brake.core.designsystem.theme.Gray700
+
+@Composable
+internal fun AppGroupSubtitle(
+ modifier: Modifier = Modifier,
+ @StringRes titleResId: Int,
+ currentIndex: Int,
+ totalCount: Int,
+) {
+ Row(
+ verticalAlignment = Alignment.Bottom,
+ modifier = Modifier.fillMaxWidth().then(modifier),
+ ) {
+ Text(
+ text = stringResource(titleResId),
+ style = BrakeTheme.typography.subtitle18SB,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ HorizontalSpacer(1f)
+ val annotatedString = buildAnnotatedString {
+ withStyle(
+ style = SpanStyle(
+ color = BrakeYellow,
+ ),
+ ) {
+ append("$currentIndex")
+ }
+ append("/$totalCount")
+ }
+ Text(
+ text = annotatedString,
+ modifier = Modifier.padding(end = 12.dp),
+ style = BrakeTheme.typography.body12B,
+ color = Gray700,
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppsList.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppsList.kt
new file mode 100644
index 00000000..a6cb27b1
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/AppsList.kt
@@ -0,0 +1,134 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Circle
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ColorMatrix
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.core.model.app.App
+import com.teambrake.brake.core.util.toImageBitmap
+import com.teambrake.brake.presentation.home.R
+
+@Composable
+internal fun AppsList(
+ apps: List,
+ isDimmed: Boolean = false,
+ showSummary: Boolean = false,
+) {
+ val colorFilter = if (isDimmed) {
+ ColorFilter.colorMatrix(
+ ColorMatrix().apply {
+ setToSaturation(0.3f)
+ },
+ )
+ } else {
+ null
+ }
+
+ val maxDisplayCount = if (showSummary) 6 else apps.size
+ val displayApps = if (showSummary) apps.take(maxDisplayCount) else apps
+
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier,
+ ) {
+ displayApps.forEach { app ->
+ val icon = app.icon?.toImageBitmap()
+ if (icon == null) {
+ Image(
+ imageVector = Icons.Default.Circle,
+ contentDescription = stringResource(R.string.default_app_icon_content_description),
+ colorFilter = colorFilter,
+ modifier = Modifier
+ .size(24.dp),
+ )
+ } else {
+ Image(
+ bitmap = icon,
+ contentDescription = stringResource(R.string.app_icon_content_description),
+ colorFilter = colorFilter,
+ modifier = Modifier
+ .size(24.dp),
+ )
+ }
+ }
+
+ if (showSummary && apps.size > maxDisplayCount) {
+ Text(
+ text = "+${apps.size - maxDisplayCount}",
+ style = BrakeTheme.typography.body14M,
+ color = Gray200,
+ modifier = Modifier.align(Alignment.CenterVertically),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun AppsListPreview() {
+ AppsList(
+ apps = listOf(
+ App(
+ packageName = "com.app1",
+ id = null,
+ icon = null,
+ name = "App 1",
+ category = "Category 1",
+ ),
+ App(
+ packageName = "com.app2",
+ id = null,
+ icon = null,
+ name = "App 2",
+ category = "Category 2",
+ ),
+ ),
+ )
+}
+
+@Preview
+@Composable
+private fun AppsListOverPreview() {
+ AppsList(
+ apps = listOf(
+ App(packageName = "com.app1", id = null, icon = null, name = "App 1", category = "Category 1"),
+ App(packageName = "com.app2", id = null, icon = null, name = "App 2", category = "Category 2"),
+ App(packageName = "com.app3", id = null, icon = null, name = "App 3", category = "Category 3"),
+ App(packageName = "com.app4", id = null, icon = null, name = "App 4", category = "Category 4"),
+ App(packageName = "com.app5", id = null, icon = null, name = "App 5", category = "Category 5"),
+ App(packageName = "com.app6", id = null, icon = null, name = "App 6", category = "Category 6"),
+ App(packageName = "com.app7", id = null, icon = null, name = "App 7", category = "Category 7"),
+ ),
+ )
+}
+
+@Preview
+@Composable
+private fun AppsListFullPreview() {
+ AppsList(
+ apps = listOf(
+ App(packageName = "com.app1", id = null, icon = null, name = "App 1", category = "Category 1"),
+ App(packageName = "com.app2", id = null, icon = null, name = "App 2", category = "Category 2"),
+ App(packageName = "com.app3", id = null, icon = null, name = "App 3", category = "Category 3"),
+ App(packageName = "com.app4", id = null, icon = null, name = "App 4", category = "Category 4"),
+ App(packageName = "com.app5", id = null, icon = null, name = "App 5", category = "Category 5"),
+ App(packageName = "com.app6", id = null, icon = null, name = "App 6", category = "Category 6"),
+ App(packageName = "com.app7", id = null, icon = null, name = "App 7", category = "Category 7"),
+ ),
+ showSummary = false,
+ )
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/BlockingAppGroup.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/BlockingAppGroup.kt
new file mode 100644
index 00000000..caf4fa0c
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/BlockingAppGroup.kt
@@ -0,0 +1,76 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.presentation.home.R
+
+@Composable
+internal fun BlockingAppGroup(
+ appGroup: AppGroup,
+ onEditClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ AppGroupBox(
+ modifier = modifier,
+ ) {
+ AppGroupItemContent(
+ appGroup = appGroup,
+ clickable = false,
+ onEditClick = onEditClick,
+ isDimmed = true,
+ )
+ VerticalSpacer(16.dp)
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ ProgressTime(
+ startTime = appGroup.startTime,
+ endTime = appGroup.endTime,
+ minuteTextStyle = BrakeTheme.typography.subtitle14B,
+ startColor = Color(0xFF8E97B0),
+ endColor = Color(0xFFF0F4FF),
+ textBottomPadding = 0,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.img_lock),
+ contentDescription = "Lock Icon",
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier
+ .width(110.dp)
+ .wrapContentHeight(),
+ )
+ }
+ }
+ VerticalSpacer(10.dp)
+ }
+}
+
+@Preview
+@Composable
+private fun BlockingScreenPreview() {
+ BrakeTheme {
+ BlockingAppGroup(
+ appGroup = AppGroup.sample,
+ onEditClick = { /* TODO: Handle edit click */ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/CircularProgressTimer.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/CircularProgressTimer.kt
new file mode 100644
index 00000000..f8cb750f
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/CircularProgressTimer.kt
@@ -0,0 +1,112 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray600
+
+@Composable
+internal fun CircularProgressTimer(
+ progress: Float,
+ modifier: Modifier = Modifier,
+ startColor: Color = Color(0xFFB6C1E0),
+ endColor: Color = Color(0xFFF2FF5E),
+ strokeWidth: Dp = 8.dp,
+ backgroundColor: Color = Gray600,
+ content: @Composable () -> Unit = {},
+) {
+ val animatedProgress by animateFloatAsState(
+ targetValue = progress,
+ animationSpec = tween(durationMillis = 150),
+ label = "progress_animation",
+ )
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .aspectRatio(1f)
+ .padding(horizontal = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Canvas(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f),
+ ) {
+ val strokeWidthPx = strokeWidth.toPx()
+ val size = Size(this.size.width - strokeWidthPx, this.size.height - strokeWidthPx)
+ val topLeft = Offset(strokeWidthPx / 2, strokeWidthPx / 2)
+ val center = Offset(this.size.width / 2, this.size.height / 2)
+ val radius = (this.size.minDimension - strokeWidthPx) / 2
+
+ drawCircle(
+ color = backgroundColor.copy(alpha = 0.1f),
+ radius = radius,
+ center = center,
+ style = Stroke(
+ width = strokeWidthPx,
+ cap = StrokeCap.Round,
+ ),
+ )
+
+ if (animatedProgress > 0f) {
+ val progressBrush = Brush.linearGradient(
+ colors = listOf(
+ startColor,
+ endColor,
+ ),
+ start = Offset(center.x, 0f),
+ end = Offset(
+ center.x + radius * kotlin.math.cos(2 * kotlin.math.PI * animatedProgress - kotlin.math.PI / 2)
+ .toFloat(),
+ center.y + radius * kotlin.math.sin(2 * kotlin.math.PI * animatedProgress - kotlin.math.PI / 2)
+ .toFloat(),
+ ),
+ )
+
+ drawArc(
+ brush = progressBrush,
+ startAngle = -90f,
+ sweepAngle = 360f * animatedProgress,
+ useCenter = false,
+ topLeft = topLeft,
+ size = size,
+ style = Stroke(
+ width = strokeWidthPx,
+ cap = StrokeCap.Round,
+ ),
+ )
+ }
+ }
+
+ content()
+ }
+}
+
+@Preview
+@Composable
+private fun CircularProgressTimerPreview() {
+ BrakeTheme {
+ CircularProgressTimer(
+ progress = 0.7f,
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/GroupStateIcon.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/GroupStateIcon.kt
new file mode 100644
index 00000000..6fe1d4e9
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/GroupStateIcon.kt
@@ -0,0 +1,102 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.BrakeYellow
+import com.teambrake.brake.core.designsystem.theme.Gray50
+import com.teambrake.brake.core.designsystem.theme.Gray800
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.presentation.home.R
+
+internal enum class GroupStateIcon(
+ val groupState: AppGroupState,
+ val icon: @Composable (Modifier) -> Unit,
+) {
+ NEED_SETTING(
+ groupState = AppGroupState.NeedSetting,
+ icon = { modifier ->
+ ChipIconTemplate(
+ modifier = modifier,
+ iconResId = R.drawable.ic_need_setting,
+ contentResId = R.string.group_state_need_setting,
+ contentColor = Gray50,
+ contentDescription = "사용전",
+ )
+ },
+ ),
+ USING(
+ groupState = AppGroupState.Using,
+ icon = { modifier ->
+ ChipIconTemplate(
+ modifier = modifier,
+ iconResId = R.drawable.ic_using,
+ contentResId = R.string.group_state_using,
+ contentColor = BrakeYellow,
+ contentDescription = "사용중",
+ )
+ },
+ ),
+ BLOCKING(
+ groupState = AppGroupState.Blocking,
+ icon = { modifier ->
+ ChipIconTemplate(
+ modifier = modifier,
+ contentColor = Color(0xFFFF6E31),
+ iconResId = R.drawable.ic_blocking,
+ contentResId = R.string.group_state_blocking,
+ contentDescription = "차단중",
+ )
+ },
+ ),
+}
+
+@Composable
+private fun ChipIconTemplate(
+ modifier: Modifier = Modifier,
+ contentColor: Color,
+ @DrawableRes iconResId: Int,
+ @StringRes contentResId: Int,
+ contentDescription: String,
+) {
+ Row(
+ modifier = modifier
+ .clip(RoundedCornerShape(36.dp))
+ .background(Gray800)
+ .padding(horizontal = 12.dp)
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = iconResId),
+ contentDescription = contentDescription,
+ tint = contentColor,
+ )
+
+ HorizontalSpacer(8.dp)
+
+ Text(
+ text = stringResource(id = contentResId),
+ style = BrakeTheme.typography.subtitle14SB,
+ color = contentColor,
+ maxLines = 1,
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/ImageTextBox.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/ImageTextBox.kt
new file mode 100644
index 00000000..9c8da05c
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/ImageTextBox.kt
@@ -0,0 +1,54 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray100
+import com.teambrake.brake.presentation.home.R
+
+@Composable
+internal fun ImageTextBox(
+ @DrawableRes imageRes: Int,
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(id = imageRes),
+ contentDescription = stringResource(R.string.list_screen_image_content_description),
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ contentScale = ContentScale.FillWidth,
+ )
+
+ VerticalSpacer(12.dp)
+
+ Text(
+ text = text,
+ style = BrakeTheme.typography.subtitle20SB,
+ textAlign = TextAlign.Center,
+ color = Gray100,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 24.dp),
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/ProgressTime.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/ProgressTime.kt
new file mode 100644
index 00000000..70adebf3
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/ProgressTime.kt
@@ -0,0 +1,118 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.sp
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.util.extensions.getRemainingSeconds
+import kotlinx.coroutines.delay
+import java.time.LocalDateTime
+import java.time.temporal.ChronoUnit
+
+@Composable
+internal fun ProgressTime(
+ startTime: LocalDateTime?,
+ endTime: LocalDateTime?,
+ modifier: Modifier = Modifier,
+ minuteTextStyle: TextStyle = BrakeTheme.typography.body16M.copy(
+ fontSize = 54.sp,
+ ),
+ startColor: Color = Color(0xFFB6C1E0),
+ endColor: Color = Color(0xFFF2FF5E),
+ textBottomPadding: Int = 8,
+ centerContent: @Composable () -> Unit = { },
+) {
+ var remainingSeconds by remember(startTime, endTime) {
+ mutableLongStateOf(getRemainingSeconds(endTime))
+ }
+
+ var progress by remember(startTime, endTime) {
+ mutableFloatStateOf(0f)
+ }
+
+ val totalDurationSeconds = remember(startTime, endTime) {
+ if (startTime != null && endTime != null && endTime.isAfter(startTime)) {
+ ChronoUnit.SECONDS.between(startTime, endTime)
+ } else {
+ 0L
+ }
+ }
+
+ LaunchedEffect(endTime, startTime) {
+ if (endTime != null && startTime != null && totalDurationSeconds > 0) {
+ while (true) {
+ val now = LocalDateTime.now()
+ val newRemainingSeconds = getRemainingSeconds(endTime)
+ val elapsedSeconds = ChronoUnit.SECONDS.between(startTime, now)
+
+ remainingSeconds = newRemainingSeconds
+ progress =
+ (elapsedSeconds.toFloat() / totalDurationSeconds.toFloat()).coerceIn(0f, 1f)
+
+ if (newRemainingSeconds <= 0) break
+ delay(100)
+ }
+ }
+ }
+
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ CircularProgressTimer(
+ progress = progress,
+ startColor = startColor,
+ endColor = endColor,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ centerContent()
+ RemainingTimeTextDisplay(
+ remainingSeconds = remainingSeconds,
+ onTimeChange = { seconds ->
+ remainingSeconds = seconds
+ },
+ endTime = endTime,
+ modifier = Modifier,
+ minuteTextStyle = minuteTextStyle,
+ textBottomPadding = textBottomPadding,
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun UsingTimePreview() {
+ BrakeTheme {
+ ProgressTime(
+ startTime = LocalDateTime.now().minusMinutes(10).minusSeconds(30),
+ endTime = LocalDateTime.now().plusMinutes(10).plusSeconds(30),
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun UsingTimeWithoutMinutesPreview() {
+ BrakeTheme {
+ ProgressTime(
+ startTime = LocalDateTime.now().minusSeconds(30),
+ endTime = LocalDateTime.now().plusSeconds(45),
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/RemainingTimeDisplay.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/RemainingTimeDisplay.kt
new file mode 100644
index 00000000..2bc92c8c
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/RemainingTimeDisplay.kt
@@ -0,0 +1,160 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray300
+import com.teambrake.brake.core.util.extensions.getRemainingSeconds
+import com.teambrake.brake.core.util.extensions.toMinutesAndSeconds
+import com.teambrake.brake.presentation.home.R
+import kotlinx.coroutines.delay
+import java.time.LocalDateTime
+import java.util.Locale
+
+@Composable
+private fun AnimatedDigit(
+ digit: Int,
+ textStyle: TextStyle,
+ modifier: Modifier = Modifier,
+) {
+ val density = LocalDensity.current
+ val digitWidth = with(density) { textStyle.fontSize.toDp() * 0.6f }
+
+ AnimatedContent(
+ targetState = digit,
+ transitionSpec = {
+ slideInVertically { -it } + fadeIn() togetherWith
+ slideOutVertically { it } + fadeOut()
+ },
+ label = "digit_animation",
+ modifier = modifier.width(digitWidth),
+ ) { animatedDigit ->
+ Text(
+ text = animatedDigit.toString(),
+ style = textStyle,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Composable
+private fun AnimatedNumber(
+ number: Long,
+ textStyle: TextStyle,
+ modifier: Modifier = Modifier,
+ isMinute: Boolean = false,
+) {
+ val digits = if (isMinute) {
+ number.toString().map { it.digitToInt() }
+ } else {
+ String.format(Locale.getDefault(), "%02d", number).map { it.digitToInt() }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ modifier = modifier,
+ ) {
+ digits.forEach { digit ->
+ AnimatedDigit(
+ digit = digit,
+ textStyle = textStyle,
+ )
+ }
+ }
+}
+
+@Composable
+internal fun RemainingTimeTextDisplay(
+ remainingSeconds: Long,
+ onTimeChange: (Long) -> Unit,
+ endTime: LocalDateTime?,
+ modifier: Modifier = Modifier,
+ minuteTextStyle: TextStyle = BrakeTheme.typography.body16M.copy(
+ fontSize = 54.sp,
+ ),
+ textBottomPadding: Int = 8,
+) {
+
+ LaunchedEffect(endTime) {
+ onTimeChange(getRemainingSeconds(endTime))
+ while (remainingSeconds > 0) {
+ delay(50)
+ onTimeChange(getRemainingSeconds(endTime))
+ }
+ }
+
+ val (minutes, seconds) = remainingSeconds.toMinutesAndSeconds()
+
+ AnimatedContent(
+ targetState = minutes > 0,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "remaining_time_animation",
+ modifier = modifier,
+ ) { hasMinutes ->
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ if (hasMinutes) {
+ AnimatedNumber(
+ number = minutes,
+ textStyle = minuteTextStyle,
+ isMinute = true,
+ )
+ Text(
+ text = stringResource(R.string.minute),
+ style = BrakeTheme.typography.body16M,
+ color = Gray300,
+ modifier = Modifier.padding(bottom = textBottomPadding.dp),
+ )
+ HorizontalSpacer(4.dp)
+ }
+ AnimatedNumber(
+ number = seconds,
+ textStyle = minuteTextStyle,
+ isMinute = false,
+ )
+ Text(
+ text = stringResource(R.string.second),
+ style = BrakeTheme.typography.body16M,
+ color = Gray300,
+ modifier = Modifier.padding(bottom = textBottomPadding.dp),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun RemainingTimeDisplayPreview() {
+ BrakeTheme {
+ RemainingTimeTextDisplay(
+ remainingSeconds = 330L,
+ onTimeChange = {},
+ endTime = LocalDateTime.now().plusMinutes(5).plusSeconds(30),
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/StopButton.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/StopButton.kt
new file mode 100644
index 00000000..ab6daebd
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/StopButton.kt
@@ -0,0 +1,69 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Error
+import com.teambrake.brake.core.designsystem.util.MultipleEventsCutter
+import com.teambrake.brake.core.designsystem.util.get
+import com.teambrake.brake.presentation.home.R
+
+@Composable
+internal fun StopButton(
+ onStopClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter.get() }
+
+ Button(
+ shape = RoundedCornerShape(8.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = BrakeTheme.colorScheme.background.copy(alpha = 0.35f),
+ contentColor = Error,
+ ),
+ contentPadding = PaddingValues(vertical = 12.dp, horizontal = 20.dp),
+ onClick = { multipleEventsCutter.processEvent(onStopClick) },
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_exit),
+ contentDescription = stringResource(R.string.stop_button_content_description),
+ modifier = Modifier.size(20.dp),
+ )
+ HorizontalSpacer(8.dp)
+ Text(
+ text = stringResource(R.string.stop_usage),
+ style = BrakeTheme.typography.body14SB,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
+
+@Composable
+@Preview
+private fun AddButtonPreview() {
+ BrakeTheme {
+ StopButton(onStopClick = {})
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/StopUsingDialog.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/StopUsingDialog.kt
new file mode 100644
index 00000000..74c21d59
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/StopUsingDialog.kt
@@ -0,0 +1,77 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.OneButtonDialog
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray300
+import com.teambrake.brake.presentation.home.R
+
+@Composable
+internal fun StopUsingDialog(
+ onStopUsing: () -> Unit,
+ onDismissRequest: () -> Unit,
+) {
+ OneButtonDialog(
+ buttonText = stringResource(R.string.stop_using_dialog_button),
+ onButtonClick = onStopUsing,
+ onDismissRequest = onDismissRequest,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.img_celebration),
+ contentDescription = stringResource(R.string.cat_image_content_description),
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier
+ .width(120.dp)
+ .wrapContentHeight(),
+ )
+ VerticalSpacer(28.dp)
+ Text(
+ text = stringResource(R.string.stop_using_dialog_title),
+ style = BrakeTheme.typography.subtitle22SB,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(8.dp)
+ Text(
+ text = stringResource(R.string.stop_using_dialog_message),
+ style = BrakeTheme.typography.body16M,
+ color = Gray300,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(12.dp)
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun StopUsingDialogPreview() {
+ BrakeTheme {
+ StopUsingDialog(
+ onStopUsing = {},
+ onDismissRequest = {},
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/UsingAppGroup.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/UsingAppGroup.kt
new file mode 100644
index 00000000..663560bf
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/component/UsingAppGroup.kt
@@ -0,0 +1,68 @@
+package com.teambrake.brake.presentation.home.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray400
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.presentation.home.R
+
+@Composable
+internal fun UsingAppGroup(
+ appGroup: AppGroup,
+ onEditClick: () -> Unit,
+ onStopClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ AppGroupBox(
+ modifier = modifier,
+ ) {
+ AppGroupItemContent(
+ appGroup = appGroup,
+ clickable = false,
+ onEditClick = onEditClick,
+ )
+ VerticalSpacer(16.dp)
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ ProgressTime(
+ startTime = appGroup.startTime,
+ endTime = appGroup.endTime,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = stringResource(R.string.remaining_usage_time),
+ style = BrakeTheme.typography.body16M,
+ color = Gray400,
+ )
+ }
+ VerticalSpacer(10.dp)
+ StopButton(
+ onStopClick = onStopClick,
+ modifier = Modifier,
+ )
+ }
+ VerticalSpacer(10.dp)
+ }
+}
+
+@Preview
+@Composable
+private fun UsingAppGroupPreview() {
+ BrakeTheme {
+ UsingAppGroup(
+ appGroup = AppGroup.sample,
+ onEditClick = {},
+ onStopClick = {},
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/contract/HomeEvent.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/contract/HomeEvent.kt
new file mode 100644
index 00000000..2c44f537
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/contract/HomeEvent.kt
@@ -0,0 +1,14 @@
+package com.teambrake.brake.presentation.home.contract
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+
+@Stable
+internal sealed interface HomeEvent {
+
+ @Immutable
+ data class ShowStopUsingSuccess(val groupName: String) : HomeEvent
+
+ @Immutable
+ data class NavigateToRegistry(val groupId: Long?) : HomeEvent
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/contract/HomeModalState.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/contract/HomeModalState.kt
new file mode 100644
index 00000000..04602fd9
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/contract/HomeModalState.kt
@@ -0,0 +1,15 @@
+package com.teambrake.brake.presentation.home.contract
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import com.teambrake.brake.core.model.app.AppGroup
+
+@Stable
+internal sealed interface HomeModalState {
+
+ @Immutable
+ data object Nothing : HomeModalState
+
+ @Immutable
+ data class StopUsingDialog(val appGroup: AppGroup) : HomeModalState
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/contract/HomeUiState.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/contract/HomeUiState.kt
new file mode 100644
index 00000000..2cbba080
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/contract/HomeUiState.kt
@@ -0,0 +1,22 @@
+package com.teambrake.brake.presentation.home.contract
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import com.teambrake.brake.core.model.app.AppGroup
+import kotlinx.collections.immutable.PersistentList
+
+@Stable
+internal sealed interface HomeUiState {
+
+ @Immutable
+ data object Loading : HomeUiState
+
+ @Immutable
+ data object Nothing : HomeUiState
+
+ @Immutable
+ data class GroupList(val appGroups: PersistentList) : HomeUiState
+
+ @Immutable
+ data class Ticking(val appGroups: PersistentList) : HomeUiState
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/navigation/HomeNavigation.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/navigation/HomeNavigation.kt
new file mode 100644
index 00000000..d9528161
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/navigation/HomeNavigation.kt
@@ -0,0 +1,25 @@
+package com.teambrake.brake.presentation.home.navigation
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import androidx.navigation.navOptions
+import com.teambrake.brake.core.navigation.route.MainTabRoute
+import com.teambrake.brake.presentation.home.HomeRoute
+
+fun NavController.navigateToHome(
+ navOptions: NavOptions? = null,
+) {
+ navigate(
+ route = MainTabRoute.Home,
+ navOptions = navOptions,
+ )
+}
+
+fun NavGraphBuilder.homeNavGraph(padding: PaddingValues) {
+ composable {
+ HomeRoute(padding = padding)
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/screen/ListScreen.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/screen/ListScreen.kt
new file mode 100644
index 00000000..f68b1936
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/screen/ListScreen.kt
@@ -0,0 +1,160 @@
+package com.teambrake.brake.presentation.home.screen
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.LocalOverscrollFactory
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.ScrollableDefaults
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.AppItemGradient
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.presentation.home.R
+import com.teambrake.brake.presentation.home.component.AppGroupItem
+import com.teambrake.brake.presentation.home.component.AppGroupSubtitle
+
+@Composable
+internal fun ListScreen(
+ appGroups: List,
+ onEditClick: (AppGroup) -> Unit,
+ onAddClick: () -> Unit,
+) {
+ val needSettingState = rememberLazyListState()
+ val currentIndex by remember {
+ derivedStateOf {
+ val lazyIndex = needSettingState.firstVisibleItemIndex
+ val currentOffset = needSettingState.firstVisibleItemScrollOffset
+ val adjustedIndex = when {
+ currentOffset >= 80 -> lazyIndex + 1
+ else -> lazyIndex
+ }
+ adjustedIndex + 1
+ }
+ }
+
+ Scaffold(
+ content = { paddingValue ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(paddingValue),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.img_home_list),
+ contentDescription = null,
+ modifier = Modifier.fillMaxWidth(),
+ contentScale = ContentScale.FillWidth,
+ )
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.group),
+ style = BrakeTheme.typography.subtitle22SB,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ HorizontalSpacer(1f)
+
+ IconButton(
+ onClick = onAddClick,
+ modifier = Modifier.align(Alignment.CenterVertically),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(R.string.add_button_content_description),
+ )
+ }
+ }
+
+ VerticalSpacer(18.dp)
+
+ AppGroupSubtitle(
+ modifier = Modifier.padding(horizontal = 28.dp),
+ titleResId = R.string.group_title_need_setting,
+ currentIndex = currentIndex,
+ totalCount = appGroups.size,
+ )
+
+ VerticalSpacer(12.dp)
+
+ BoxWithConstraints {
+ val containerWidth = this.maxWidth
+
+ CompositionLocalProvider(LocalOverscrollFactory provides null) {
+ LazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ flingBehavior = ScrollableDefaults.flingBehavior(),
+ state = needSettingState,
+ contentPadding = PaddingValues(horizontal = 28.dp),
+ horizontalArrangement = if (appGroups.size == 1) {
+ Arrangement.Start
+ } else {
+ Arrangement.spacedBy(12.dp)
+ },
+ ) {
+ items(appGroups) { appGroup ->
+ AppGroupItem(
+ appGroup = appGroup,
+ onEditClick = { onEditClick(appGroup) },
+ showSummary = true,
+ modifier = Modifier
+ .width(containerWidth * 0.6f)
+ .background(AppItemGradient)
+ .padding(16.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ },
+ )
+}
+
+@Preview
+@Composable
+private fun ListScreenPreview() {
+ BrakeTheme {
+ ListScreen(
+ appGroups = listOf(AppGroup.sample),
+ onEditClick = { /* TODO: Handle app group click */ },
+ onAddClick = { /* Handle add click */ },
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/screen/NothingScreen.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/screen/NothingScreen.kt
new file mode 100644
index 00000000..1a3eb3ba
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/screen/NothingScreen.kt
@@ -0,0 +1,71 @@
+package com.teambrake.brake.presentation.home.screen
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.presentation.home.R
+import com.teambrake.brake.presentation.home.component.AddButton
+
+@Composable
+internal fun NothingScreen(
+ onAddClick: () -> Unit,
+) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ VerticalSpacer(55.dp)
+ Image(
+ painter = painterResource(R.drawable.img_home_init),
+ contentDescription = stringResource(R.string.home_image_content_description),
+ modifier = Modifier.fillMaxWidth(0.8f).aspectRatio(1f),
+ )
+ VerticalSpacer(10.dp)
+ Text(
+ text = stringResource(R.string.nothing_screen_title),
+ style = BrakeTheme.typography.subtitle22SB,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ VerticalSpacer(10.dp)
+ Text(
+ text = stringResource(R.string.nothing_screen_subtitle),
+ style = BrakeTheme.typography.body16M,
+ textAlign = TextAlign.Center,
+ color = Gray200,
+ modifier = Modifier
+ .fillMaxWidth(),
+ )
+ VerticalSpacer(24.dp)
+ AddButton(
+ onAddClick = onAddClick,
+ )
+ VerticalSpacer(40.dp)
+ }
+}
+
+@Preview
+@Composable
+private fun NothingScreenPreview() {
+ BrakeTheme {
+ NothingScreen(
+ onAddClick = {},
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/home/screen/TickingScreen.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/screen/TickingScreen.kt
new file mode 100644
index 00000000..7bfbd19f
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/home/screen/TickingScreen.kt
@@ -0,0 +1,272 @@
+package com.teambrake.brake.presentation.home.screen
+
+import androidx.compose.foundation.LocalOverscrollFactory
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.ScrollableDefaults
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.AppItemGradient
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray700
+import com.teambrake.brake.core.designsystem.theme.LocalDynamicPaddings
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.presentation.home.R
+import com.teambrake.brake.presentation.home.component.AppGroupItem
+import com.teambrake.brake.presentation.home.component.AppGroupSubtitle
+import com.teambrake.brake.presentation.home.component.BlockingAppGroup
+import com.teambrake.brake.presentation.home.component.UsingAppGroup
+import kotlinx.collections.immutable.PersistentList
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+internal fun TickingScreen(
+ appGroups: PersistentList,
+ onAddClick: () -> Unit,
+ onEditClick: (AppGroup) -> Unit,
+ onStopClick: (AppGroup) -> Unit,
+) {
+ val (tickingGroups, notUsingGroups) = remember(appGroups) {
+ derivedStateOf {
+ val blockingBuilder = persistentListOf().builder()
+ val remainingBuilder = persistentListOf().builder()
+
+ appGroups.forEach { appGroup ->
+ if (appGroup.appGroupState != AppGroupState.NeedSetting) {
+ blockingBuilder.add(appGroup)
+ } else {
+ remainingBuilder.add(appGroup)
+ }
+ }
+
+ blockingBuilder.build() to remainingBuilder.build()
+ }
+ }.value
+ val tickingPagerState = rememberPagerState(pageCount = { tickingGroups.size })
+ val needSettingState = rememberLazyListState()
+ // 현재 보이는 아이템의 index 추적
+ val currentIndex by remember {
+ derivedStateOf {
+ // 첫 아이템 패딩 고려
+ val lazyIndex = needSettingState.firstVisibleItemIndex
+ val currentOffset = needSettingState.firstVisibleItemScrollOffset
+ val adjustedIndex = when {
+ currentOffset >= 80 -> lazyIndex + 1
+ else -> lazyIndex
+ }
+ adjustedIndex + 1
+ }
+ }
+
+ val bottomPadding = LocalDynamicPaddings.current.paddings.bottomNavBarHeight
+ val colorScheme = BrakeTheme.colorScheme
+
+ // LazyColumn, LazyRow 의 오버 스크롤시 내부 요소의 늘어짐 방지
+ CompositionLocalProvider(LocalOverscrollFactory provides null) {
+ Scaffold(
+ content = { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = paddingValues.calculateTopPadding()),
+ state = rememberLazyListState(),
+ ) {
+ stickyHeader {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(color = colorScheme.background)
+ .padding(top = 16.dp)
+ .padding(horizontal = 24.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.group),
+ style = BrakeTheme.typography.subtitle22SB,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ HorizontalSpacer(1f)
+
+ IconButton(onClick = onAddClick) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(R.string.add_button_content_description),
+ )
+ }
+ }
+ }
+
+ if (tickingGroups.isNotEmpty()) {
+
+ item { VerticalSpacer(24.dp) }
+
+ item {
+ // currentPage가 범위를 벗어나지 않도록 보장
+ val safeCurrentPage = tickingPagerState.currentPage.coerceIn(0, tickingGroups.size - 1)
+
+ AppGroupSubtitle(
+ modifier = Modifier.padding(horizontal = 28.dp),
+ titleResId = if (tickingGroups.getOrNull(safeCurrentPage)?.appGroupState == AppGroupState.Using) {
+ R.string.group_state_using
+ } else {
+ R.string.group_state_blocking
+ },
+ currentIndex = safeCurrentPage + 1,
+ totalCount = tickingGroups.size,
+ )
+
+ VerticalSpacer(16.dp)
+
+ HorizontalPager(
+ state = tickingPagerState,
+ contentPadding = PaddingValues(horizontal = 28.dp),
+ pageSpacing = 16.dp,
+ modifier = Modifier.fillMaxWidth(),
+ ) { index ->
+ if (tickingGroups[index].appGroupState == AppGroupState.Using) {
+ val bg = painterResource(R.drawable.using_group_background)
+
+ UsingAppGroup(
+ appGroup = tickingGroups[index],
+ onEditClick = { /* 사용중일 땐 그룹 수정 금지 */ },
+ onStopClick = { onStopClick(tickingGroups[index]) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .drawBehind {
+ with(bg) {
+ draw(
+ size = size,
+ alpha = 1f,
+ colorFilter = null,
+ )
+ }
+ }
+ .padding(horizontal = 24.dp)
+ .padding(top = 16.dp, bottom = 10.dp),
+ )
+ } else {
+ val bg = painterResource(R.drawable.blocking_group_background)
+
+ BlockingAppGroup(
+ appGroup = tickingGroups[index],
+ onEditClick = { onEditClick(tickingGroups[index]) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Gray700.copy(alpha = 0.3f))
+ .drawBehind {
+ with(bg) {
+ draw(
+ size = size,
+ alpha = 1f,
+ colorFilter = null,
+ )
+ }
+ }
+ .padding(horizontal = 24.dp)
+ .padding(top = 16.dp, bottom = 19.dp),
+ )
+ }
+ }
+ }
+ }
+
+ if (notUsingGroups.isNotEmpty()) {
+ item { VerticalSpacer(40.dp) }
+
+ item {
+ AppGroupSubtitle(
+ modifier = Modifier.padding(horizontal = 28.dp),
+ titleResId = R.string.group_title_need_setting,
+ currentIndex = currentIndex,
+ totalCount = notUsingGroups.size,
+ )
+ }
+
+ item { VerticalSpacer(12.dp) }
+
+ item {
+ BoxWithConstraints {
+ val containerWidth = this.maxWidth
+
+ CompositionLocalProvider(LocalOverscrollFactory provides null) {
+ LazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ flingBehavior = ScrollableDefaults.flingBehavior(),
+ state = needSettingState,
+ contentPadding = PaddingValues(horizontal = 28.dp),
+ horizontalArrangement = if (appGroups.size == 1) {
+ Arrangement.Start
+ } else {
+ Arrangement.spacedBy(12.dp)
+ },
+ ) {
+ itemsIndexed(notUsingGroups) { index, appGroup ->
+ AppGroupItem(
+ appGroup = appGroup,
+ onEditClick = { onEditClick(appGroup) },
+ showSummary = true,
+ modifier = Modifier
+ .width(containerWidth * 0.6f)
+ .background(AppItemGradient)
+ .padding(16.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ item { VerticalSpacer(bottomPadding) }
+ }
+ },
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun UsingScreenPreview() {
+ BrakeTheme {
+ TickingScreen(
+ appGroups = persistentListOf(AppGroup.sample),
+ onAddClick = { },
+ onEditClick = { },
+ onStopClick = { },
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/RegistryScreen.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/RegistryScreen.kt
new file mode 100644
index 00000000..0ad1573f
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/RegistryScreen.kt
@@ -0,0 +1,114 @@
+package com.teambrake.brake.presentation.registry
+
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.ime
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.teambrake.brake.core.designsystem.theme.LocalPadding
+import com.teambrake.brake.core.navigation.compositionlocal.LocalMainAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+import com.teambrake.brake.presentation.registry.component.GroupDeletionWarningDialog
+import com.teambrake.brake.presentation.registry.model.RegistryNavState
+import com.teambrake.brake.presentation.registry.model.RegistryModalState
+import com.teambrake.brake.presentation.registry.model.RegistrySnackBarState
+import com.teambrake.brake.presentation.registry.model.RegistryUiState
+import com.teambrake.brake.presentation.registry.screen.AppRegistryScreen
+import com.teambrake.brake.presentation.registry.screen.GroupRegistryScreen
+
+@Composable
+fun RegistryRoute(
+ viewModel: RegistryViewModel,
+) {
+ val padding = LocalPadding.current.screenPaddingHorizontal
+ val registryUiState by viewModel.registryUiState.collectAsStateWithLifecycle()
+ val registryModalState by viewModel.modalFlow.collectAsStateWithLifecycle()
+ val focusManager = LocalFocusManager.current
+ val context = LocalContext.current
+ val navAction = LocalNavigatorAction.current
+ val mainAction = LocalMainAction.current
+ val density = LocalDensity.current
+ val imeVisible = WindowInsets.ime.getBottom(density) > 0
+ var prevVisible by remember { mutableStateOf(imeVisible) }
+
+ // 키보드가 사라졌을 때 포커스를 해제
+ SideEffect {
+ if (prevVisible && !imeVisible) {
+ focusManager.clearFocus()
+ }
+ prevVisible = imeVisible
+ }
+
+ LaunchedEffect(true) {
+ viewModel.navigationFlow.collect { effect ->
+ when (effect) {
+ is RegistryNavState.NavigateToHome -> navAction.popBackStack()
+ }
+ }
+ }
+
+ LaunchedEffect(true) {
+ viewModel.snackBarFlow.collect {
+ when (it) {
+ is RegistrySnackBarState.Success -> {
+ mainAction.onShowSuccessMessage(
+ message = it.uiString.asString(context = context),
+ )
+ }
+ is RegistrySnackBarState.Error -> {
+ mainAction.onShowErrorMessage(
+ message = it.uiString.asString(context = context),
+ )
+ }
+ }
+ }
+ }
+
+ when (registryModalState) {
+ is RegistryModalState.Idle -> {}
+ RegistryModalState.ShowGroupDeletionWarning -> {
+ GroupDeletionWarningDialog(
+ onDismissRequest = viewModel::dismissModal,
+ onConfirm = viewModel::removeGroup,
+ )
+ }
+ }
+
+ when (registryUiState) {
+ is RegistryUiState.Group.Initial, is RegistryUiState.Group.Updated -> {
+ GroupRegistryScreen(
+ padding = padding,
+ registryUiState = registryUiState,
+ focusManager = focusManager,
+ onGroupNameChange = viewModel::updateGroupName,
+ onStartSelectingApps = viewModel::startSelectingApps,
+ onRemoveApp = viewModel::removeSelectedApp,
+ onRemoveGroup = viewModel::tryRemoveGroup,
+ onRegisterGroup = viewModel::createNewGroup,
+ onBackClick = viewModel::cancelCreatingNewGroup,
+ )
+ }
+
+ is RegistryUiState.App -> {
+ AppRegistryScreen(
+ padding = padding,
+ registryUiState = registryUiState as RegistryUiState.App,
+ lazyColumnIndexFlow = viewModel.lazyColumnIndexFlow,
+ focusManager = focusManager,
+ onSearchTextChange = viewModel::updateSearchingText,
+ onSearchApp = { viewModel.searchApp(shouldShowNotFound = true) },
+ onSelectApp = viewModel::selectApp,
+ onBackClick = viewModel::cancelSelectingApps,
+ onRegisterApps = viewModel::completeSelectingApps,
+ )
+ }
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/RegistryViewModel.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/RegistryViewModel.kt
new file mode 100644
index 00000000..6640c806
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/RegistryViewModel.kt
@@ -0,0 +1,377 @@
+package com.teambrake.brake.presentation.registry
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.navigation.toRoute
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.analytics.logEvent
+import com.teambrake.brake.core.appscanner.InstalledAppScanner
+import com.teambrake.brake.core.model.app.App
+import com.teambrake.brake.core.model.app.AppGroup
+import com.teambrake.brake.core.model.app.AppGroupState
+import com.teambrake.brake.core.navigation.route.SubRoute
+import com.teambrake.brake.core.ui.UiString
+import com.teambrake.brake.core.util.toByteArray
+import com.teambrake.brake.domain.repository.AppGroupRepository
+import com.teambrake.brake.domain.usecase.CreateNewGroupUseCase
+import com.teambrake.brake.domain.usecase.DeleteGroupUseCase
+import com.teambrake.brake.domain.usecase.GrantNewGroupIdUseCase
+import com.teambrake.brake.presentation.home.R
+import com.teambrake.brake.presentation.registry.model.AppModel.Companion.initialAppsMapper
+import com.teambrake.brake.presentation.registry.model.RegistryModalState
+import com.teambrake.brake.presentation.registry.model.RegistryNavState
+import com.teambrake.brake.presentation.registry.model.RegistrySnackBarState
+import com.teambrake.brake.presentation.registry.model.RegistryUiState
+import com.teambrake.brake.presentation.registry.model.SelectedAppModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class RegistryViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ appScanner: InstalledAppScanner,
+ private val appGroupRepository: AppGroupRepository,
+ private val createNewGroupUseCase: CreateNewGroupUseCase,
+ private val deleteGroupUseCase: DeleteGroupUseCase,
+ grantNewGroupIdUseCase: GrantNewGroupIdUseCase,
+ private val firebaseAnalytics: FirebaseAnalytics,
+) : ViewModel() {
+ private val _registryUiState: MutableStateFlow = MutableStateFlow(
+ RegistryUiState.Group.Initial(
+ groupId = 0L,
+ groupName = "",
+ selectedApps = persistentListOf(),
+ ),
+ )
+ val registryUiState = _registryUiState.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ val groupId = savedStateHandle.toRoute().groupId
+ ?: grantNewGroupIdUseCase({})
+
+ val targetAppGroup = appGroupRepository.getAppGroupById(groupId)
+
+ val selectedApps =
+ persistentListOf().builder().apply {
+ targetAppGroup?.let { group ->
+ group.apps.forEachIndexed { index, appModel ->
+ add(
+ SelectedAppModel(
+ id = appModel.id,
+ index = index,
+ name = appModel.name,
+ packageName = appModel.packageName,
+ icon = appScanner.getIconDrawable(appModel.packageName),
+ ),
+ )
+ }
+ }
+ }.build()
+
+ _registryUiState.value = RegistryUiState.Group.Initial(
+ groupId = groupId,
+ groupName = targetAppGroup?.name.orEmpty(),
+ selectedApps = selectedApps,
+ )
+ }
+ }
+
+ private val _snackBarFlow = MutableSharedFlow()
+ val snackBarFlow = _snackBarFlow.asSharedFlow()
+
+ private val _navigationFlow = MutableSharedFlow()
+ val navigationFlow = _navigationFlow.asSharedFlow()
+
+ private val _modalFlow = MutableStateFlow(RegistryModalState.Idle)
+ val modalFlow = _modalFlow.asStateFlow()
+
+ private val _lazyColumnIndexFlow = MutableSharedFlow()
+ val lazyColumnIndexFlow = _lazyColumnIndexFlow.asSharedFlow()
+
+ private val cachedAppsDeferred = viewModelScope.async(Dispatchers.IO) {
+ appScanner.getInstalledAppsMetaData().map(initialAppsMapper).toPersistentList()
+ }
+
+ // ------------- Group Registry -------------
+ fun updateGroupName(groupName: String) {
+ val currentUiState = _registryUiState.value
+ _registryUiState.value = currentUiState.let {
+ RegistryUiState.Group.Updated(
+ groupId = it.groupId,
+ groupName = groupName,
+ apps = it.apps,
+ selectedApps = it.selectedApps,
+ )
+ }
+ }
+
+ fun startSelectingApps() {
+ viewModelScope.launch {
+ val currentUiState = _registryUiState.value
+ val cachedApps = cachedAppsDeferred.await()
+ _registryUiState.value = RegistryUiState.App(
+ groupId = currentUiState.groupId,
+ groupName = currentUiState.groupName,
+ apps = cachedApps.builder().apply {
+ currentUiState.selectedApps.forEach { selectedApp ->
+ cachedApps.indexOfFirst { it.packageName == selectedApp.packageName }
+ .takeIf { it >= 0 }?.let { index ->
+ set(
+ index = index,
+ element = cachedApps[index].copy(
+ isSelected = true,
+ ),
+ )
+ }
+ }
+ }.build(),
+ selectedApps = persistentListOf().builder().apply {
+ currentUiState.selectedApps.forEach { selectedApp ->
+ cachedApps.indexOfFirst { it.packageName == selectedApp.packageName }
+ .takeIf { it >= 0 }?.let { index ->
+ add(
+ SelectedAppModel(
+ index = index,
+ name = selectedApp.name,
+ packageName = selectedApp.packageName,
+ icon = selectedApp.icon,
+ id = selectedApp.id,
+ ),
+ )
+ }
+ }
+ }.build(),
+ )
+ }
+ firebaseAnalytics.logEvent("start_selecting_app") {
+ param("group_id", registryUiState.value.groupId)
+ }
+ }
+
+ fun removeSelectedApp(selectedIndex: Int) {
+ val currentUiState = _registryUiState.value
+ _registryUiState.value = currentUiState.let {
+ RegistryUiState.Group.Updated(
+ groupId = it.groupId,
+ groupName = it.groupName,
+ apps = it.apps,
+ selectedApps = it.selectedApps.removeAt(selectedIndex),
+ )
+ }
+ }
+
+ fun cancelCreatingNewGroup() {
+ viewModelScope.launch {
+ _navigationFlow.emit(RegistryNavState.NavigateToHome)
+ }
+ firebaseAnalytics.logEvent("cancel_creating_modifying_group") {
+ param("group_id", registryUiState.value.groupId)
+ }
+ }
+
+ fun tryRemoveGroup() {
+ _modalFlow.value = RegistryModalState.ShowGroupDeletionWarning
+ }
+
+ fun createNewGroup() {
+ viewModelScope.launch {
+ val currentUiState = _registryUiState.value
+ createNewGroupUseCase(
+ onError = { throwable ->
+ _snackBarFlow.emit(
+ RegistrySnackBarState.Error(
+ UiString.ResourceString(R.string.registry_snackbar_group_creation_error),
+ ),
+ )
+ },
+ group = currentUiState.let {
+ AppGroup(
+ id = it.groupId,
+ name = it.groupName,
+ appGroupState = AppGroupState.NeedSetting,
+ apps = it.selectedApps.map { selectedApp ->
+ App(
+ packageName = selectedApp.packageName,
+ id = selectedApp.id,
+ name = selectedApp.name,
+ icon = selectedApp.icon.toByteArray(),
+ category = "기타",
+ )
+ },
+ snoozes = emptyList(),
+ goalMinutes = null,
+ sessionStartTime = null,
+ startTime = null,
+ endTime = null,
+ )
+ },
+ )
+ _snackBarFlow.emit(
+ RegistrySnackBarState.Success(
+ UiString.ResourceString(R.string.registry_snackbar_group_creation_successful),
+ ),
+ )
+ firebaseAnalytics.logEvent("create_modify_group") {
+ param("group_id", currentUiState.groupId)
+ param("group_name", currentUiState.groupName)
+ for (selectedApp in currentUiState.selectedApps) {
+ param("app_name", selectedApp.name)
+ }
+ }
+ _navigationFlow.emit(RegistryNavState.NavigateToHome)
+ }
+ }
+
+ // ------------- App Registry -------------
+ fun updateSearchingText(searchingText: String) {
+ val currentUiState = _registryUiState.value as RegistryUiState.App
+ _registryUiState.value = currentUiState.copy(
+ searchingText = searchingText,
+ )
+ searchApp(shouldShowNotFound = false)
+ }
+
+ fun searchApp(shouldShowNotFound: Boolean) {
+ val currentUiState = _registryUiState.value as RegistryUiState.App
+ val query = currentUiState.searchingText
+
+ val matchedIndex = currentUiState.apps
+ .withIndex()
+ .minByOrNull { (_, app) ->
+ val pos = app.name.indexOf(query, ignoreCase = true)
+ if (pos >= 0) pos else Int.MAX_VALUE
+ }?.takeIf { it.value.name.contains(query, ignoreCase = true) }?.index ?: -1
+
+ // 결과가 없으면 상태 갱신하지 않음
+ if (matchedIndex == -1) {
+ // ime 에서 완료 버튼을 통해 검색을 시도했을 때만 에러 스낵바 표시
+ if (shouldShowNotFound) {
+ viewModelScope.launch {
+ _snackBarFlow.emit(
+ RegistrySnackBarState.Error(
+ UiString.ResourceString(R.string.registry_app_error_message, query),
+ ),
+ )
+ }
+ }
+ return
+ }
+ viewModelScope.launch {
+ _lazyColumnIndexFlow.emit(matchedIndex)
+ }
+ }
+
+ fun selectApp(selectedIndex: Int) {
+ val currentUiState = _registryUiState.value as RegistryUiState.App
+ _registryUiState.value = currentUiState.let {
+ it.copy(
+ apps = it.apps.set(
+ index = selectedIndex,
+ element = it.apps[selectedIndex].copy(
+ isSelected = !it.apps[selectedIndex].isSelected,
+ ),
+ ),
+ )
+ }
+ }
+
+ fun completeSelectingApps() {
+ val currentUiState = _registryUiState.value
+ _registryUiState.value = currentUiState.let {
+ RegistryUiState.Group.Updated(
+ groupId = it.groupId,
+ groupName = it.groupName,
+ apps = it.apps,
+ selectedApps = it.apps.mapIndexedNotNull { index, appModel ->
+ if (appModel.isSelected) {
+ val existingApp = it.selectedApps.find { selectedApp ->
+ selectedApp.packageName == appModel.packageName
+ }
+
+ SelectedAppModel(
+ index = index,
+ name = appModel.name,
+ packageName = appModel.packageName,
+ icon = appModel.icon,
+ id = existingApp?.id,
+ )
+ } else {
+ null
+ }
+ }.toPersistentList(),
+ )
+ }
+ firebaseAnalytics.logEvent("complete_selecting_apps") {
+ param("group_id", currentUiState.groupId)
+ }
+ }
+
+ // 선택한 앱을 그룹에 포함시키지 않고 그룹 화면으로 돌아가기
+ fun cancelSelectingApps() {
+ viewModelScope.launch {
+ val currentUiState = _registryUiState.value
+ val cachedApps = cachedAppsDeferred.await()
+ _registryUiState.value = RegistryUiState.Group.Updated(
+ groupId = currentUiState.groupId,
+ groupName = currentUiState.groupName,
+ apps = cachedApps.builder().apply {
+ currentUiState.selectedApps.forEach { selectedApp ->
+ set(
+ index = selectedApp.index,
+ element = cachedApps[selectedApp.index].copy(isSelected = true),
+ )
+ }
+ }.build(),
+ selectedApps = currentUiState.selectedApps,
+ )
+ firebaseAnalytics.logEvent("cancel_selecting_apps") {
+ param("group_id", currentUiState.groupId)
+ }
+ }
+ }
+
+ // ------------- Modal State -------------
+ fun dismissModal() {
+ _modalFlow.value = RegistryModalState.Idle
+ }
+
+ fun removeGroup() {
+ viewModelScope.launch {
+ deleteGroupUseCase(
+ onError = {
+ _snackBarFlow.emit(
+ RegistrySnackBarState.Error(
+ UiString.ResourceString(R.string.registry_snackbar_group_deletion_error),
+ ),
+ )
+ },
+ groupId = registryUiState.value.groupId,
+ )
+ _modalFlow.value = RegistryModalState.Idle
+ _snackBarFlow.emit(
+ RegistrySnackBarState.Success(
+ UiString.ResourceString(R.string.registry_snackbar_group_deletion_successful),
+ ),
+ )
+ firebaseAnalytics.logEvent("delete_group") {
+ param("group_id", registryUiState.value.groupId)
+ param("group_name", registryUiState.value.groupName)
+ for (selectedApp in registryUiState.value.selectedApps) {
+ param("app_name", selectedApp.name)
+ }
+ }
+ _navigationFlow.emit(RegistryNavState.NavigateToHome)
+ }
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/GroupDeletionWarningDialog.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/GroupDeletionWarningDialog.kt
new file mode 100644
index 00000000..d4792433
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/GroupDeletionWarningDialog.kt
@@ -0,0 +1,45 @@
+package com.teambrake.brake.presentation.registry.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.TwoButtonDialog
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.presentation.home.R
+
+@Composable
+internal fun GroupDeletionWarningDialog(
+ onDismissRequest: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ TwoButtonDialog(
+ onDismissRequest = onDismissRequest,
+ dismissButtonText = stringResource(R.string.registry_warning_dialog_button_cancel),
+ confirmButtonText = stringResource(R.string.registry_warning_dialog_button_approve),
+ onConfirmButtonClick = onConfirm,
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.registry_warning_dialog_title),
+ style = BrakeTheme.typography.subtitle22SB,
+ color = White,
+ textAlign = TextAlign.Center,
+ )
+
+ VerticalSpacer(8.dp)
+
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.registry_warning_dialog_description),
+ style = BrakeTheme.typography.body16M,
+ color = White,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/GroupNameTextField.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/GroupNameTextField.kt
new file mode 100644
index 00000000..da69a6a9
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/GroupNameTextField.kt
@@ -0,0 +1,130 @@
+package com.teambrake.brake.presentation.registry.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.BaseTextField
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.R as DesignSystemRes
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.core.designsystem.theme.Green
+import com.teambrake.brake.core.designsystem.theme.Red
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.core.ui.isValidInput
+
+@Composable
+fun GroupNameTextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ onValueChange: (String) -> Unit,
+ placeholder: String = "ex) SNS",
+ trailingIcon: Painter,
+ warningGuideText: String,
+ validGuideText: String,
+ keyboardActions: KeyboardActions,
+) {
+ Column(
+ modifier = modifier,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.Absolute.SpaceBetween,
+ ) {
+ Text(
+ text = "그룹명",
+ color = Gray200,
+ style = BrakeTheme.typography.body16M,
+ )
+
+ Text(
+ text = "${value.length} / 10",
+ color = when {
+ value.isValidInput() -> Green
+ value.isEmpty() -> White
+ else -> Red
+ },
+ style = BrakeTheme.typography.body12M,
+ )
+ }
+
+ VerticalSpacer(8.dp)
+
+ BaseTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = value,
+ onValueChange = onValueChange,
+ placeholder = placeholder,
+ trailingIcon = if (value.isValidInput()) trailingIcon else null,
+ supportingText = {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Start,
+ ) {
+ if (!value.isValidInput()) {
+ Text(
+ text = if (value.isEmpty()) {
+ " "
+ } else {
+ warningGuideText
+ },
+ color = Red,
+ style = BrakeTheme.typography.body12M,
+ )
+ } else {
+ Text(
+ text = validGuideText,
+ color = Green,
+ style = BrakeTheme.typography.body12M,
+ )
+ }
+ }
+ },
+ keyboardActions = keyboardActions,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun GroupNameTextFieldPreview() {
+ BrakeTheme {
+ GroupNameTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = "",
+ onValueChange = {},
+ trailingIcon = painterResource(DesignSystemRes.drawable.ic_check),
+ warningGuideText = "공백, 특수문자 없이 2~10자를 입력해 주세요.",
+ validGuideText = "사용 가능한 그룹명입니다.",
+ keyboardActions = KeyboardActions(),
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun GroupNameTextFieldPreviewWithError() {
+ BrakeTheme {
+ GroupNameTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = "S",
+ onValueChange = {},
+ trailingIcon = painterResource(DesignSystemRes.drawable.ic_check),
+ warningGuideText = "공백, 특수문자 없이 2~10자를 입력해 주세요.",
+ validGuideText = "사용 가능한 그룹명입니다.",
+ keyboardActions = KeyboardActions(),
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/LazyColumnScrollBar.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/LazyColumnScrollBar.kt
new file mode 100644
index 00000000..6ea85b35
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/LazyColumnScrollBar.kt
@@ -0,0 +1,103 @@
+package com.teambrake.brake.presentation.registry.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Composable
+fun LazyColumnScrollBar(
+ lazyListState: LazyListState,
+ hidable: Boolean = true,
+ color: Color = Color.LightGray,
+ width: Int = 12,
+ modifier: Modifier = Modifier,
+) {
+ val height by remember(lazyListState) {
+ derivedStateOf {
+ val columnHeight = lazyListState.layoutInfo.viewportSize.height
+ val totalCnt = lazyListState.layoutInfo.totalItemsCount.takeIf { it > 0 } ?: 1
+ val visibleLastIndex = lazyListState.layoutInfo.visibleItemsInfo.lastIndex
+
+ (visibleLastIndex + 1) * (columnHeight.toFloat() / totalCnt)
+ }
+ }
+
+ val topOffset by remember(lazyListState) {
+ derivedStateOf {
+ val totalCnt = lazyListState.layoutInfo.totalItemsCount.takeIf { it > 0 } ?: 1
+ val visibleCnt =
+ lazyListState.layoutInfo.visibleItemsInfo.count().takeIf { it > 0 } ?: 1
+ val columnHeight = lazyListState.layoutInfo.viewportSize.height
+ val firstVisibleIndex = lazyListState.firstVisibleItemIndex
+ val scrollItemHeight = (columnHeight.toFloat() / totalCnt)
+ val realItemHeight = (columnHeight.toFloat() / visibleCnt)
+ val offset = ((firstVisibleIndex) * scrollItemHeight)
+ val firstItemOffset =
+ lazyListState.firstVisibleItemScrollOffset / realItemHeight * scrollItemHeight
+
+ offset + firstItemOffset
+ }
+ }
+ val scope = rememberCoroutineScope()
+ var isShownScrollBar by remember(lazyListState) {
+ mutableStateOf(true)
+ }
+
+ if (hidable) {
+ var disposeJob: Job? by remember {
+ mutableStateOf(null)
+ }
+ DisposableEffect(topOffset) {
+ isShownScrollBar = true
+ onDispose {
+ disposeJob?.takeIf { it.isActive }?.cancel()
+ disposeJob = scope.launch {
+ delay(300)
+ isShownScrollBar = false
+ }
+ }
+ }
+ }
+
+ val columnSize by remember(lazyListState) {
+ derivedStateOf {
+ lazyListState.layoutInfo.viewportSize
+ }
+ }
+ AnimatedVisibility(visible = isShownScrollBar, enter = fadeIn(), exit = fadeOut()) {
+ Canvas(
+ modifier = Modifier
+ .size(width = columnSize.width.dp, height = columnSize.height.dp),
+ onDraw = {
+ val barWidthPx = width.dp.toPx()
+ val cornerRadius = CornerRadius(x = barWidthPx / 2, y = barWidthPx / 2)
+ drawRoundRect(
+ color = color,
+ topLeft = Offset(this.size.width - width, topOffset),
+ size = Size(width.toFloat(), height),
+ cornerRadius = cornerRadius,
+ )
+ },
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/SearchTextField.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/SearchTextField.kt
new file mode 100644
index 00000000..b2f948be
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/component/SearchTextField.kt
@@ -0,0 +1,42 @@
+package com.teambrake.brake.presentation.registry.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.teambrake.brake.core.designsystem.component.BaseTextField
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.presentation.home.R
+
+@Composable
+fun SearchTextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ onValueChange: (String) -> Unit,
+ placeholder: String = "앱 또는 카테고리 검색",
+ keyboardActions: KeyboardActions,
+) {
+ BaseTextField(
+ modifier = modifier,
+ value = value,
+ onValueChange = onValueChange,
+ placeholder = placeholder,
+ leadingIcon = painterResource(R.drawable.ic_search),
+ keyboardActions = keyboardActions,
+ )
+}
+
+@Preview
+@Composable
+private fun SearchTextFieldPreview() {
+ BrakeTheme {
+ SearchTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = "",
+ onValueChange = {},
+ keyboardActions = KeyboardActions(),
+ )
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/AppModel.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/AppModel.kt
new file mode 100644
index 00000000..c38d5875
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/AppModel.kt
@@ -0,0 +1,24 @@
+package com.teambrake.brake.presentation.registry.model
+
+import android.graphics.drawable.Drawable
+import androidx.compose.runtime.Stable
+import com.teambrake.brake.core.appscanner.AppMetaData
+
+@Stable
+data class AppModel(
+ val isSelected: Boolean,
+ val name: String,
+ val packageName: String,
+ val icon: Drawable?,
+) {
+ companion object {
+ val initialAppsMapper: (AppMetaData) -> AppModel = { appMetaData ->
+ AppModel(
+ isSelected = false,
+ name = appMetaData.appName,
+ packageName = appMetaData.packageName,
+ icon = appMetaData.icon,
+ )
+ }
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistryModalState.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistryModalState.kt
new file mode 100644
index 00000000..5560696e
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistryModalState.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.presentation.registry.model
+
+import androidx.compose.runtime.Immutable
+
+sealed interface RegistryModalState {
+ @Immutable
+ data object Idle : RegistryModalState
+
+ @Immutable
+ data object ShowGroupDeletionWarning : RegistryModalState
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistryNavState.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistryNavState.kt
new file mode 100644
index 00000000..2f6605b0
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistryNavState.kt
@@ -0,0 +1,8 @@
+package com.teambrake.brake.presentation.registry.model
+
+import androidx.compose.runtime.Immutable
+
+sealed interface RegistryNavState {
+ @Immutable
+ data object NavigateToHome : RegistryNavState
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistrySnackBarState.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistrySnackBarState.kt
new file mode 100644
index 00000000..7615a374
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistrySnackBarState.kt
@@ -0,0 +1,12 @@
+package com.teambrake.brake.presentation.registry.model
+
+import androidx.compose.runtime.Immutable
+import com.teambrake.brake.core.ui.UiString
+
+sealed interface RegistrySnackBarState {
+ @Immutable
+ data class Success(val uiString: UiString) : RegistrySnackBarState
+
+ @Immutable
+ data class Error(val uiString: UiString) : RegistrySnackBarState
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistryUiState.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistryUiState.kt
new file mode 100644
index 00000000..f931eabb
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/RegistryUiState.kt
@@ -0,0 +1,40 @@
+package com.teambrake.brake.presentation.registry.model
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.PersistentList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+sealed class RegistryUiState(
+ open val groupId: Long,
+ open val groupName: String,
+ open val apps: PersistentList,
+ open val selectedApps: PersistentList,
+) {
+ @Stable
+ data class App(
+ val searchingText: String = "",
+ override val groupId: Long,
+ override val groupName: String,
+ override val apps: PersistentList,
+ override val selectedApps: PersistentList,
+ ) : RegistryUiState(groupId, groupName, apps, selectedApps)
+
+ sealed interface Group {
+ @Stable
+ data class Initial(
+ override val groupId: Long,
+ override val groupName: String = "",
+ override val apps: PersistentList = persistentListOf(),
+ override val selectedApps: PersistentList,
+ ) : RegistryUiState(groupId, groupName, apps, selectedApps)
+
+ @Stable
+ data class Updated(
+ override val groupId: Long,
+ override val groupName: String,
+ override val apps: PersistentList,
+ override val selectedApps: PersistentList,
+ ) : RegistryUiState(groupId, groupName, apps, selectedApps)
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/SelectedAppModel.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/SelectedAppModel.kt
new file mode 100644
index 00000000..ca45f430
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/model/SelectedAppModel.kt
@@ -0,0 +1,13 @@
+package com.teambrake.brake.presentation.registry.model
+
+import android.graphics.drawable.Drawable
+import androidx.compose.runtime.Stable
+
+@Stable
+data class SelectedAppModel(
+ val index: Int,
+ val name: String,
+ val packageName: String,
+ val icon: Drawable?,
+ val id: Long?,
+)
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/navigation/RegistryNavigation.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/navigation/RegistryNavigation.kt
new file mode 100644
index 00000000..f5d23ea0
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/navigation/RegistryNavigation.kt
@@ -0,0 +1,25 @@
+package com.teambrake.brake.presentation.registry.navigation
+
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.teambrake.brake.core.navigation.route.SubRoute
+import com.teambrake.brake.presentation.registry.RegistryRoute
+
+fun NavController.navigateToRegistry(
+ groupId: Long?,
+ navOptions: NavOptions? = null,
+) {
+ navigate(
+ route = SubRoute.Registry(groupId),
+ navOptions = navOptions,
+ )
+}
+
+fun NavGraphBuilder.registryNavGraph() {
+ composable { backstackEntry ->
+ RegistryRoute(viewModel = hiltViewModel(backstackEntry))
+ }
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/screen/AppRegistryScreen.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/screen/AppRegistryScreen.kt
new file mode 100644
index 00000000..db151a5c
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/screen/AppRegistryScreen.kt
@@ -0,0 +1,314 @@
+package com.teambrake.brake.presentation.registry.screen
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.RadioButtonColors
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.constraintlayout.compose.Dimension
+import androidx.core.graphics.drawable.toBitmap
+import com.teambrake.brake.core.designsystem.component.BaseScaffold
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.component.LargeButton
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray500
+import com.teambrake.brake.core.designsystem.theme.Gray700
+import com.teambrake.brake.core.designsystem.theme.Gray850
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.presentation.home.R
+import com.teambrake.brake.presentation.registry.component.LazyColumnScrollBar
+import com.teambrake.brake.presentation.registry.component.SearchTextField
+import com.teambrake.brake.presentation.registry.model.AppModel
+import com.teambrake.brake.presentation.registry.model.RegistryUiState
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+
+@Composable
+fun AppRegistryScreen(
+ padding: Dp,
+ registryUiState: RegistryUiState.App,
+ lazyColumnIndexFlow: SharedFlow,
+ focusManager: FocusManager,
+ onSearchTextChange: (String) -> Unit,
+ onSearchApp: () -> Unit,
+ onSelectApp: (Int) -> Unit,
+ onRegisterApps: () -> Unit,
+ onBackClick: () -> Unit,
+) {
+ val lazyListState = rememberLazyListState()
+
+ BackHandler {
+ onBackClick()
+ }
+
+ LaunchedEffect(true) {
+ lazyColumnIndexFlow.collect {
+ lazyListState.scrollToItem(index = it)
+ }
+ }
+
+ BaseScaffold(
+ contentPadding = PaddingValues(padding),
+ bottomBar = {
+ LargeButton(
+ text = stringResource(R.string.registry_app_button),
+ onClick = {
+ focusManager.clearFocus()
+ onRegisterApps()
+ },
+ modifier = Modifier
+ .padding(bottom = 24.dp)
+ .padding(horizontal = padding),
+ // 최소 한 개의 앱이 선택되어야 버튼이 활성화됨
+ enabled = registryUiState.apps.any { it.isSelected },
+ )
+ },
+ modifier = Modifier
+ .fillMaxSize()
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ ) {
+ focusManager.clearFocus()
+ },
+ ) {
+ ConstraintLayout(
+ modifier = Modifier
+ .fillMaxSize()
+ .statusBarsPadding()
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ ) {
+ focusManager.clearFocus()
+ },
+ ) {
+ val (title, searchBar, appList) = createRefs()
+
+ Column(
+ modifier = Modifier
+ .constrainAs(title) {
+ top.linkTo(parent.top)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ }
+ .padding(top = 37.dp)
+ .navigationBarsPadding(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.registry_app_titel),
+ style = BrakeTheme.typography.body16M,
+ color = White,
+ )
+
+ VerticalSpacer(8.dp)
+
+ Text(
+ text = "${registryUiState.apps.filter { it.isSelected }.size}개",
+ style = BrakeTheme.typography.subtitle18SB,
+ color = White,
+ )
+ }
+
+ SearchTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .constrainAs(searchBar) {
+ top.linkTo(title.bottom)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ }
+ .padding(vertical = 26.dp),
+ value = registryUiState.searchingText,
+ onValueChange = onSearchTextChange,
+ keyboardActions = KeyboardActions(
+ onDone = {
+ onSearchApp()
+ focusManager.clearFocus()
+ },
+ ),
+ )
+ Box(
+ modifier = Modifier
+ .padding(bottom = 83.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(Gray850)
+ .padding(16.dp)
+ .constrainAs(appList) {
+ top.linkTo(searchBar.bottom)
+ bottom.linkTo(parent.bottom)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ height = Dimension.fillToConstraints
+ }
+ .clipToBounds(),
+ ) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = lazyListState,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ items(registryUiState.apps.size) { index ->
+ AppItem(
+ app = registryUiState.apps[index],
+ onSelectClick = { onSelectApp(index) },
+ )
+ }
+ }
+ LazyColumnScrollBar(
+ lazyListState = lazyListState,
+ hidable = true,
+ color = Gray500,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun AppItem(
+ app: AppModel,
+ onSelectClick: () -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(
+ indication = null,
+ interactionSource = interactionSource,
+ onClick = onSelectClick,
+ )
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start,
+ ) {
+ RadioButton(
+ selected = app.isSelected,
+ onClick = onSelectClick,
+ modifier = Modifier.size(24.dp),
+ colors = RadioButtonColors(
+ selectedColor = White,
+ unselectedColor = Gray700,
+ disabledSelectedColor = White,
+ disabledUnselectedColor = Gray700,
+ ),
+ interactionSource = interactionSource,
+ )
+
+ HorizontalSpacer(16.dp)
+
+ Image(
+ painter = BitmapPainter(
+ app.icon?.toBitmap()?.asImageBitmap() ?: ImageBitmap(24, 24),
+ ),
+ contentDescription = null,
+ modifier = Modifier.size(28.dp),
+ )
+
+ HorizontalSpacer(12.dp)
+
+ Text(
+ text = app.name,
+ style = BrakeTheme.typography.body16M,
+ color = White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun AppScreenPreview() {
+ val apps = persistentListOf(
+ AppModel(
+ name = "Instagram",
+ packageName = "com.example.testapp",
+ icon = null,
+ isSelected = false,
+ ),
+ AppModel(
+ name = "Facebook",
+ packageName = "com.example.testapp2",
+ icon = null,
+ isSelected = true,
+ ),
+ )
+
+ AppRegistryScreen(
+ padding = 16.dp,
+ registryUiState = RegistryUiState.App(
+ groupId = 1L,
+ groupName = "",
+ apps = apps,
+ selectedApps = persistentListOf(),
+ ),
+ lazyColumnIndexFlow = MutableSharedFlow(),
+ onSearchTextChange = {},
+ onSearchApp = {},
+ onSelectApp = {},
+ onBackClick = {},
+ onRegisterApps = {},
+ focusManager = LocalFocusManager.current,
+ )
+}
+
+@Preview
+@Composable
+private fun AppItemPreview() {
+ val app = AppModel(
+ name = "Instagram",
+ packageName = "com.example.testapp",
+ icon = null,
+ isSelected = false,
+ )
+
+ AppItem(
+ app = app,
+ onSelectClick = {},
+ )
+}
diff --git a/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/screen/GroupRegistryScreen.kt b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/screen/GroupRegistryScreen.kt
new file mode 100644
index 00000000..4be0d2af
--- /dev/null
+++ b/presentation/home/src/main/java/com/teambrake/brake/presentation/registry/screen/GroupRegistryScreen.kt
@@ -0,0 +1,446 @@
+package com.teambrake.brake.presentation.registry.screen
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.constraintlayout.compose.Dimension
+import androidx.core.graphics.drawable.toBitmap
+import com.teambrake.brake.core.designsystem.component.BaseScaffold
+import com.teambrake.brake.core.designsystem.component.BrakeTopAppbar
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.component.LargeButton
+import com.teambrake.brake.core.designsystem.component.TopAppbarType
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.ButtonYellow
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.core.designsystem.theme.Gray300
+import com.teambrake.brake.core.designsystem.theme.Gray500
+import com.teambrake.brake.core.designsystem.theme.Gray800
+import com.teambrake.brake.core.designsystem.theme.Gray850
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.core.ui.isValidInput
+import com.teambrake.brake.presentation.home.R
+import com.teambrake.brake.presentation.registry.component.GroupNameTextField
+import com.teambrake.brake.presentation.registry.component.LazyColumnScrollBar
+import com.teambrake.brake.presentation.registry.model.AppModel
+import com.teambrake.brake.presentation.registry.model.RegistryUiState
+import com.teambrake.brake.presentation.registry.model.SelectedAppModel
+import com.teambrake.brake.core.designsystem.R as DesignSystemRes
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+fun GroupRegistryScreen(
+ padding: Dp,
+ registryUiState: RegistryUiState,
+ focusManager: FocusManager,
+ onGroupNameChange: (String) -> Unit,
+ onStartSelectingApps: () -> Unit,
+ onRemoveApp: (Int) -> Unit,
+ onRemoveGroup: () -> Unit,
+ onRegisterGroup: () -> Unit,
+ onBackClick: () -> Unit,
+) {
+ BackHandler {
+ onBackClick()
+ }
+
+ val colorScheme = MaterialTheme.colorScheme
+
+ BaseScaffold(
+ contentPadding = PaddingValues(padding),
+ topBar = {
+ BrakeTopAppbar(
+ title = stringResource(R.string.registry_group_screen_title),
+ appbarType = TopAppbarType.Cancel,
+ onClick = onBackClick,
+ )
+ },
+ bottomBar = {
+ LargeButton(
+ text = stringResource(R.string.registry_group_completion_button),
+ onClick = {
+ focusManager.clearFocus()
+ onRegisterGroup()
+ },
+ modifier = Modifier
+ .padding(bottom = 24.dp)
+ .padding(horizontal = padding),
+ // 최소 한 개의 앱이 선택되어야 버튼이 활성화됨
+ enabled = registryUiState.selectedApps.isNotEmpty() &&
+ registryUiState.groupName.isValidInput(),
+ )
+ },
+ modifier = Modifier
+ .fillMaxSize()
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ ) {
+ focusManager.clearFocus()
+ },
+ ) {
+ ConstraintLayout(
+ modifier = Modifier
+ .fillMaxSize()
+ .statusBarsPadding(),
+ ) {
+ val (textField, listTitle, groupList, removeButton) = createRefs()
+
+ GroupNameTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .constrainAs(textField) {
+ top.linkTo(parent.top)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ }
+ .padding(top = 28.dp),
+ value = registryUiState.groupName,
+ onValueChange = onGroupNameChange,
+ trailingIcon = painterResource(DesignSystemRes.drawable.ic_check),
+ warningGuideText = stringResource(R.string.registry_textfield_warning_text),
+ validGuideText = stringResource(R.string.registry_textfield_validation_text),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ },
+ ),
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = padding)
+ .padding(top = 28.dp)
+ .constrainAs(listTitle) {
+ top.linkTo(textField.bottom)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ },
+ horizontalArrangement = Arrangement.Absolute.SpaceBetween,
+ ) {
+ Text(
+ text = "목록",
+ color = Gray200,
+ style = BrakeTheme.typography.body16M,
+ )
+
+ Text(
+ text = "${registryUiState.selectedApps.size} 개",
+ color = White,
+ style = BrakeTheme.typography.body12M,
+ )
+ }
+
+ if (registryUiState is RegistryUiState.Group.Initial && registryUiState.selectedApps.isEmpty()) {
+ Column(
+ modifier = Modifier
+ .padding(top = 8.dp)
+ .fillMaxWidth()
+ .constrainAs(groupList) {
+ top.linkTo(listTitle.bottom)
+ bottom.linkTo(parent.bottom)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ height = Dimension.fillToConstraints
+ }
+ .clip(RoundedCornerShape(16.dp))
+ .background(Gray850),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = stringResource(R.string.registry_group_list_guide),
+ style = BrakeTheme.typography.body16M,
+ color = White,
+ textAlign = TextAlign.Center,
+ )
+
+ VerticalSpacer(28.dp)
+
+ IconButton(
+ modifier = Modifier
+ .size(52.dp)
+ .clip(CircleShape)
+ .background(ButtonYellow),
+ onClick = onStartSelectingApps,
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_plus),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = colorScheme.onPrimary,
+ )
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .padding(top = 8.dp, bottom = 75.dp)
+ .fillMaxWidth()
+ .constrainAs(groupList) {
+ top.linkTo(listTitle.bottom)
+ bottom.linkTo(removeButton.top)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ height = Dimension.fillToConstraints
+ }
+ .clip(RoundedCornerShape(16.dp))
+ .background(Gray850),
+ ) {
+ val listState = rememberLazyListState()
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(16.dp))
+ .background(Gray850)
+ .padding(16.dp)
+ .clipToBounds(),
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize(),
+ state = listState,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ items(registryUiState.selectedApps.size) { index ->
+ SelectedAppItem(
+ app = registryUiState.selectedApps[index],
+ onDeleteClick = { onRemoveApp(index) },
+ )
+ }
+ }
+ LazyColumnScrollBar(
+ lazyListState = listState,
+ hidable = true,
+ color = Gray500,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ LargeButton(
+ text = stringResource(R.string.registry_group_add_app_button),
+ modifier = Modifier
+ .padding(horizontal = padding)
+ .padding(bottom = 16.dp),
+ textStyle = BrakeTheme.typography.body14SB,
+ paddingValues = PaddingValues(horizontal = 20.dp, vertical = 14.dp),
+ leadingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.ic_plus),
+ contentDescription = null,
+ )
+ },
+ colors = ButtonColors(
+ containerColor = Gray800,
+ contentColor = White,
+ disabledContainerColor = Gray800,
+ disabledContentColor = White,
+ ),
+ onClick = onStartSelectingApps,
+ )
+ }
+
+ Row(
+ modifier = Modifier
+ .constrainAs(removeButton) {
+ bottom.linkTo(parent.bottom)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ }
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) {
+ focusManager.clearFocus()
+ onRemoveGroup()
+ },
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_delete),
+ contentDescription = null,
+ modifier = Modifier
+ .size(24.dp)
+ .padding(end = 8.dp),
+ tint = Gray300,
+ )
+
+ HorizontalSpacer(4.dp)
+
+ Text(
+ text = stringResource(R.string.registry_group_delete_group_button),
+ style = BrakeTheme.typography.body14M.copy(
+ textDecoration = TextDecoration.Underline,
+ ),
+ color = Gray300,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SelectedAppItem(
+ app: SelectedAppModel,
+ onDeleteClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp, horizontal = 12.dp),
+ horizontalArrangement = Arrangement.Absolute.SpaceBetween,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start,
+ ) {
+ Image(
+ painter = BitmapPainter(
+ // 앱이 삭제된 경우 drawable 크기가 0이므로 기본 크기 이미지로 대체
+ app.icon?.let { drawable ->
+ val w = drawable.intrinsicWidth
+ val h = drawable.intrinsicHeight
+ if (w > 0 && h > 0) drawable.toBitmap().asImageBitmap() else null
+ } ?: ImageBitmap(24, 24),
+ ),
+ contentDescription = null,
+ modifier = Modifier
+ .size(28.dp),
+ )
+
+ HorizontalSpacer(12.dp)
+
+ Text(
+ text = app.name,
+ style = BrakeTheme.typography.body16M,
+ color = White,
+ )
+ }
+
+ Icon(
+ painter = painterResource(R.drawable.ic_remove),
+ contentDescription = null,
+ modifier = Modifier
+ .padding(8.dp)
+ .size(16.dp)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) {
+ onDeleteClick()
+ },
+ tint = Gray300,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun GroupRegistryScreenPreview() {
+ BrakeTheme {
+ GroupRegistryScreen(
+ padding = 16.dp,
+ registryUiState = RegistryUiState.Group.Updated(
+ groupId = 1L,
+ groupName = "그룹 이름",
+ selectedApps = persistentListOf(
+ SelectedAppModel(
+ index = 0,
+ name = "앱1",
+ packageName = "",
+ icon = null,
+ id = 1L,
+ ),
+ SelectedAppModel(
+ index = 1,
+ name = "앱2",
+ packageName = "",
+ icon = null,
+ id = 2L,
+ ),
+ SelectedAppModel(
+ index = 2,
+ name = "앱3",
+ packageName = "",
+ icon = null,
+ id = 3L,
+ ),
+ ),
+ apps = persistentListOf(
+ AppModel(
+ name = "앱1",
+ packageName = "com.example.app1",
+ icon = null,
+ isSelected = true,
+ ),
+ AppModel(
+ name = "앱2",
+ packageName = "com.example.app2",
+ icon = null,
+ isSelected = false,
+ ),
+ AppModel(
+ name = "앱3",
+ packageName = "com.example.app3",
+ icon = null,
+ isSelected = true,
+ ),
+ ),
+ ),
+ onGroupNameChange = {},
+ onStartSelectingApps = {},
+ onRemoveApp = {},
+ onRemoveGroup = {},
+ onBackClick = {},
+ onRegisterGroup = {},
+ focusManager = LocalFocusManager.current,
+ )
+ }
+}
diff --git a/presentation/home/src/main/res/drawable/blocking_group_background.png b/presentation/home/src/main/res/drawable/blocking_group_background.png
new file mode 100644
index 00000000..e0dd96d8
Binary files /dev/null and b/presentation/home/src/main/res/drawable/blocking_group_background.png differ
diff --git a/presentation/home/src/main/res/drawable/ic_app_group.xml b/presentation/home/src/main/res/drawable/ic_app_group.xml
new file mode 100644
index 00000000..4679ac57
--- /dev/null
+++ b/presentation/home/src/main/res/drawable/ic_app_group.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/presentation/home/src/main/res/drawable/ic_blocking.xml b/presentation/home/src/main/res/drawable/ic_blocking.xml
new file mode 100644
index 00000000..7213e809
--- /dev/null
+++ b/presentation/home/src/main/res/drawable/ic_blocking.xml
@@ -0,0 +1,18 @@
+
+
+
+
diff --git a/presentation/home/src/main/res/drawable/ic_delete.xml b/presentation/home/src/main/res/drawable/ic_delete.xml
new file mode 100644
index 00000000..5d3d68de
--- /dev/null
+++ b/presentation/home/src/main/res/drawable/ic_delete.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/presentation/home/src/main/res/drawable/ic_edit.xml b/presentation/home/src/main/res/drawable/ic_edit.xml
new file mode 100644
index 00000000..993652c6
--- /dev/null
+++ b/presentation/home/src/main/res/drawable/ic_edit.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
diff --git a/presentation/home/src/main/res/drawable/ic_exit.xml b/presentation/home/src/main/res/drawable/ic_exit.xml
new file mode 100644
index 00000000..01da7f6c
--- /dev/null
+++ b/presentation/home/src/main/res/drawable/ic_exit.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/presentation/home/src/main/res/drawable/ic_need_setting.xml b/presentation/home/src/main/res/drawable/ic_need_setting.xml
new file mode 100644
index 00000000..5e52034f
--- /dev/null
+++ b/presentation/home/src/main/res/drawable/ic_need_setting.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/presentation/home/src/main/res/drawable/ic_plus.xml b/presentation/home/src/main/res/drawable/ic_plus.xml
new file mode 100644
index 00000000..c64a7a0c
--- /dev/null
+++ b/presentation/home/src/main/res/drawable/ic_plus.xml
@@ -0,0 +1,18 @@
+
+
+
+
diff --git a/presentation/home/src/main/res/drawable/ic_remove.xml b/presentation/home/src/main/res/drawable/ic_remove.xml
new file mode 100644
index 00000000..a716c5d4
--- /dev/null
+++ b/presentation/home/src/main/res/drawable/ic_remove.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/presentation/home/src/main/res/drawable/ic_search.xml b/presentation/home/src/main/res/drawable/ic_search.xml
new file mode 100644
index 00000000..eff3e2d7
--- /dev/null
+++ b/presentation/home/src/main/res/drawable/ic_search.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/presentation/home/src/main/res/drawable/ic_using.xml b/presentation/home/src/main/res/drawable/ic_using.xml
new file mode 100644
index 00000000..0ccdbe1f
--- /dev/null
+++ b/presentation/home/src/main/res/drawable/ic_using.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/presentation/home/src/main/res/drawable/img_celebration.png b/presentation/home/src/main/res/drawable/img_celebration.png
new file mode 100644
index 00000000..f1943701
Binary files /dev/null and b/presentation/home/src/main/res/drawable/img_celebration.png differ
diff --git a/presentation/home/src/main/res/drawable/img_home_init.png b/presentation/home/src/main/res/drawable/img_home_init.png
new file mode 100644
index 00000000..322b293e
Binary files /dev/null and b/presentation/home/src/main/res/drawable/img_home_init.png differ
diff --git a/presentation/home/src/main/res/drawable/img_home_list.png b/presentation/home/src/main/res/drawable/img_home_list.png
new file mode 100644
index 00000000..497541f4
Binary files /dev/null and b/presentation/home/src/main/res/drawable/img_home_list.png differ
diff --git a/presentation/home/src/main/res/drawable/img_lock.png b/presentation/home/src/main/res/drawable/img_lock.png
new file mode 100644
index 00000000..cbc7147d
Binary files /dev/null and b/presentation/home/src/main/res/drawable/img_lock.png differ
diff --git a/presentation/home/src/main/res/drawable/using_group_background.png b/presentation/home/src/main/res/drawable/using_group_background.png
new file mode 100644
index 00000000..b8b4be4b
Binary files /dev/null and b/presentation/home/src/main/res/drawable/using_group_background.png differ
diff --git a/presentation/home/src/main/res/values/strings.xml b/presentation/home/src/main/res/values/strings.xml
new file mode 100644
index 00000000..174d2176
--- /dev/null
+++ b/presentation/home/src/main/res/values/strings.xml
@@ -0,0 +1,75 @@
+
+
+
+ 추가
+ 그룹
+ 분
+ 초
+ 사용 종료
+
+
+ 사용중
+ 사용 금지
+ 사용전
+ 현재 사용 중인 그룹
+ 사용 금지 그룹
+ 사용 전 그룹
+
+
+ 스크린타임, 이제 줄여볼까요?
+ 사용을 자제할 앱을 추가해주세요.
+
+
+ 그룹 관리
+ 등록한 앱을 사용할 때\n사용 시간을 설정할 수 있어요
+ 총 %d개
+ 1개
+
+
+ 남은 사용 시간
+
+
+ 앱 사용을 종료할까요?
+ 예정보다 일찍 마무리하셨네요.\n멋진 선택이에요!
+ 종료하기
+
+
+ 추가 버튼
+ 사용 중지
+ 홈 이미지
+ 리스트 화면 이미지
+ 앱 그룹 아이콘
+ 앱 그룹 편집 아이콘
+ 기본 앱 아이콘
+ 앱 아이콘
+ 고양이 이미지
+
+
+ %s 사용 종료!\n이제 %s 앱을 사용할 수 없어요.
+
+
+ 앱 그룹 추가
+ 사용을 자제할\n앱을 선택해주세요
+ 공백, 특수문자 없이 2~10자를 입력해 주세요.
+ 사용 가능한 그룹명입니다.
+ 추가
+ 그룹 삭제
+ 완료
+
+
+ 선택 완료
+ 검색 결과가 없습니다. \'%1$s\'에 해당하는 앱이 없습니다.
+ 사용을 자제할 앱을 선택해주세요
+
+
+ 그룹을 삭제할까요?
+ 삭제한 그룹은 복구할 수 없습니다.
+ 취소
+ 삭제
+
+
+ 그룹이 추가되었습니다.
+ 그룹 생성 중 오류가 발생했습니다.
+ 그룹이 삭제되었습니다.
+ 그룹 삭제 중 오류가 발생했습니다.
+
diff --git a/presentation/home/src/main/java/com/yapp/breake/presentation/home/.gitkeep b/presentation/home/src/test/java/com/teambrake/brake/presentation/home/.gitkeep
similarity index 100%
rename from presentation/home/src/main/java/com/yapp/breake/presentation/home/.gitkeep
rename to presentation/home/src/test/java/com/teambrake/brake/presentation/home/.gitkeep
diff --git a/presentation/home/src/test/java/com/yapp/breake/presentation/home/ExampleUnitTest.kt b/presentation/home/src/test/java/com/yapp/breake/presentation/home/ExampleUnitTest.kt
deleted file mode 100644
index 6122110c..00000000
--- a/presentation/home/src/test/java/com/yapp/breake/presentation/home/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.yapp.breake.presentation.home
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/presentation/legal/.gitignore b/presentation/legal/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/presentation/legal/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/presentation/legal/build.gradle.kts b/presentation/legal/build.gradle.kts
new file mode 100644
index 00000000..23252433
--- /dev/null
+++ b/presentation/legal/build.gradle.kts
@@ -0,0 +1,13 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.feature)
+}
+
+android {
+ setNamespace("presentation.legal")
+}
+
+dependencies {
+ implementation(libs.androidx.browser)
+}
diff --git a/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/navigation/LegalNavigation.kt b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/navigation/LegalNavigation.kt
new file mode 100644
index 00000000..2a5a8afa
--- /dev/null
+++ b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/navigation/LegalNavigation.kt
@@ -0,0 +1,26 @@
+package com.teambrake.brake.presentation.legal.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.teambrake.brake.core.navigation.route.SubRoute
+import com.teambrake.brake.presentation.legal.privacy.PrivacyRoute
+import com.teambrake.brake.presentation.legal.terms.TermsRoute
+
+fun NavController.navigateToPrivacy(navOptions: NavOptions? = null) {
+ navigate(SubRoute.Privacy, navOptions)
+}
+
+fun NavController.navigateToTerms(navOptions: NavOptions? = null) {
+ navigate(SubRoute.Terms, navOptions)
+}
+
+fun NavGraphBuilder.legalNavGraph() {
+ composable {
+ PrivacyRoute()
+ }
+ composable {
+ TermsRoute()
+ }
+}
diff --git a/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/privacy/PrivacyScreen.kt b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/privacy/PrivacyScreen.kt
new file mode 100644
index 00000000..b22b6d35
--- /dev/null
+++ b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/privacy/PrivacyScreen.kt
@@ -0,0 +1,58 @@
+package com.teambrake.brake.presentation.legal.privacy
+
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_OFF
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.graphics.drawable.toBitmap
+import androidx.core.net.toUri
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.teambrake.brake.core.designsystem.R
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+
+@Composable
+fun PrivacyRoute(
+ viewModel: PrivacyViewModel = hiltViewModel(),
+) {
+ val navAction = LocalNavigatorAction.current
+
+ PrivacyScreen(
+ onBack = { viewModel.onBackPressed(navAction::popBackStack) },
+ )
+}
+
+@Composable
+fun PrivacyScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ DisposableEffect(Unit) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ onBack()
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ val customTabsIntent = CustomTabsIntent.Builder().apply {
+ setBookmarksButtonEnabled(false)
+ setDownloadButtonEnabled(false)
+ setShareState(SHARE_STATE_OFF)
+ setUrlBarHidingEnabled(true)
+ setShowTitle(true)
+ setInstantAppsEnabled(false)
+ setCloseButtonIcon(context.getDrawable(R.drawable.ic_back_24)!!.toBitmap())
+ }.build()
+ customTabsIntent.launchUrl(context, PRIVACY_BASE_URL.toUri())
+}
+
+private const val PRIVACY_BASE_URL =
+ "https://ahnsh.notion.site/Brake-223b76e3040280438cc7ec812de68c0f?pvs=74"
diff --git a/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/privacy/PrivacyViewModel.kt b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/privacy/PrivacyViewModel.kt
new file mode 100644
index 00000000..5b6ea47b
--- /dev/null
+++ b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/privacy/PrivacyViewModel.kt
@@ -0,0 +1,16 @@
+package com.teambrake.brake.presentation.legal.privacy
+
+import androidx.lifecycle.ViewModel
+import com.google.firebase.analytics.FirebaseAnalytics
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class PrivacyViewModel @Inject constructor(
+ private val firebaseAnalytics: FirebaseAnalytics,
+) : ViewModel() {
+ fun onBackPressed(onBack: () -> Unit) {
+ firebaseAnalytics.logEvent("privacy_policy_back_pressed", null)
+ onBack()
+ }
+}
diff --git a/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/privacy/model/PrivacyUiState.kt b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/privacy/model/PrivacyUiState.kt
new file mode 100644
index 00000000..ac023098
--- /dev/null
+++ b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/privacy/model/PrivacyUiState.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.presentation.legal.privacy.model
+
+import androidx.compose.runtime.Immutable
+
+sealed interface PrivacyUiState {
+ @Immutable
+ data object PrivacyIdle : PrivacyUiState
+
+ @Immutable
+ data object PrivacyError : PrivacyUiState
+}
diff --git a/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/terms/TermsScreen.kt b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/terms/TermsScreen.kt
new file mode 100644
index 00000000..f49c0ba8
--- /dev/null
+++ b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/terms/TermsScreen.kt
@@ -0,0 +1,58 @@
+package com.teambrake.brake.presentation.legal.terms
+
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_OFF
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.graphics.drawable.toBitmap
+import androidx.core.net.toUri
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.teambrake.brake.core.designsystem.R
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+
+@Composable
+fun TermsRoute(
+ viewModel: TermsViewModel = hiltViewModel(),
+) {
+ val navAction = LocalNavigatorAction.current
+
+ TermsScreen(
+ onBack = { viewModel.onBackPressed(navAction::popBackStack) },
+ )
+}
+
+@Composable
+fun TermsScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ DisposableEffect(Unit) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ onBack()
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ val customTabsIntent = CustomTabsIntent.Builder().apply {
+ setBookmarksButtonEnabled(false)
+ setDownloadButtonEnabled(false)
+ setShareState(SHARE_STATE_OFF)
+ setUrlBarHidingEnabled(true)
+ setShowTitle(true)
+ setInstantAppsEnabled(false)
+ setCloseButtonIcon(context.getDrawable(R.drawable.ic_back_24)!!.toBitmap())
+ }.build()
+ customTabsIntent.launchUrl(context, TERMS_BASE_URL.toUri())
+}
+
+private const val TERMS_BASE_URL =
+ "https://ahnsh.notion.site/Brake-223b76e3040280f3b9e0d70c83e239ac?pvs=143"
diff --git a/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/terms/TermsViewModel.kt b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/terms/TermsViewModel.kt
new file mode 100644
index 00000000..17224a29
--- /dev/null
+++ b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/terms/TermsViewModel.kt
@@ -0,0 +1,16 @@
+package com.teambrake.brake.presentation.legal.terms
+
+import androidx.lifecycle.ViewModel
+import com.google.firebase.analytics.FirebaseAnalytics
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class TermsViewModel @Inject constructor(
+ private val firebaseAnalytics: FirebaseAnalytics,
+) : ViewModel() {
+ fun onBackPressed(onBack: () -> Unit) {
+ firebaseAnalytics.logEvent("privacy_policy_back_pressed", null)
+ onBack()
+ }
+}
diff --git a/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/terms/model/TermsUiState.kt b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/terms/model/TermsUiState.kt
new file mode 100644
index 00000000..c1d1a98c
--- /dev/null
+++ b/presentation/legal/src/main/java/com/teambrake/brake/presentation/legal/terms/model/TermsUiState.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.presentation.legal.terms.model
+
+import androidx.compose.runtime.Immutable
+
+sealed interface TermsUiState {
+ @Immutable
+ data object PrivacyIdle : TermsUiState
+
+ @Immutable
+ data object PrivacyError : TermsUiState
+}
diff --git a/presentation/login/build.gradle.kts b/presentation/login/build.gradle.kts
index 88b499f2..a38dc50f 100644
--- a/presentation/login/build.gradle.kts
+++ b/presentation/login/build.gradle.kts
@@ -1,9 +1,16 @@
-import com.yapp.breake.setNamespace
+import com.teambrake.brake.setNamespace
plugins {
- alias(libs.plugins.breake.android.feature)
+ alias(libs.plugins.brake.android.feature)
}
android {
setNamespace("presentation.login")
-}
\ No newline at end of file
+}
+
+dependencies {
+ implementation(projects.core.auth)
+ implementation(projects.core.permission)
+
+ implementation(libs.google.auth)
+}
diff --git a/presentation/login/src/androidTest/java/com/yapp/breake/presentation/login/ExampleInstrumentedTest.kt b/presentation/login/src/androidTest/java/com/android/brake/presentation/login/ExampleInstrumentedTest.kt
similarity index 80%
rename from presentation/login/src/androidTest/java/com/yapp/breake/presentation/login/ExampleInstrumentedTest.kt
rename to presentation/login/src/androidTest/java/com/android/brake/presentation/login/ExampleInstrumentedTest.kt
index 65afe41b..4cc385d8 100644
--- a/presentation/login/src/androidTest/java/com/yapp/breake/presentation/login/ExampleInstrumentedTest.kt
+++ b/presentation/login/src/androidTest/java/com/android/brake/presentation/login/ExampleInstrumentedTest.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake.presentation.login
+package com.teambrake.brake.presentation.login
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.yapp.breake.presentation.login.test", appContext.packageName)
+ assertEquals("com.teambrake.brake.presentation.login.test", appContext.packageName)
}
-}
\ No newline at end of file
+}
diff --git a/presentation/login/src/main/java/com/teambrake/brake/presentation/login/LoginScreen.kt b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/LoginScreen.kt
new file mode 100644
index 00000000..52d58831
--- /dev/null
+++ b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/LoginScreen.kt
@@ -0,0 +1,259 @@
+package com.teambrake.brake.presentation.login
+
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.google.android.gms.auth.api.identity.Identity
+import com.google.android.gms.common.api.ApiException
+import com.teambrake.brake.core.auth.kakao.KakaoScreen
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.LocalDynamicPaddings
+import com.teambrake.brake.core.designsystem.theme.LocalPadding
+import com.teambrake.brake.core.navigation.compositionlocal.LocalMainAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorProvider
+import com.teambrake.brake.core.ui.SnackBarState
+import com.teambrake.brake.presentation.login.component.GoogleLoginButton
+import com.teambrake.brake.presentation.login.component.KakaoLoginButton
+import com.teambrake.brake.presentation.login.component.LoginNoticeText
+import com.teambrake.brake.presentation.login.model.LoginNavState.NavigateToHome
+import com.teambrake.brake.presentation.login.model.LoginNavState.NavigateToOnboarding
+import com.teambrake.brake.presentation.login.model.LoginNavState.NavigateToPermission
+import com.teambrake.brake.presentation.login.model.LoginNavState.NavigateToPrivacyPolicy
+import com.teambrake.brake.presentation.login.model.LoginNavState.NavigateToSignup
+import com.teambrake.brake.presentation.login.model.LoginNavState.NavigateToTermsOfService
+import com.teambrake.brake.presentation.login.model.LoginUiState
+import timber.log.Timber
+
+@Composable
+internal fun LoginRoute(viewModel: LoginViewModel = hiltViewModel()) {
+ val context = LocalContext.current
+ val padding = LocalPadding.current.screenPaddingHorizontal
+ val navAction = LocalNavigatorAction.current
+ val navProvider = LocalNavigatorProvider.current
+ val mainAction = LocalMainAction.current
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ if (uiState == LoginUiState.LoginLoading) {
+ mainAction.OnShowLoading()
+ BackHandler {
+ viewModel.cancelLogin()
+ }
+ } else {
+ mainAction.OnFinishBackHandler()
+ }
+
+ LaunchedEffect(true) {
+ viewModel.snackBarFlow.collect {
+ when (it) {
+ is SnackBarState.Error -> mainAction.onShowErrorMessage(
+ message = it.uiString.asString(context),
+ )
+
+ is SnackBarState.Success -> mainAction.onShowSuccessMessage(
+ message = it.uiString.asString(context),
+ )
+ }
+ }
+ }
+
+ LaunchedEffect(true) {
+ viewModel.navigationFlow.collect { navigation ->
+ when (navigation) {
+ NavigateToPrivacyPolicy -> navAction.navigateToPrivacy()
+ NavigateToTermsOfService -> navAction.navigateToTerms()
+ NavigateToHome -> navAction.navigateToHome(
+ navOptions = navProvider.getNavOptionsClearingBackStack(),
+ )
+ NavigateToSignup -> navAction.navigateToSignup()
+ NavigateToOnboarding -> navAction.navigateToGuide()
+ NavigateToPermission -> navAction.navigateToPermission()
+ }
+ }
+ }
+
+ val authorizationLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartIntentSenderForResult(),
+ ) { result ->
+ try {
+ // 결과에서 AuthorizationResult 추출
+ val authorizationResult = Identity.getAuthorizationClient(context)
+ .getAuthorizationResultFromIntent(result.data)
+
+ authorizationResult.serverAuthCode?.let { code ->
+ Timber.d("Google One Tap 로그인 성공, code: $code")
+ viewModel.loginWithGoogle(context = context, authCode = code)
+ } ?: run {
+ Timber.d("Google One Tap 로그인 취소 또는 실패")
+ viewModel.cancelGoogleAuthorization()
+ }
+ } catch (e: ApiException) {
+ when (e.statusCode) {
+ 7 -> {
+ Timber.e("네트워크 문제로 Google 로그인에 실패했습니다")
+ viewModel.failGoogleAuthorization()
+ }
+ 16 -> {
+ Timber.d("유저가 Google 로그인 창을 닫았습니다")
+ viewModel.cancelGoogleAuthorization()
+ }
+ else -> {
+ Timber.e(e, "Google 로그인 API 예외 발생")
+ viewModel.failGoogleAuthorization()
+ }
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Google One Tap 로그인 알 수 없는 예외 발생")
+ viewModel.failGoogleAuthorization()
+ }
+ }
+
+ LoginScreen(
+ padding = padding,
+ onPrivacyClick = viewModel::showPrivacyPolicy,
+ onTermsClick = viewModel::showTermsOfService,
+ onGoogleLoginClick = {
+ viewModel.getGoogleAuthorization(context) { intent ->
+ authorizationLauncher.launch(intent)
+ }
+ },
+ onkakaoLoginClick = viewModel::getKakaoAuthorization,
+ )
+
+ if (uiState == LoginUiState.LoginOnWebView) {
+ KakaoScreen(
+ onBack = viewModel::cancelKakaoAuthorization,
+ onAuthSuccess = { viewModel.loginWithKakao(context = context, authCode = it) },
+ onAuthError = viewModel::failKakaoAuthorization,
+ )
+ }
+}
+
+@Composable
+fun LoginScreen(
+ padding: Dp,
+ onPrivacyClick: () -> Unit,
+ onTermsClick: () -> Unit,
+ onGoogleLoginClick: () -> Unit,
+ onkakaoLoginClick: () -> Unit,
+) {
+ val density = LocalDensity.current
+ val dynamicPaddingsProvider = LocalDynamicPaddings.current
+
+ var googleButtonHeight by remember { mutableStateOf(0.dp) }
+ var kakaoButtonHeight by remember { mutableStateOf(0.dp) }
+
+ val buttonSpacing = 12.dp
+ val bottomPadding = 24.dp
+
+ LaunchedEffect(googleButtonHeight, kakaoButtonHeight) {
+ if (googleButtonHeight > 0.dp && kakaoButtonHeight > 0.dp) {
+ val totalHeight = googleButtonHeight + kakaoButtonHeight + buttonSpacing + bottomPadding
+ dynamicPaddingsProvider.updateTwoButtonHeight(totalHeight)
+ }
+ }
+
+ ConstraintLayout(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ val (title, notice, googleLoginButton, kakaoLoginButton) = createRefs()
+
+ Box(
+ modifier = Modifier
+ .constrainAs(title) {
+ top.linkTo(parent.top)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ },
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Image(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(bottom = 8.dp),
+ painter = painterResource(R.drawable.img_login),
+ contentDescription = null,
+ )
+
+ Text(
+ text = stringResource(R.string.login_main_message),
+ modifier = Modifier.align(Alignment.BottomCenter),
+ textAlign = TextAlign.Center,
+ style = BrakeTheme.typography.subtitle22SB,
+ )
+ }
+
+ LoginNoticeText(
+ modifier = Modifier
+ .constrainAs(notice) {
+ top.linkTo(title.bottom)
+ bottom.linkTo(googleLoginButton.top, margin = 20.dp)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ // top, bottom 과 linkTo 관계가 설정되어 있을 때, 해당 컴포넌트 y 위치를 바텀(1f)으로 조정
+ verticalBias = 1f
+ }
+ .padding(horizontal = padding),
+ onPrivacyClick = onPrivacyClick,
+ onTermsClick = onTermsClick,
+ )
+
+ GoogleLoginButton(
+ modifier = Modifier
+ .padding(horizontal = padding)
+ .widthIn(max = 400.dp)
+ .onGloballyPositioned { coordinates ->
+ googleButtonHeight = with(density) { coordinates.size.height.toDp() }
+ }
+ .constrainAs(googleLoginButton) {
+ bottom.linkTo(kakaoLoginButton.top, margin = 12.dp)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ },
+ onClick = onGoogleLoginClick,
+ )
+
+ KakaoLoginButton(
+ modifier = Modifier
+ .navigationBarsPadding()
+ .padding(horizontal = padding)
+ .padding(bottom = 24.dp)
+ .widthIn(max = 400.dp)
+ .onGloballyPositioned { coordinates ->
+ kakaoButtonHeight = with(density) { coordinates.size.height.toDp() }
+ }
+ .constrainAs(kakaoLoginButton) {
+ bottom.linkTo(parent.bottom)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ },
+ onClick = onkakaoLoginClick,
+ )
+ }
+}
diff --git a/presentation/login/src/main/java/com/teambrake/brake/presentation/login/LoginViewModel.kt b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/LoginViewModel.kt
new file mode 100644
index 00000000..02cb0a05
--- /dev/null
+++ b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/LoginViewModel.kt
@@ -0,0 +1,284 @@
+package com.teambrake.brake.presentation.login
+
+import android.content.Context
+import androidx.activity.result.IntentSenderRequest
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.analytics.logEvent
+import com.teambrake.brake.core.auth.google.GoogleAuthManager
+import com.teambrake.brake.core.model.user.Destination
+import com.teambrake.brake.core.model.user.UserStatus
+import com.teambrake.brake.core.permission.PermissionManager
+import com.teambrake.brake.core.permission.PermissionType
+import com.teambrake.brake.core.ui.SnackBarState
+import com.teambrake.brake.core.ui.UiString
+import com.teambrake.brake.domain.usecase.DecideNextDestinationFromPermissionUseCase
+import com.teambrake.brake.domain.usecase.LoginUseCase
+import com.teambrake.brake.presentation.login.model.LoginNavState
+import com.teambrake.brake.presentation.login.model.LoginUiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltViewModel
+internal class LoginViewModel @Inject constructor(
+ private val loginUseCase: LoginUseCase,
+ private val decideDestinationUseCase: DecideNextDestinationFromPermissionUseCase,
+ private val permissionManager: PermissionManager,
+ private val googleAuthManager: GoogleAuthManager,
+ private val firebaseAnalytics: FirebaseAnalytics,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(LoginUiState.LoginIdle)
+ val uiState = _uiState.asStateFlow()
+
+ private val _snackBarFlow = MutableSharedFlow()
+ val snackBarFlow = _snackBarFlow.asSharedFlow()
+
+ private val _navigationFlow = MutableSharedFlow()
+ val navigationFlow = _navigationFlow.asSharedFlow()
+
+ private var loginJob: Job? = null
+
+ fun showPrivacyPolicy() {
+ viewModelScope.launch {
+ _navigationFlow.emit(LoginNavState.NavigateToPrivacyPolicy)
+ }
+ }
+
+ fun showTermsOfService() {
+ viewModelScope.launch {
+ _navigationFlow.emit(LoginNavState.NavigateToTermsOfService)
+ }
+ }
+
+ fun loginWithGoogle(context: Context, authCode: String) {
+ loginService(context, authCode, GOOGLE_LOGIN)
+ }
+
+ fun getGoogleAuthorization(
+ context: Context,
+ onRequestGoogleAuth: (IntentSenderRequest) -> Unit,
+ ) {
+ _uiState.value = LoginUiState.LoginLoading
+ googleAuthManager.requestGoogleAuthorization(
+ context = context,
+ onRequestGoogleAuth = onRequestGoogleAuth,
+ onFailure = {
+ cancelGoogleAuthorization()
+ },
+ onAlertUpdateGooglePlayServices = ::alterGoogleServicesUpdate,
+ )
+ }
+
+ fun cancelGoogleAuthorization() {
+ _uiState.value = LoginUiState.LoginIdle
+ }
+
+ fun failGoogleAuthorization() {
+ _uiState.value = LoginUiState.LoginIdle
+ viewModelScope.launch {
+ _snackBarFlow.emit(
+ SnackBarState.Error(
+ uiString = UiString.ResourceString(
+ resId = R.string.login_snackbar_login_error,
+ ),
+ ),
+ )
+ }
+ }
+
+ private fun alterGoogleServicesUpdate() {
+ _uiState.value = LoginUiState.LoginIdle
+ viewModelScope.launch {
+ _snackBarFlow.emit(
+ SnackBarState.Error(
+ uiString = UiString.ResourceString(
+ resId = R.string.login_snackbar_login_error_need_google_services_update,
+ ),
+ ),
+ )
+ }
+ }
+
+ fun loginWithKakao(context: Context, authCode: String) {
+ loginService(context, authCode, KAKAO_LOGIN)
+ }
+
+ fun getKakaoAuthorization() {
+ viewModelScope.launch {
+ _uiState.value = LoginUiState.LoginOnWebView
+ }
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "kakao_webView_activity")
+ }
+ }
+
+ fun cancelKakaoAuthorization() {
+ _uiState.value = LoginUiState.LoginIdle
+ firebaseAnalytics.apply {
+ logEvent("cancel_kakao_login") {
+ param("reason", "user_cancel")
+ }
+ logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "login_screen")
+ }
+ }
+ }
+
+ fun failKakaoAuthorization(message: String) {
+ viewModelScope.launch {
+ _uiState.value = LoginUiState.LoginIdle
+ _snackBarFlow.emit(
+ SnackBarState.Error(
+ uiString = UiString.DynamicString(message),
+ ),
+ )
+ }
+ firebaseAnalytics.apply {
+ logEvent("cancel_kakao_login") {
+ param("reason", message)
+ }
+ logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "login_screen")
+ }
+ }
+ }
+
+ fun cancelLogin() {
+ loginJob?.run {
+ cancel()
+ _uiState.value = LoginUiState.LoginIdle
+ }
+ firebaseAnalytics.apply {
+ logEvent("cancel_server_login") {
+ param("reason", "user_cancel")
+ }
+ logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "login_screen")
+ }
+ }
+ }
+
+ private fun loginService(context: Context, authCode: String, provider: String) {
+ loginJob?.cancel()
+ loginJob = viewModelScope.launch {
+ _uiState.value = LoginUiState.LoginLoading
+ loginUseCase(
+ authCode = authCode,
+ provider = provider,
+ onError = { throwable ->
+ _uiState.value = LoginUiState.LoginIdle
+ _snackBarFlow.emit(
+ SnackBarState.Error(
+ uiString = UiString.ResourceString(
+ resId = R.string.login_snackbar_login_error,
+ ),
+ ),
+ )
+ firebaseAnalytics.logEvent("cancel_server_login") {
+ param("reason", "server_error")
+ }
+ },
+ ).catch {
+ Timber.e(it, "로그인 중 에러 발생")
+ }.collect { result ->
+ when (result) {
+ UserStatus.ACTIVE -> {
+ _uiState.value = LoginUiState.LoginIdle
+ decideNextDestination(context)
+ }
+
+ UserStatus.HALF_SIGNUP -> {
+ _uiState.value = LoginUiState.LoginIdle
+ _navigationFlow.emit(LoginNavState.NavigateToSignup)
+ }
+
+ UserStatus.INACTIVE -> {
+ _uiState.value = LoginUiState.LoginIdle
+ _snackBarFlow.emit(
+ SnackBarState.Error(
+ uiString = UiString.ResourceString(
+ resId = R.string.login_snackbar_login_error_inactive,
+ ),
+ ),
+ )
+ firebaseAnalytics.logEvent("cancel_server_login") {
+ param("reason", "server_not_allowed")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun checkPermissions(context: Context): Boolean {
+ if (!permissionManager.isGranted(context, PermissionType.OVERLAY)) return false
+ if (!permissionManager.isGranted(context, PermissionType.STATS)) return false
+ if (!permissionManager.isGranted(context, PermissionType.EXACT_ALARM)) return false
+ if (!permissionManager.isGranted(context, PermissionType.ACCESSIBILITY)) return false
+ return true
+ }
+
+ private suspend fun decideNextDestination(context: Context) {
+ val status = decideDestinationUseCase(
+ onError = { error ->
+ _snackBarFlow.emit(
+ SnackBarState.Error(
+ uiString = UiString.ResourceString(
+ resId = R.string.login_snackbar_next_destination_error,
+ ),
+ ),
+ )
+ firebaseAnalytics.apply {
+ logEvent("cancel_client_login") {
+ param("reason", "client_error")
+ }
+ logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "login_screen")
+ }
+ }
+ },
+ )
+ when (status) {
+ is Destination.PermissionOrHome -> if (checkPermissions(context)) {
+ _navigationFlow.emit(LoginNavState.NavigateToHome)
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.LOGIN) {
+ param(FirebaseAnalytics.Param.METHOD, "user_login")
+ }
+ } else {
+ _navigationFlow.emit(LoginNavState.NavigateToPermission)
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.LOGIN) {
+ param(FirebaseAnalytics.Param.METHOD, "user_login")
+ }
+ }
+
+ is Destination.Onboarding -> {
+ _navigationFlow.emit(
+ LoginNavState.NavigateToOnboarding,
+ )
+ firebaseAnalytics.apply {
+ logEvent(FirebaseAnalytics.Event.LOGIN) {
+ param(FirebaseAnalytics.Param.METHOD, "user_login")
+ }
+ logEvent(FirebaseAnalytics.Event.TUTORIAL_BEGIN, null)
+ }
+ }
+
+ else -> {}
+ }
+ }
+
+ companion object {
+ const val GOOGLE_LOGIN = "GOOGLE"
+ const val KAKAO_LOGIN = "KAKAO"
+ }
+}
diff --git a/presentation/login/src/main/java/com/teambrake/brake/presentation/login/component/GoogleLoginButton.kt b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/component/GoogleLoginButton.kt
new file mode 100644
index 00000000..85e3a91e
--- /dev/null
+++ b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/component/GoogleLoginButton.kt
@@ -0,0 +1,114 @@
+package com.teambrake.brake.presentation.login.component
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.core.designsystem.theme.Gray700
+import com.teambrake.brake.core.designsystem.util.BooleanProvider
+import com.teambrake.brake.core.designsystem.util.MultipleEventsCutter
+import com.teambrake.brake.core.designsystem.util.get
+import com.teambrake.brake.presentation.login.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun GoogleLoginButton(
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter.get() }
+
+ Button(
+ shape = MaterialTheme.shapes.large,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF141414),
+ contentColor = Color(0xFFe4e4e4),
+ disabledContainerColor = Gray700,
+ disabledContentColor = Gray200,
+ ),
+ border = BorderStroke(
+ width = 1.dp,
+ color = Color(0xFF8f918f),
+ ),
+ contentPadding = PaddingValues(16.dp),
+ enabled = enabled,
+ onClick = { multipleEventsCutter.processEvent(onClick) },
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start,
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(0.2f)
+ .wrapContentHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_g),
+ contentDescription = "구글 로고",
+ modifier = Modifier.padding(end = 8.dp),
+ tint = null,
+ )
+ }
+
+ // 가운데 텍스트 영역 (전체 폭의 60%)
+ Box(
+ modifier = Modifier
+ .weight(0.6f)
+ .wrapContentHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(R.string.login_google_button_text),
+ style = BrakeTheme.typography.subtitle16B,
+ )
+ }
+
+ // 오른쪽 빈 공간 영역 (전체 폭의 20%)
+ Box(
+ modifier = Modifier
+ .weight(0.2f)
+ .wrapContentHeight(),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun GoogleLoginButtonPreview(
+ @PreviewParameter(BooleanProvider::class) enabled: Boolean,
+) {
+ BrakeTheme {
+ GoogleLoginButton(
+ onClick = { },
+ enabled = enabled,
+ )
+ }
+}
diff --git a/presentation/login/src/main/java/com/teambrake/brake/presentation/login/component/KakaoLoginButton.kt b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/component/KakaoLoginButton.kt
new file mode 100644
index 00000000..a2823524
--- /dev/null
+++ b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/component/KakaoLoginButton.kt
@@ -0,0 +1,108 @@
+package com.teambrake.brake.presentation.login.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.core.designsystem.theme.Gray700
+import com.teambrake.brake.core.designsystem.theme.KakaoYellow
+import com.teambrake.brake.core.designsystem.util.BooleanProvider
+import com.teambrake.brake.core.designsystem.util.MultipleEventsCutter
+import com.teambrake.brake.core.designsystem.util.get
+import com.teambrake.brake.presentation.login.R
+
+@Composable
+internal fun KakaoLoginButton(
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter.get() }
+
+ Button(
+ shape = MaterialTheme.shapes.large,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = KakaoYellow,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ disabledContainerColor = Gray700,
+ disabledContentColor = Gray200,
+ ),
+ contentPadding = PaddingValues(16.dp),
+ enabled = enabled,
+ onClick = { multipleEventsCutter.processEvent(onClick) },
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start,
+ ) {
+ // 왼쪽 카카오 로고 영역 (전체 폭의 20%)
+ Box(
+ modifier = Modifier
+ .weight(0.2f)
+ .wrapContentHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_kakao_logo),
+ contentDescription = "카카오 로고",
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.padding(end = 8.dp),
+ )
+ }
+
+ // 가운데 텍스트 영역 (전체 폭의 60%)
+ Box(
+ modifier = Modifier
+ .weight(0.6f)
+ .wrapContentHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(R.string.login_kakao_button_text),
+ style = BrakeTheme.typography.subtitle16B,
+ )
+ }
+
+ // 오른쪽 빈 공간 영역 (전체 폭의 20%)
+ Box(
+ modifier = Modifier
+ .weight(0.2f)
+ .wrapContentHeight(),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun KakaoLoginButtonPreview(
+ @PreviewParameter(BooleanProvider::class) enabled: Boolean,
+) {
+ BrakeTheme {
+ KakaoLoginButton(
+ onClick = { },
+ enabled = enabled,
+ )
+ }
+}
diff --git a/presentation/login/src/main/java/com/teambrake/brake/presentation/login/component/LoginNoticeText.kt b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/component/LoginNoticeText.kt
new file mode 100644
index 00000000..ee28a3bc
--- /dev/null
+++ b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/component/LoginNoticeText.kt
@@ -0,0 +1,59 @@
+package com.teambrake.brake.presentation.login.component
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withLink
+import androidx.compose.ui.text.withStyle
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.presentation.login.R
+
+@Composable
+fun LoginNoticeText(
+ modifier: Modifier = Modifier,
+ onPrivacyClick: () -> Unit,
+ onTermsClick: () -> Unit,
+) {
+ val notice = stringResource(R.string.login_notice_full_message)
+ val privacy = stringResource(R.string.privacy_policy)
+ val terms = stringResource(R.string.terms_of_service)
+
+ val pStart = notice.indexOf(privacy).takeIf { it >= 0 } ?: return
+ val pEnd = pStart + privacy.length
+ val tStart = notice.indexOf(terms).takeIf { it >= 0 } ?: return
+ val tEnd = tStart + terms.length
+
+ // 특정 단어에 withLink 적용하여 해당 부분에 밑줄 효과와 이벤트 핸들러 추가
+ val annotated = buildAnnotatedString {
+ append(notice.substring(0, pStart))
+
+ withLink(LinkAnnotation.Clickable("PRIVACY") { onPrivacyClick() }) {
+ withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
+ append(privacy)
+ }
+ }
+
+ append(notice.substring(pEnd, tStart))
+
+ withLink(LinkAnnotation.Clickable("TERMS") { onTermsClick() }) {
+ withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
+ append(terms)
+ }
+ }
+
+ append(notice.substring(tEnd))
+ }
+
+ Text(
+ text = annotated,
+ style = BrakeTheme.typography.body12M,
+ modifier = modifier,
+ textAlign = TextAlign.Center,
+ )
+}
diff --git a/presentation/login/src/main/java/com/teambrake/brake/presentation/login/model/LoginNavState.kt b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/model/LoginNavState.kt
new file mode 100644
index 00000000..e4269a5d
--- /dev/null
+++ b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/model/LoginNavState.kt
@@ -0,0 +1,25 @@
+package com.teambrake.brake.presentation.login.model
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+
+@Stable
+sealed interface LoginNavState {
+ @Immutable
+ data object NavigateToPrivacyPolicy : LoginNavState
+
+ @Immutable
+ data object NavigateToTermsOfService : LoginNavState
+
+ @Immutable
+ data object NavigateToHome : LoginNavState
+
+ @Immutable
+ data object NavigateToSignup : LoginNavState
+
+ @Immutable
+ data object NavigateToPermission : LoginNavState
+
+ @Immutable
+ data object NavigateToOnboarding : LoginNavState
+}
diff --git a/presentation/login/src/main/java/com/teambrake/brake/presentation/login/model/LoginUiState.kt b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/model/LoginUiState.kt
new file mode 100644
index 00000000..10646f40
--- /dev/null
+++ b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/model/LoginUiState.kt
@@ -0,0 +1,17 @@
+package com.teambrake.brake.presentation.login.model
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+
+@Stable
+sealed interface LoginUiState {
+
+ @Immutable
+ data object LoginIdle : LoginUiState
+
+ @Immutable
+ data object LoginLoading : LoginUiState
+
+ @Immutable
+ data object LoginOnWebView : LoginUiState
+}
diff --git a/presentation/login/src/main/java/com/teambrake/brake/presentation/login/navigation/LoginNavigation.kt b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/navigation/LoginNavigation.kt
new file mode 100644
index 00000000..62f15b6f
--- /dev/null
+++ b/presentation/login/src/main/java/com/teambrake/brake/presentation/login/navigation/LoginNavigation.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.presentation.login.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.teambrake.brake.core.navigation.route.InitialRoute
+import com.teambrake.brake.presentation.login.LoginRoute
+
+fun NavController.navigateToLogin(navOptions: NavOptions? = null) {
+ navigate(InitialRoute.Login, navOptions)
+}
+
+fun NavGraphBuilder.loginNavGraph() {
+ composable {
+ LoginRoute()
+ }
+}
diff --git a/presentation/login/src/main/res/drawable/ic_g.xml b/presentation/login/src/main/res/drawable/ic_g.xml
new file mode 100644
index 00000000..32eedd25
--- /dev/null
+++ b/presentation/login/src/main/res/drawable/ic_g.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/presentation/login/src/main/res/drawable/ic_kakao_logo.xml b/presentation/login/src/main/res/drawable/ic_kakao_logo.xml
new file mode 100644
index 00000000..bb6d3441
--- /dev/null
+++ b/presentation/login/src/main/res/drawable/ic_kakao_logo.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/presentation/login/src/main/res/drawable/img_login.png b/presentation/login/src/main/res/drawable/img_login.png
new file mode 100644
index 00000000..846b25d1
Binary files /dev/null and b/presentation/login/src/main/res/drawable/img_login.png differ
diff --git a/presentation/login/src/main/res/values/strings.xml b/presentation/login/src/main/res/values/strings.xml
new file mode 100644
index 00000000..ef40d6da
--- /dev/null
+++ b/presentation/login/src/main/res/values/strings.xml
@@ -0,0 +1,16 @@
+
+
+ 계획한 만큼만 사용하도록\n도와드릴게요
+ 아래 버튼으로 로그인 시,\n개인정보처리방침 및 이용약관에 동의하는 것으로 간주합니다.
+ 개인정보처리방침
+ 이용약관
+
+ 로그인이 취소되었습니다.
+ 서버 문제로 인해 로그인에 실패했습니다.
+ 현재 Google 로그인을 지원하지 않습니다.\nPlay Store 에서 Google Play 서비스를 업데이트 해주세요.
+ 계정이 비활성화되어 있습니다. 고객센터로 문의해주세요.
+ 다음 화면으로 이동에 실패했습니다.
+
+ 카카오 로그인
+ Google 로그인
+
diff --git a/presentation/login/src/main/java/com/yapp/breake/presentation/login/.gitkeep b/presentation/login/src/test/java/com/teambrake/brake/presentation/login/.gitkeep
similarity index 100%
rename from presentation/login/src/main/java/com/yapp/breake/presentation/login/.gitkeep
rename to presentation/login/src/test/java/com/teambrake/brake/presentation/login/.gitkeep
diff --git a/presentation/login/src/test/java/com/yapp/breake/presentation/login/ExampleUnitTest.kt b/presentation/login/src/test/java/com/yapp/breake/presentation/login/ExampleUnitTest.kt
deleted file mode 100644
index b4b9e46f..00000000
--- a/presentation/login/src/test/java/com/yapp/breake/presentation/login/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.yapp.breake.presentation.login
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/presentation/main/build.gradle.kts b/presentation/main/build.gradle.kts
index 0c7a332c..5f7f0553 100644
--- a/presentation/main/build.gradle.kts
+++ b/presentation/main/build.gradle.kts
@@ -1,7 +1,7 @@
-import com.yapp.breake.setNamespace
+import com.teambrake.brake.setNamespace
plugins {
- alias(libs.plugins.breake.android.feature)
+ alias(libs.plugins.brake.android.feature)
}
android {
@@ -9,13 +9,21 @@ android {
defaultConfig {
testInstrumentationRunner =
- "com.yapp.breake.core.testing.runner.BreakeTestRunner"
+ "com.teambrake.brake.core.testing.runner.BrakeTestRunner"
}
}
dependencies {
- implementation(projects.presentation.home)
implementation(projects.presentation.login)
+ implementation(projects.presentation.signup)
+ implementation(projects.presentation.onboarding)
+ implementation(projects.presentation.legal)
+ implementation(projects.presentation.permission)
+ implementation(projects.presentation.home)
+ implementation(projects.presentation.report)
+ implementation(projects.presentation.setting)
+ implementation(projects.core.permission)
+
androidTestImplementation(projects.core.testing)
implementation(libs.androidx.core.ktx)
@@ -23,6 +31,9 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.lifecycle.viewModelCompose)
+
+ implementation(libs.core.splashscreen)
+
implementation(libs.kotlinx.immutable)
androidTestImplementation(libs.hilt.android.testing)
kspAndroidTest(libs.hilt.android.compiler)
diff --git a/presentation/main/src/androidTest/java/com/yapp/breake/presentation/main/ExampleInstrumentedTest.kt b/presentation/main/src/androidTest/java/com/android/brake/presentation/main/ExampleInstrumentedTest.kt
similarity index 80%
rename from presentation/main/src/androidTest/java/com/yapp/breake/presentation/main/ExampleInstrumentedTest.kt
rename to presentation/main/src/androidTest/java/com/android/brake/presentation/main/ExampleInstrumentedTest.kt
index cbfbef77..e0b7836a 100644
--- a/presentation/main/src/androidTest/java/com/yapp/breake/presentation/main/ExampleInstrumentedTest.kt
+++ b/presentation/main/src/androidTest/java/com/android/brake/presentation/main/ExampleInstrumentedTest.kt
@@ -1,4 +1,4 @@
-package com.yapp.breake.presentation.main
+package com.teambrake.brake.presentation.main
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.yapp.breake.presentation.main.test", appContext.packageName)
+ assertEquals("com.teambrake.brake.presentation.main.test", appContext.packageName)
}
-}
\ No newline at end of file
+}
diff --git a/presentation/main/src/main/AndroidManifest.xml b/presentation/main/src/main/AndroidManifest.xml
index 0c641cff..fbbaf9e2 100644
--- a/presentation/main/src/main/AndroidManifest.xml
+++ b/presentation/main/src/main/AndroidManifest.xml
@@ -1,6 +1,15 @@
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/presentation/main/src/main/java/com/teambrake/brake/presentation/main/MainActivity.kt b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/MainActivity.kt
new file mode 100644
index 00000000..4fbc166c
--- /dev/null
+++ b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/MainActivity.kt
@@ -0,0 +1,162 @@
+package com.teambrake.brake.presentation.main
+
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.window.Popup
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import com.teambrake.brake.core.designsystem.component.DotProgressIndicator
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.navigation.action.MainAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalMainAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorProvider
+import com.teambrake.brake.presentation.main.component.BrakeSnackbarHostState
+import com.teambrake.brake.presentation.main.component.BrakeSnackbarType
+import com.teambrake.brake.presentation.main.component.LogoutWarningDialog
+import com.teambrake.brake.presentation.main.navigation.MainNavigator
+import com.teambrake.brake.presentation.main.navigation.rememberMainNavigator
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ private val viewModel: MainViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // 스플래시 스크린 설치, 내부에서 API 31 미만 버전도 호환되도록 처리
+ val splashScreen = installSplashScreen()
+
+ enableEdgeToEdge()
+
+ // 스플래시 스크린이 유지되는 조건 설정
+ splashScreen.setKeepOnScreenCondition {
+ viewModel.startRoute.value == null
+ }
+
+ viewModel.decideStartDestination(context = this@MainActivity)
+
+ setContent {
+ val startDestination by viewModel.startRoute.collectAsState()
+
+ when (val destination = startDestination) {
+ null -> { /* 스플래시 화면 유지 */ }
+ else -> {
+ val navigator: MainNavigator = rememberMainNavigator(destination)
+ val coroutineScope: CoroutineScope = rememberCoroutineScope()
+ val snackBarHostState = remember { BrakeSnackbarHostState() }
+
+ val mainAction = object : MainAction {
+ @Composable
+ override fun OnFinishBackHandler() {
+ var backPressedTime by remember { mutableLongStateOf(0L) }
+ BackHandler {
+ if (System.currentTimeMillis() - backPressedTime <= 2000L) {
+ finish()
+ } else {
+ Toast.makeText(
+ this@MainActivity,
+ this@MainActivity.getString(
+ R.string.exit_message,
+ ),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ backPressedTime = System.currentTimeMillis()
+ }
+ }
+
+ @Composable
+ override fun OnShowLogoutDialog(
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ ) {
+ LogoutWarningDialog(
+ onConfirm = onConfirm,
+ onDismissRequest = onDismiss,
+ )
+ }
+
+ @Composable
+ override fun OnShowLoading() {
+ Popup {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background.copy(alpha = 0.8f))
+ .statusBarsPadding(),
+ contentAlignment = Alignment.Center,
+ ) {
+ DotProgressIndicator()
+ }
+ }
+ }
+
+ override fun onShowErrorMessage(message: String) {
+ coroutineScope.launch {
+ snackBarHostState.showSnackbar(
+ message = message,
+ actionLabel = BrakeSnackbarType.ERROR.name,
+ duration = 5000L,
+ onAction = snackBarHostState::dismiss,
+ )
+ }
+ }
+
+ override fun onShowSuccessMessage(message: String) {
+ coroutineScope.launch {
+ snackBarHostState.showSnackbar(
+ message = message,
+ actionLabel = BrakeSnackbarType.SUCCESS.name,
+ duration = 3000L,
+ onAction = snackBarHostState::dismiss,
+ )
+ }
+ }
+ }
+
+ CompositionLocalProvider(
+ LocalMainAction provides mainAction,
+ LocalNavigatorAction provides navigator.navigatorAction(),
+ LocalNavigatorProvider provides navigator.navigatorProvider(),
+ ) {
+ BrakeTheme {
+ MainScreen(
+ navigator = navigator,
+ onChangeDarkTheme = { false },
+ snackBarHostState = snackBarHostState,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ viewModel.analyzeFinishApp()
+ super.onDestroy()
+ }
+}
diff --git a/presentation/main/src/main/java/com/teambrake/brake/presentation/main/MainScreen.kt b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/MainScreen.kt
new file mode 100644
index 00000000..95675c38
--- /dev/null
+++ b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/MainScreen.kt
@@ -0,0 +1,153 @@
+package com.teambrake.brake.presentation.main
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.teambrake.brake.core.designsystem.theme.DynamicPaddingsProvider
+import com.teambrake.brake.core.designsystem.theme.LocalDynamicPaddings
+import com.teambrake.brake.core.navigation.route.InitialRoute
+import com.teambrake.brake.core.navigation.route.MainTabRoute
+import com.teambrake.brake.presentation.main.component.BrakeSnackbar
+import com.teambrake.brake.presentation.main.component.BrakeSnackbarHostState
+import com.teambrake.brake.presentation.main.component.BrakeSnackbarHost
+import com.teambrake.brake.presentation.main.navigation.MainBottomNavBar
+import com.teambrake.brake.presentation.main.navigation.MainNavHost
+import com.teambrake.brake.presentation.main.navigation.MainNavigator
+import com.teambrake.brake.presentation.main.navigation.MainTab
+import kotlinx.collections.immutable.toPersistentList
+
+@Composable
+internal fun MainScreen(
+ navigator: MainNavigator,
+ onChangeDarkTheme: (Boolean) -> Unit,
+ snackBarHostState: BrakeSnackbarHostState,
+) {
+ MainScreenContent(
+ navigator = navigator,
+ onChangeDarkTheme = onChangeDarkTheme,
+ snackBarHostState = snackBarHostState,
+ )
+}
+
+@Composable
+private fun MainScreenContent(
+ navigator: MainNavigator,
+ onChangeDarkTheme: (Boolean) -> Unit,
+ snackBarHostState: BrakeSnackbarHostState,
+ modifier: Modifier = Modifier,
+) {
+ // 1. 현재 화면에 따라 실시간 스낵바 위치 조정을 위한 Route 구독
+ // 2. MainTabRoute 화면일 때 하단 네비게이션 바를 띄우고, 그 외는 안띄우기 위한 Route 구독
+ val currentRoute by navigator.currentRoute.collectAsStateWithLifecycle()
+
+ // 바텀 패딩 조정 용도 (스낵바 높이 위치 및 하단 네비게이션 바 상호작용)
+ val dynamicPaddingsProvider = remember { DynamicPaddingsProvider() }
+ val density = LocalDensity.current
+
+ Scaffold(
+ modifier = modifier,
+ content = { padding ->
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ CompositionLocalProvider(
+ LocalDynamicPaddings provides dynamicPaddingsProvider,
+ ) {
+ MainNavHost(
+ navigator = navigator,
+ padding = padding,
+ onChangeDarkTheme = onChangeDarkTheme,
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) { /* 터치 이벤트 가로채기 */ }
+ .onGloballyPositioned { coordinates ->
+ with(density) {
+ dynamicPaddingsProvider.updateBottomNavHeight(
+ coordinates.size.height.toDp() + 12.dp,
+ )
+ }
+ }
+ .fillMaxWidth()
+ .wrapContentWidth(Alignment.CenterHorizontally)
+ .navigationBarsPadding()
+ .padding(bottom = 34.dp),
+ ) {
+ // AnimatedVisibility 를 사용할 경우, 스낵바의 y 좌표 위치 변동 시 애니메이션 활성화 동안 스낵바의 위치가 튀는 현상 발생
+ val route = currentRoute
+ if (route is MainTabRoute) {
+ MainBottomNavBar(
+ modifier = Modifier
+ .background(Color.Transparent),
+ tabs = MainTab.entries.toPersistentList(),
+ currentTab = when (route) {
+ is MainTabRoute.Home -> MainTab.HOME
+ is MainTabRoute.Report -> MainTab.REPORT
+ is MainTabRoute.Setting -> MainTab.SETTING
+ },
+ onTabSelected = navigator::navigate,
+ )
+ }
+ }
+ }
+ },
+ contentWindowInsets = WindowInsets(0.dp),
+ snackbarHost = {
+ BrakeSnackbarHost(
+ hostState = snackBarHostState,
+ snackbar = { snackbarData ->
+ BrakeSnackbar(
+ snackbarData = snackbarData,
+ )
+ },
+ // 현재 화면에 따라 스낵바 y 축 위치 조정
+ modifier = Modifier.then(
+ when (currentRoute) {
+ is MainTabRoute -> Modifier.padding(
+ bottom = dynamicPaddingsProvider.paddings.bottomNavBarHeight,
+ )
+
+ InitialRoute.Login ->
+ Modifier
+ .navigationBarsPadding()
+ .padding(
+ bottom = dynamicPaddingsProvider.paddings.twoButtonHeight,
+ )
+
+ else ->
+ Modifier
+ .navigationBarsPadding()
+ .padding(
+ bottom = dynamicPaddingsProvider.paddings.oneButtonHeight,
+ )
+ },
+ ),
+ )
+ },
+ )
+}
diff --git a/presentation/main/src/main/java/com/teambrake/brake/presentation/main/MainViewModel.kt b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/MainViewModel.kt
new file mode 100644
index 00000000..f474e049
--- /dev/null
+++ b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/MainViewModel.kt
@@ -0,0 +1,85 @@
+package com.teambrake.brake.presentation.main
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.teambrake.brake.core.permission.PermissionManager
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.analytics.logEvent
+import com.teambrake.brake.core.model.user.Destination
+import com.teambrake.brake.core.navigation.route.InitialRoute
+import com.teambrake.brake.core.navigation.route.MainTabRoute
+import com.teambrake.brake.core.navigation.route.Route
+import com.teambrake.brake.domain.usecase.DecideStartDestinationUseCase
+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 javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val permissionManager: PermissionManager,
+ private val decideStartDestinationUseCase: DecideStartDestinationUseCase,
+ private val firebaseAnalytics: FirebaseAnalytics,
+) : ViewModel() {
+
+ private var _startRoute: MutableStateFlow = MutableStateFlow(null)
+ val startRoute: StateFlow = _startRoute.asStateFlow()
+
+ init {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.APP_OPEN) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "main_activity")
+ }
+ }
+
+ fun decideStartDestination(context: Context) {
+ viewModelScope.launch {
+ val dest = decideStartDestinationUseCase()
+ val route = when (dest) {
+ is Destination.Login -> {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "login_screen")
+ }
+ InitialRoute.Login
+ }
+
+ is Destination.Onboarding -> {
+ firebaseAnalytics.run {
+ logEvent(FirebaseAnalytics.Event.LOGIN) {
+ param(FirebaseAnalytics.Param.METHOD, "auto_login")
+ }
+ logEvent(FirebaseAnalytics.Event.TUTORIAL_BEGIN, null)
+ }
+ InitialRoute.Onboarding.Guide
+ }
+
+ is Destination.PermissionOrHome -> {
+ if (permissionManager.isAllGranted(context)) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.LOGIN) {
+ param(FirebaseAnalytics.Param.METHOD, "auto_login")
+ }
+ MainTabRoute.Home
+ } else {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.LOGIN) {
+ param(FirebaseAnalytics.Param.METHOD, "auto_login")
+ }
+ InitialRoute.Permission
+ }
+ }
+
+ else -> {
+ InitialRoute.Login
+ }
+ }
+ _startRoute.value = route
+ }
+ }
+
+ fun analyzeFinishApp() {
+ firebaseAnalytics.logEvent("app_exit") {
+ param("reason", "user_exit")
+ }
+ }
+}
diff --git a/presentation/main/src/main/java/com/teambrake/brake/presentation/main/component/BrakeSnackbar.kt b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/component/BrakeSnackbar.kt
new file mode 100644
index 00000000..b395f9da
--- /dev/null
+++ b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/component/BrakeSnackbar.kt
@@ -0,0 +1,172 @@
+package com.teambrake.brake.presentation.main.component
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.R
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray900
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import kotlin.math.abs
+
+internal enum class BrakeSnackbarType {
+ SUCCESS,
+ ERROR,
+}
+
+@Composable
+internal fun BrakeSnackbar(
+ snackbarData: BrakeSnackbarData,
+ modifier: Modifier = Modifier,
+) {
+ val type = try {
+ BrakeSnackbarType.valueOf(snackbarData.actionLabel ?: "SUCCESS")
+ } catch (_: IllegalArgumentException) {
+ BrakeSnackbarType.SUCCESS
+ }
+
+ val icon = when (type) {
+ BrakeSnackbarType.SUCCESS -> R.drawable.ic_snackbar_success
+ BrakeSnackbarType.ERROR -> R.drawable.ic_snackbar_error
+ }
+
+ SnackbarContent(
+ icon = icon,
+ message = snackbarData.message,
+ modifier = modifier,
+ onDismiss = snackbarData.onAction,
+ )
+}
+
+@Composable
+private fun SnackbarContent(
+ icon: Int,
+ message: String,
+ modifier: Modifier = Modifier,
+ onDismiss: (() -> Unit),
+) {
+
+ val offsetX = remember { Animatable(0f) }
+ val coroutineScope = rememberCoroutineScope()
+
+ val density = LocalDensity.current
+ val swipeThreshold = with(density) { 160.dp.toPx() }
+
+ var dragStartTime by remember { mutableLongStateOf(0L) }
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .graphicsLayer {
+ translationX = offsetX.value
+ alpha = 1f - (abs(offsetX.value) / (swipeThreshold))
+ }
+ .pointerInput(Unit) {
+ detectHorizontalDragGestures(
+ onDragStart = { offset ->
+ dragStartTime = System.currentTimeMillis()
+ },
+ onDragEnd = {
+ coroutineScope.launch {
+ val dragDuration = System.currentTimeMillis() - dragStartTime
+ val dragDistance = abs(offsetX.value)
+ val velocity = if (dragDuration > 0) {
+ (dragDistance * 1000f) / dragDuration // pixels per second
+ } else {
+ 0f
+ }
+ Timber.d("dismiss 속도: $velocity px/s")
+
+ // 속도 기반 dismiss 분기 처리
+ val dynamicThreshold = when {
+ velocity > 5000f -> swipeThreshold * 0.3f // 매우 빠른 스와이프
+ velocity > 2500f -> swipeThreshold * 0.5f // 빠른 스와이프
+ velocity > 1000f -> swipeThreshold * 0.8f // 보통 속도
+ else -> swipeThreshold // 느린 드래그
+ }
+
+ if (abs(offsetX.value) > dynamicThreshold) {
+ onDismiss()
+ } else {
+ offsetX.animateTo(
+ targetValue = 0f,
+ animationSpec = tween(
+ durationMillis = 300,
+ easing = FastOutSlowInEasing,
+ ),
+ )
+ }
+ }
+ },
+ onHorizontalDrag = { _, dragAmount ->
+ coroutineScope.launch {
+ offsetX.snapTo(offsetX.value + dragAmount)
+ }
+ },
+ )
+ }
+ .background(
+ color = Gray900,
+ shape = RoundedCornerShape(16.dp),
+ )
+ .padding(16.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = icon),
+ contentDescription = null,
+ tint = Color.Unspecified,
+ )
+ HorizontalSpacer(10.dp)
+ Text(
+ text = message,
+ style = BrakeTheme.typography.body14M,
+ color = Color.White,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun BrakeSnackbarPreview() {
+ BrakeTheme {
+ SnackbarContent(
+ icon = R.drawable.ic_snackbar_success,
+ message = "This is a success message!",
+ modifier = Modifier.fillMaxWidth(),
+ onDismiss = {},
+ )
+ }
+}
diff --git a/presentation/main/src/main/java/com/teambrake/brake/presentation/main/component/BrakeSnackbarHost.kt b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/component/BrakeSnackbarHost.kt
new file mode 100644
index 00000000..be25fb16
--- /dev/null
+++ b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/component/BrakeSnackbarHost.kt
@@ -0,0 +1,103 @@
+package com.teambrake.brake.presentation.main.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.slideInVertically
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Modifier
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+internal data class BrakeSnackbarData(
+ val message: String,
+ val actionLabel: String?,
+ val duration: Long,
+ val onAction: (() -> Unit) = {},
+)
+
+@Stable
+internal class BrakeSnackbarHostState {
+ private val _currentSnackbarData = mutableStateOf(null)
+ val currentSnackbarData: State = _currentSnackbarData
+ private var currentJob: Job? = null
+
+ fun showSnackbar(
+ message: String,
+ actionLabel: String?,
+ duration: Long = 5000000L,
+ onAction: () -> Unit,
+ ) {
+ // 이전 스낵바가 표시되고 있다면 취소
+ currentJob?.run {
+ if (isActive) {
+ _currentSnackbarData.value = null
+ cancel()
+ }
+ }
+
+ currentJob = CoroutineScope(Dispatchers.IO).launch {
+
+ // 약간의 딜레이로 애니메이션 자연스럽게 처리
+ delay(100)
+
+ val snackbarData = BrakeSnackbarData(
+ message = message,
+ actionLabel = actionLabel,
+ duration = duration,
+ onAction = onAction,
+ )
+
+ _currentSnackbarData.value = snackbarData
+
+ delay(duration)
+ if (isActive) {
+ _currentSnackbarData.value = null
+ }
+ }
+ }
+
+ fun dismiss() {
+ currentJob?.cancel()
+ _currentSnackbarData.value = null
+ }
+}
+
+@Composable
+internal fun BrakeSnackbarHost(
+ hostState: BrakeSnackbarHostState,
+ snackbar: @Composable (BrakeSnackbarData) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val currentSnackbarData by hostState.currentSnackbarData
+
+ AnimatedVisibility(
+ visible = currentSnackbarData != null,
+ enter = slideInVertically(
+ initialOffsetY = { it },
+ animationSpec = tween(
+ durationMillis = 300,
+ easing = FastOutSlowInEasing,
+ ),
+ ) + fadeIn(
+ animationSpec = tween(
+ durationMillis = 800,
+ easing = FastOutSlowInEasing,
+ ),
+ ),
+ modifier = modifier,
+ ) {
+ currentSnackbarData?.let { data ->
+ snackbar(data)
+ }
+ }
+}
diff --git a/presentation/main/src/main/java/com/teambrake/brake/presentation/main/component/LogoutWarningDialog.kt b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/component/LogoutWarningDialog.kt
new file mode 100644
index 00000000..f04b49ea
--- /dev/null
+++ b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/component/LogoutWarningDialog.kt
@@ -0,0 +1,45 @@
+package com.teambrake.brake.presentation.main.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import com.teambrake.brake.core.designsystem.component.TwoButtonDialog
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.presentation.main.R
+
+@Composable
+internal fun LogoutWarningDialog(
+ onDismissRequest: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ TwoButtonDialog(
+ onDismissRequest = onDismissRequest,
+ dismissButtonText = stringResource(R.string.logout_cancel),
+ confirmButtonText = stringResource(R.string.logout_confirm),
+ onConfirmButtonClick = onConfirm,
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.logout_message),
+ style = BrakeTheme.typography.subtitle22SB,
+ color = White,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Preview
+@Composable
+fun LogoutWarningDialogPreview() {
+ BrakeTheme {
+ LogoutWarningDialog(
+ onDismissRequest = {},
+ onConfirm = {},
+ )
+ }
+}
diff --git a/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainBottomNav.kt b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainBottomNav.kt
new file mode 100644
index 00000000..58c96aa1
--- /dev/null
+++ b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainBottomNav.kt
@@ -0,0 +1,104 @@
+package com.teambrake.brake.presentation.main.navigation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray700
+import com.teambrake.brake.core.designsystem.theme.Gray800
+import com.teambrake.brake.core.designsystem.theme.White
+import kotlinx.collections.immutable.ImmutableList
+
+@Composable
+internal fun MainBottomNavBar(
+ tabs: ImmutableList,
+ currentTab: MainTab?,
+ onTabSelected: (MainTab) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ // REPORT 개발 후 width 조정 필요
+ .fillMaxWidth(0.70f)
+ .widthIn(max = 216.dp)
+ .wrapContentHeight()
+ .background(
+ color = Gray800,
+ shape = RoundedCornerShape(60.dp),
+ )
+ .padding(horizontal = 28.dp, vertical = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ tabs.forEach { tab ->
+ MainBottomNavItem(
+ tab = tab,
+ selected = tab == currentTab,
+ onClick = { onTabSelected(tab) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun RowScope.MainBottomNavItem(
+ tab: MainTab,
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .weight(1f)
+ .selectable(
+ selected = selected,
+ indication = null,
+ role = null,
+ interactionSource = remember { MutableInteractionSource() },
+ onClick = onClick,
+ ),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ painter = painterResource(tab.iconResId),
+ contentDescription = tab.contentDescription,
+ modifier = Modifier.size(20.dp),
+ tint = if (selected) {
+ White
+ } else {
+ Gray700
+ },
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = tab.contentDescription,
+ style = BrakeTheme.typography.body12M,
+ color = if (selected) {
+ White
+ } else {
+ Gray700
+ },
+ )
+ }
+}
diff --git a/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainNavHost.kt b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainNavHost.kt
new file mode 100644
index 00000000..de52c25e
--- /dev/null
+++ b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainNavHost.kt
@@ -0,0 +1,50 @@
+package com.teambrake.brake.presentation.main.navigation
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation.compose.NavHost
+import com.teambrake.brake.presentation.feeback.inquiry.navigation.inquiryNavGraph
+import com.teambrake.brake.presentation.feeback.opinion.navigation.opinionNavGraph
+import com.teambrake.brake.presentation.home.navigation.homeNavGraph
+import com.teambrake.brake.presentation.legal.navigation.legalNavGraph
+import com.teambrake.brake.presentation.login.navigation.loginNavGraph
+import com.teambrake.brake.presentation.nickname.navigation.nicknameNavGraph
+import com.teambrake.brake.presentation.onboarding.navigation.onboardingNavGraph
+import com.teambrake.brake.presentation.permission.navigation.permissionNavGraph
+import com.teambrake.brake.presentation.registry.navigation.registryNavGraph
+import com.teambrake.brake.presentation.report.navigation.reportNavGraph
+import com.teambrake.brake.presentation.setting.navigation.settingNavGraph
+import com.teambrake.brake.presentation.signup.navigation.signupNavGraph
+
+@Composable
+internal fun MainNavHost(
+ navigator: MainNavigator,
+ padding: PaddingValues,
+ onChangeDarkTheme: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val navController = navigator.navController
+
+ NavHost(
+ modifier = modifier,
+ navController = navController,
+ startDestination = navigator.startDestination,
+ ) {
+ loginNavGraph()
+ signupNavGraph()
+ onboardingNavGraph()
+ legalNavGraph()
+ permissionNavGraph()
+ reportNavGraph(padding = padding)
+ homeNavGraph(padding = padding)
+ registryNavGraph()
+ settingNavGraph(
+ padding = padding,
+ onChangeDarkTheme = onChangeDarkTheme,
+ )
+ nicknameNavGraph()
+ inquiryNavGraph()
+ opinionNavGraph()
+ }
+}
diff --git a/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainNavigator.kt b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainNavigator.kt
new file mode 100644
index 00000000..9b36aad5
--- /dev/null
+++ b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainNavigator.kt
@@ -0,0 +1,237 @@
+package com.teambrake.brake.presentation.main.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.navigation.NavDestination
+import androidx.navigation.NavDestination.Companion.hasRoute
+import androidx.navigation.NavHostController
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navOptions
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.analytics.logEvent
+import com.teambrake.brake.core.navigation.action.NavigatorAction
+import com.teambrake.brake.core.navigation.provider.NavigatorProvider
+import com.teambrake.brake.core.navigation.route.InitialRoute
+import com.teambrake.brake.core.navigation.route.MainTabRoute
+import com.teambrake.brake.core.navigation.route.Route
+import com.teambrake.brake.core.navigation.route.SubRoute
+import com.teambrake.brake.core.navigation.route.stringRoute
+import com.teambrake.brake.presentation.feeback.inquiry.navigation.navigateToInquiry
+import com.teambrake.brake.presentation.feeback.opinion.navigation.navigateToOpinion
+import com.teambrake.brake.presentation.home.navigation.navigateToHome
+import com.teambrake.brake.presentation.legal.navigation.navigateToPrivacy
+import com.teambrake.brake.presentation.legal.navigation.navigateToTerms
+import com.teambrake.brake.presentation.login.navigation.navigateToLogin
+import com.teambrake.brake.presentation.nickname.navigation.navigateToNickname
+import com.teambrake.brake.presentation.onboarding.navigation.navigateToComplete
+import com.teambrake.brake.presentation.onboarding.navigation.navigateToGuide
+import com.teambrake.brake.presentation.permission.navigation.navigateToPermission
+import com.teambrake.brake.presentation.registry.navigation.navigateToRegistry
+import com.teambrake.brake.presentation.report.navigation.navigateReport
+import com.teambrake.brake.presentation.setting.navigation.navigateSetting
+import com.teambrake.brake.presentation.signup.navigation.navigateToSignup
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+internal class MainNavigator(
+ val startDestination: Route,
+ val navController: NavHostController,
+ private val firebaseAnalytics: FirebaseAnalytics,
+) {
+ // 기존 MainTab 상태 변화의 Composable State Producer 를 State Flow 와 함께 쓰면 recomposition 이 두 번 일어나는 문제가 있어,
+ // 하나의 State Producer 로 통합
+ private val _currentRoute = MutableStateFlow(startDestination)
+ val currentRoute: StateFlow = _currentRoute.asStateFlow()
+
+ init {
+ // NavController의 destination 변화를 감지
+ navController.addOnDestinationChangedListener { _, destination, _ ->
+ _currentRoute.value = destination.toBrakeRoute
+ }
+ }
+
+ private val NavDestination.toBrakeRoute: Route
+ get() = when (this.route) {
+ MainTabRoute.Home.stringRoute() -> MainTabRoute.Home
+ MainTabRoute.Setting.stringRoute() -> MainTabRoute.Setting
+ MainTabRoute.Report.stringRoute() -> MainTabRoute.Report
+ InitialRoute.Login.stringRoute() -> InitialRoute.Login
+ InitialRoute.SignUp.stringRoute() -> InitialRoute.SignUp
+ InitialRoute.Onboarding.Guide.stringRoute() -> InitialRoute.Onboarding.Guide
+ InitialRoute.Onboarding.Complete.stringRoute() -> InitialRoute.Onboarding.Complete
+ InitialRoute.Permission.stringRoute() -> InitialRoute.Permission
+ SubRoute.Nickname.stringRoute() -> SubRoute.Nickname
+ SubRoute.Privacy.stringRoute() -> SubRoute.Privacy
+ SubRoute.Terms.stringRoute() -> SubRoute.Terms
+ SubRoute.Feedback.Inquiry.stringRoute() -> SubRoute.Feedback.Inquiry
+ SubRoute.Feedback.Opinion.stringRoute() -> SubRoute.Feedback.Opinion
+ else -> SubRoute.Registry()
+ // Registry 타입은 유일하게 인자를 갖는 Route 의 클래스 이므로, else 분기로 Registry 기본 생성자 반환
+ }
+
+ fun navigatorAction(): NavigatorAction = object : NavigatorAction {
+ override fun popBackStack(navOptions: NavOptions?) = popBackStackIfNotHome()
+ override fun navigateToLogin(navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "login_screen")
+ }
+ navController.navigateToLogin(navOptions)
+ }
+
+ override fun navigateToSignup(navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "signup_screen")
+ }
+ navController.navigateToSignup(navOptions)
+ }
+
+ override fun navigateToGuide(navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "onboarding_guide_screen")
+ }
+ navController.navigateToGuide(navOptions)
+ }
+
+ override fun navigateToPrivacy(navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "privacy_policy_chrome_activity")
+ }
+ navController.navigateToPrivacy(navOptions)
+ }
+
+ override fun navigateToTerms(navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "terms_of_service_chrome_activity")
+ }
+ navController.navigateToTerms(navOptions)
+ }
+
+ override fun navigateToComplete(navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "onboarding_complete_screen")
+ }
+ navController.navigateToComplete(navOptions)
+ }
+
+ override fun navigateToPermission(navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "permission_screen")
+ }
+ navController.navigateToPermission(navOptions)
+ }
+
+ override fun navigateToHome(navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "home_screen")
+ }
+ navController.navigateToHome(navOptions)
+ }
+
+ override fun navigateToRegistry(groupId: Long?, navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "registry_screen")
+ }
+ navController.navigateToRegistry(groupId, navOptions)
+ }
+
+ override fun navigateToNickname(navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "nickname_screen")
+ }
+ navController.navigateToNickname(navOptions)
+ }
+
+ override fun navigateToOpinion(navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "opinion_chrome_screen")
+ }
+ navController.navigateToOpinion(navOptions)
+ }
+
+ override fun navigateToInquiry(navOptions: NavOptions?) {
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "inquiry_chrome_screen")
+ }
+ navController.navigateToInquiry(navOptions)
+ }
+ }
+
+ fun navigatorProvider(): NavigatorProvider = object : NavigatorProvider {
+ override fun getNavOptionsClearingBackStack(): NavOptions = navOptions {
+ popUpTo(navController.graph.id) {
+ inclusive = true
+ }
+ launchSingleTop = true
+ }
+
+ override fun getPreviousDestination(): String = navController.previousBackStackEntry?.destination?.route
+ ?: startDestination.stringRoute()
+ }
+
+ fun navigate(tab: MainTab) {
+ val topNavOptions = navOptions {
+ popUpTo(navController.graph.id) {
+ inclusive = true
+ }
+ launchSingleTop = true
+ }
+ when (tab) {
+ MainTab.REPORT -> {
+ firebaseAnalytics.apply {
+ logEvent("bottom_navigation_click") {
+ param("name", "report_screen")
+ }
+ logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "report_screen")
+ }
+ }
+ navController.navigateReport(navOptions = topNavOptions)
+ }
+ MainTab.HOME -> {
+ firebaseAnalytics.apply {
+ logEvent("bottom_navigation_click") {
+ param("name", "home_screen")
+ }
+ logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "home_screen")
+ }
+ }
+ navController.navigateToHome(navOptions = topNavOptions)
+ }
+ MainTab.SETTING -> {
+ firebaseAnalytics.apply {
+ logEvent("bottom_navigation_click") {
+ param("name", "setting_screen")
+ }
+ logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "setting_screen")
+ }
+ }
+ navController.navigateSetting(navOptions = topNavOptions)
+ }
+ }
+ }
+
+ private fun popBackStackIfNotHome() {
+ if (!isSameCurrentDestination()) {
+ navController.popBackStack()
+ }
+ }
+
+ private inline fun isSameCurrentDestination(): Boolean = navController.currentDestination?.hasRoute() == true
+}
+
+@Composable
+internal fun rememberMainNavigator(
+ startDestination: Route,
+ navController: NavHostController = rememberNavController(),
+): MainNavigator {
+ val context = LocalContext.current
+ val analytics = FirebaseAnalytics.getInstance(context)
+ return remember(navController) {
+ MainNavigator(startDestination, navController, analytics)
+ }
+}
diff --git a/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainTab.kt b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainTab.kt
new file mode 100644
index 00000000..a0efcb6b
--- /dev/null
+++ b/presentation/main/src/main/java/com/teambrake/brake/presentation/main/navigation/MainTab.kt
@@ -0,0 +1,37 @@
+package com.teambrake.brake.presentation.main.navigation
+
+import androidx.compose.runtime.Composable
+import com.teambrake.brake.core.navigation.route.MainTabRoute
+import com.teambrake.brake.core.navigation.route.Route
+import com.teambrake.brake.presentation.main.R
+
+internal enum class MainTab(
+ val iconResId: Int,
+ internal val contentDescription: String,
+ val route: MainTabRoute,
+) {
+ REPORT(
+ iconResId = R.drawable.ic_chart,
+ contentDescription = "리포트",
+ MainTabRoute.Report,
+ ),
+ HOME(
+ iconResId = R.drawable.ic_timer,
+ contentDescription = "관리",
+ MainTabRoute.Home,
+ ),
+ SETTING(
+ iconResId = R.drawable.ic_user,
+ contentDescription = "내 정보",
+ MainTabRoute.Setting,
+ ),
+ ;
+
+ companion object {
+ @Composable
+ fun find(predicate: @Composable (MainTabRoute) -> Boolean): MainTab? = entries.find { predicate(it.route) }
+
+ @Composable
+ fun contains(predicate: @Composable (Route) -> Boolean): Boolean = entries.map { it.route }.any { predicate(it) }
+ }
+}
diff --git a/presentation/main/src/main/java/com/yapp/breake/presentation/main/MainActivity.kt b/presentation/main/src/main/java/com/yapp/breake/presentation/main/MainActivity.kt
deleted file mode 100644
index 520eeead..00000000
--- a/presentation/main/src/main/java/com/yapp/breake/presentation/main/MainActivity.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.yapp.breake.presentation.main
-
-import androidx.activity.ComponentActivity
-import dagger.hilt.android.AndroidEntryPoint
-
-@AndroidEntryPoint
-class MainActivity : ComponentActivity()
diff --git a/presentation/main/src/main/res/drawable/ic_chart.xml b/presentation/main/src/main/res/drawable/ic_chart.xml
new file mode 100644
index 00000000..9b3f5034
--- /dev/null
+++ b/presentation/main/src/main/res/drawable/ic_chart.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/presentation/main/src/main/res/drawable/ic_timer.xml b/presentation/main/src/main/res/drawable/ic_timer.xml
new file mode 100644
index 00000000..33519b24
--- /dev/null
+++ b/presentation/main/src/main/res/drawable/ic_timer.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
diff --git a/presentation/main/src/main/res/drawable/ic_user.xml b/presentation/main/src/main/res/drawable/ic_user.xml
new file mode 100644
index 00000000..fd80bf8d
--- /dev/null
+++ b/presentation/main/src/main/res/drawable/ic_user.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/presentation/main/src/main/res/values/strings.xml b/presentation/main/src/main/res/values/strings.xml
new file mode 100644
index 00000000..69713362
--- /dev/null
+++ b/presentation/main/src/main/res/values/strings.xml
@@ -0,0 +1,11 @@
+
+
+ 네트워크 연결이 원활하지 않습니다
+ 알 수 없는 오류가 발생했습니다
+
+ 로그아웃 하시겠습니까?
+ 확인
+ 취소
+
+ 한번 더 누르면 앱을 종료합니다.
+
diff --git a/presentation/main/src/test/java/com/teambrake/brake/presentation/main/.gitkeep b/presentation/main/src/test/java/com/teambrake/brake/presentation/main/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/presentation/main/src/test/java/com/yapp/breake/presentation/main/ExampleUnitTest.kt b/presentation/main/src/test/java/com/yapp/breake/presentation/main/ExampleUnitTest.kt
deleted file mode 100644
index 0ac4955b..00000000
--- a/presentation/main/src/test/java/com/yapp/breake/presentation/main/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.yapp.breake.presentation.main
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/presentation/onboarding/.gitignore b/presentation/onboarding/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/presentation/onboarding/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/presentation/onboarding/build.gradle.kts b/presentation/onboarding/build.gradle.kts
new file mode 100644
index 00000000..a98feb04
--- /dev/null
+++ b/presentation/onboarding/build.gradle.kts
@@ -0,0 +1,13 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.feature)
+}
+
+android {
+ setNamespace("presentation.onboarding")
+}
+
+dependencies {
+ implementation(projects.core.permission)
+}
diff --git a/presentation/onboarding/src/main/AndroidManifest.xml b/presentation/onboarding/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..568741e5
--- /dev/null
+++ b/presentation/onboarding/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/complete/CompleteScreen.kt b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/complete/CompleteScreen.kt
new file mode 100644
index 00000000..104e89f4
--- /dev/null
+++ b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/complete/CompleteScreen.kt
@@ -0,0 +1,122 @@
+package com.teambrake.brake.presentation.onboarding.complete
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.teambrake.brake.presentation.onboarding.complete.model.CompleteNavState
+import com.teambrake.brake.core.designsystem.component.LargeButton
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.LocalPadding
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.core.navigation.compositionlocal.LocalMainAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorProvider
+import com.teambrake.brake.presentation.onboarding.R
+
+@Composable
+fun CompleteRoute(
+ viewModel: CompleteViewModel = hiltViewModel(),
+) {
+ val screenHorizontalPadding = LocalPadding.current.screenPaddingHorizontal
+ val context = LocalContext.current
+ val navAction = LocalNavigatorAction.current
+ val navProvider = LocalNavigatorProvider.current
+ val mainAction = LocalMainAction.current
+
+ LaunchedEffect(true) {
+ viewModel.snackBarFlow.collect {
+ mainAction.onShowErrorMessage(
+ message = it.asString(context = context),
+ )
+ }
+ }
+
+ LaunchedEffect(true) {
+ viewModel.navigationFlow.collect { effect ->
+ when (effect) {
+ CompleteNavState.NavigateToMain -> navAction.navigateToHome(
+ navOptions = navProvider.getNavOptionsClearingBackStack(),
+ )
+
+ CompleteNavState.NavigateToBack -> navAction.popBackStack()
+ }
+ }
+ }
+
+ CompleteScreen(
+ screenHorizontalPadding = screenHorizontalPadding,
+ onStartClick = viewModel::completeOnboarding,
+ )
+}
+
+@Composable
+fun CompleteScreen(
+ screenHorizontalPadding: Dp,
+ onStartClick: () -> Unit,
+) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Column(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = 120.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.complete_title),
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ style = BrakeTheme.typography.title28B,
+ color = White,
+ )
+
+ VerticalSpacer(16.dp)
+
+ Text(
+ text = stringResource(R.string.complete_description),
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ style = BrakeTheme.typography.subtitle20SB,
+ color = White,
+ )
+ }
+
+ Image(
+ modifier = Modifier.fillMaxSize(),
+ painter = painterResource(id = R.drawable.img_complete),
+ contentDescription = "Complete Image",
+ contentScale = ContentScale.FillBounds,
+ )
+
+ LargeButton(
+ text = stringResource(R.string.complete_button),
+ onClick = onStartClick,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .widthIn(max = 500.dp)
+ .navigationBarsPadding()
+ .padding(bottom = 24.dp)
+ .padding(horizontal = screenHorizontalPadding),
+ )
+ }
+}
diff --git a/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/complete/CompleteViewModel.kt b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/complete/CompleteViewModel.kt
new file mode 100644
index 00000000..02420557
--- /dev/null
+++ b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/complete/CompleteViewModel.kt
@@ -0,0 +1,44 @@
+package com.teambrake.brake.presentation.onboarding.complete
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.teambrake.brake.presentation.onboarding.complete.model.CompleteNavState
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.analytics.logEvent
+import com.teambrake.brake.core.ui.UiString
+import com.teambrake.brake.domain.usecase.StoreOnboardingCompletionUseCase
+import com.teambrake.brake.presentation.onboarding.R
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class CompleteViewModel @Inject constructor(
+ private val storeOnboardingCompletionUseCase: StoreOnboardingCompletionUseCase,
+ private val firebaseAnalytics: FirebaseAnalytics,
+) : ViewModel() {
+ private val _snackBarFlow = MutableSharedFlow()
+ val snackBarFlow = _snackBarFlow.asSharedFlow()
+
+ private val _navigationFlow = MutableSharedFlow()
+ val navigationFlow = _navigationFlow.asSharedFlow()
+
+ fun completeOnboarding() {
+ viewModelScope.launch {
+ storeOnboardingCompletionUseCase(
+ isComplete = true,
+ onError = {
+ _snackBarFlow.emit(
+ UiString.ResourceString(R.string.onboarding_snackbar_flag_save_error),
+ )
+ },
+ )
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.TUTORIAL_COMPLETE) {
+ param(FirebaseAnalytics.Param.SUCCESS, "true")
+ }
+ _navigationFlow.emit(CompleteNavState.NavigateToMain)
+ }
+ }
+}
diff --git a/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/complete/model/CompleteNavState.kt b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/complete/model/CompleteNavState.kt
new file mode 100644
index 00000000..fab9ec55
--- /dev/null
+++ b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/complete/model/CompleteNavState.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.presentation.onboarding.complete.model
+
+import androidx.compose.runtime.Immutable
+
+interface CompleteNavState {
+ @Immutable
+ data object NavigateToBack : CompleteNavState
+
+ @Immutable
+ data object NavigateToMain : CompleteNavState
+}
diff --git a/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/GuideScreen.kt b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/GuideScreen.kt
new file mode 100644
index 00000000..e1655291
--- /dev/null
+++ b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/GuideScreen.kt
@@ -0,0 +1,252 @@
+package com.teambrake.brake.presentation.onboarding.guide
+
+import android.annotation.SuppressLint
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.runtime.getValue
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.teambrake.brake.presentation.onboarding.guide.model.GuideModalState
+import com.teambrake.brake.presentation.onboarding.guide.model.GuideNavState
+import com.teambrake.brake.core.designsystem.component.BrakeTopAppbar
+import com.teambrake.brake.core.designsystem.component.LargeButton
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray700
+import com.teambrake.brake.core.designsystem.theme.LocalPadding
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.core.navigation.compositionlocal.LocalMainAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorProvider
+import com.teambrake.brake.presentation.onboarding.R
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.launch
+
+@SuppressLint("ConfigurationScreenWidthHeight")
+@Composable
+fun GuideRoute(
+ viewModel: GuideViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val screenWidth = LocalConfiguration.current.screenWidthDp.dp
+ val screenHorizontalPadding = LocalPadding.current.screenPaddingHorizontal
+ val navAction = LocalNavigatorAction.current
+ val navProvider = LocalNavigatorProvider.current
+ val mainAction = LocalMainAction.current
+ val modalState by viewModel.modalFlow.collectAsStateWithLifecycle()
+
+ LaunchedEffect(true) {
+ viewModel.snackBarFlow.collect {
+ mainAction.onShowErrorMessage(
+ message = it.asString(context = context),
+ )
+ }
+ }
+
+ if (modalState is GuideModalState.ShowLogoutModal) {
+ mainAction.OnShowLogoutDialog(
+ onConfirm = viewModel::logout,
+ onDismiss = viewModel::dismissModal,
+ )
+ }
+
+ LaunchedEffect(true) {
+ viewModel.navigationFlow.collect { effect ->
+ when (effect) {
+ GuideNavState.NavigateToLogin -> navAction.navigateToLogin(
+ navProvider.getNavOptionsClearingBackStack(),
+ )
+
+ GuideNavState.NavigateToPermission -> navAction.navigateToPermission()
+
+ GuideNavState.NavigateToComplete -> navAction.navigateToComplete()
+ }
+ }
+ }
+
+ GuideScreen(
+ screenWidth = screenWidth,
+ screenHorizontalPadding = screenHorizontalPadding,
+ onBackClick = viewModel::tryLogout,
+ onNextClick = { viewModel.continueFromGuide(context) },
+ )
+}
+
+@Composable
+fun GuideScreen(
+ screenWidth: Dp,
+ screenHorizontalPadding: Dp,
+ onBackClick: () -> Unit,
+ onNextClick: () -> Unit,
+) {
+ val pagerState = rememberPagerState(pageCount = { 3 })
+ val scope = rememberCoroutineScope()
+ val handleBackPress: () -> Unit = {
+
+ if (pagerState.currentPage == 0) {
+ onBackClick()
+ } else {
+ scope.launch {
+ pagerState.animateScrollToPage(pagerState.currentPage - 1)
+ }
+ }
+ }
+
+ BackHandler {
+ handleBackPress()
+ }
+
+ Scaffold(
+ modifier = Modifier
+ .fillMaxSize()
+ .navigationBarsPadding()
+ .statusBarsPadding(),
+ topBar = {
+ BrakeTopAppbar(
+ onClick = handleBackPress,
+ )
+ },
+ ) { paddingValues ->
+ ConstraintLayout(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues = paddingValues),
+ ) {
+ val (content, pointer, button) = createRefs()
+
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.8f)
+ .constrainAs(content) {
+ top.linkTo(parent.top)
+ bottom.linkTo(pointer.top)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ },
+ ) { index ->
+ // 각 페이지 이미지 및 설명 리스트
+ val imgList = persistentListOf(
+ R.drawable.img_guide1,
+ R.drawable.img_guide2,
+ R.drawable.img_guide3,
+ )
+ val descriptionList = persistentListOf(
+ stringResource(R.string.onboarding_guide_first_description),
+ stringResource(R.string.onboarding_guide_second_description),
+ stringResource(R.string.onboarding_guide_third_description),
+ )
+
+ Column(
+ modifier = Modifier.width(screenWidth),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ .widthIn(max = 300.dp)
+ .aspectRatio(1f),
+ painter = painterResource(
+ id = imgList[index],
+ ),
+ contentDescription = "Item $index",
+ )
+
+ VerticalSpacer(26.dp)
+
+ Text(
+ text = descriptionList[index],
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ .widthIn(max = 300.dp),
+ textAlign = TextAlign.Center,
+ style = BrakeTheme.typography.subtitle22SB,
+ )
+
+ VerticalSpacer(60.dp)
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .constrainAs(pointer) {
+ bottom.linkTo(button.top)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ }
+ .padding(bottom = 24.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ ) {
+ repeat(3) { index ->
+ Box(
+ modifier = Modifier
+ .width(8.dp)
+ .height(8.dp)
+ .clip(shape = CircleShape)
+ .background(if (index == pagerState.currentPage) White else Gray700),
+ )
+ }
+ }
+
+ LargeButton(
+ text = if (pagerState.currentPage < 2) {
+ stringResource(R.string.guide_continue_button_next)
+ } else {
+ stringResource(R.string.guide_continue_button_confirm)
+ },
+ onClick = {
+ if (pagerState.currentPage < 2) {
+ scope.launch {
+ pagerState.animateScrollToPage(pagerState.currentPage + 1)
+ }
+ } else {
+ onNextClick()
+ }
+ },
+ modifier = Modifier
+ .constrainAs(button) {
+ bottom.linkTo(parent.bottom)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ }
+ .padding(bottom = 24.dp)
+ .padding(horizontal = screenHorizontalPadding),
+ )
+ }
+ }
+}
diff --git a/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/GuideViewModel.kt b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/GuideViewModel.kt
new file mode 100644
index 00000000..d8db294d
--- /dev/null
+++ b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/GuideViewModel.kt
@@ -0,0 +1,75 @@
+package com.teambrake.brake.presentation.onboarding.guide
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.analytics.logEvent
+import com.teambrake.brake.core.model.user.Destination
+import com.teambrake.brake.core.permission.PermissionManager
+import com.teambrake.brake.core.ui.UiString
+import com.teambrake.brake.domain.usecase.LogoutUseCase
+import com.teambrake.brake.presentation.onboarding.R
+import com.teambrake.brake.presentation.onboarding.guide.model.GuideModalState
+import com.teambrake.brake.presentation.onboarding.guide.model.GuideNavState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class GuideViewModel @Inject constructor(
+ private val permissionManager: PermissionManager,
+ private val logoutUseCase: LogoutUseCase,
+ private val firebaseAnalytics: FirebaseAnalytics,
+) : ViewModel() {
+ private val _snackBarFlow = MutableSharedFlow()
+ val snackBarFlow = _snackBarFlow.asSharedFlow()
+
+ private val _modalFlow = MutableStateFlow(GuideModalState.GuideIdle)
+ val modalFlow = _modalFlow.asStateFlow()
+
+ private val _navigationFlow = MutableSharedFlow()
+ val navigationFlow = _navigationFlow.asSharedFlow()
+
+ fun tryLogout() {
+ _modalFlow.value = GuideModalState.ShowLogoutModal
+ }
+
+ fun dismissModal() {
+ _modalFlow.value = GuideModalState.GuideIdle
+ }
+
+ fun logout() {
+ viewModelScope.launch {
+ val dest = logoutUseCase(
+ onError = { error ->
+ _snackBarFlow.emit(
+ UiString.ResourceString(
+ resId = R.string.onboarding_snackbar_logout_error,
+ ),
+ )
+ },
+ )
+ if (dest is Destination.Login) {
+ firebaseAnalytics.logEvent("logout") {
+ param("reason", "user_requested")
+ }
+ _navigationFlow.emit(GuideNavState.NavigateToLogin)
+ }
+ }
+ }
+
+ fun continueFromGuide(context: Context) {
+ viewModelScope.launch {
+ if (permissionManager.isAllGranted(context)) {
+ _navigationFlow.emit(GuideNavState.NavigateToComplete)
+ } else {
+ _navigationFlow.emit(GuideNavState.NavigateToPermission)
+ }
+ }
+ }
+}
diff --git a/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/model/GuideModalState.kt b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/model/GuideModalState.kt
new file mode 100644
index 00000000..734bd5e5
--- /dev/null
+++ b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/model/GuideModalState.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.presentation.onboarding.guide.model
+
+import androidx.compose.runtime.Immutable
+
+sealed interface GuideModalState {
+ @Immutable
+ data object GuideIdle : GuideModalState
+
+ @Immutable
+ data object ShowLogoutModal : GuideModalState
+}
diff --git a/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/model/GuideNavState.kt b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/model/GuideNavState.kt
new file mode 100644
index 00000000..6fcd4907
--- /dev/null
+++ b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/guide/model/GuideNavState.kt
@@ -0,0 +1,15 @@
+package com.teambrake.brake.presentation.onboarding.guide.model
+
+import androidx.compose.runtime.Immutable
+
+interface GuideNavState {
+
+ @Immutable
+ data object NavigateToLogin : GuideNavState
+
+ @Immutable
+ data object NavigateToPermission : GuideNavState
+
+ @Immutable
+ data object NavigateToComplete : GuideNavState
+}
diff --git a/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/navigation/OnboardingNavigation.kt b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/navigation/OnboardingNavigation.kt
new file mode 100644
index 00000000..486f2887
--- /dev/null
+++ b/presentation/onboarding/src/main/java/com/teambrake/brake/presentation/onboarding/navigation/OnboardingNavigation.kt
@@ -0,0 +1,26 @@
+package com.teambrake.brake.presentation.onboarding.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.teambrake.brake.presentation.onboarding.complete.CompleteRoute
+import com.teambrake.brake.presentation.onboarding.guide.GuideRoute
+import com.teambrake.brake.core.navigation.route.InitialRoute
+
+fun NavController.navigateToGuide(navOptions: NavOptions? = null) {
+ navigate(InitialRoute.Onboarding.Guide, navOptions)
+}
+
+fun NavController.navigateToComplete(navOptions: NavOptions? = null) {
+ navigate(InitialRoute.Onboarding.Complete, navOptions)
+}
+
+fun NavGraphBuilder.onboardingNavGraph() {
+ composable {
+ GuideRoute()
+ }
+ composable {
+ CompleteRoute()
+ }
+}
diff --git a/presentation/onboarding/src/main/res/drawable/img_complete.png b/presentation/onboarding/src/main/res/drawable/img_complete.png
new file mode 100644
index 00000000..349c96f4
Binary files /dev/null and b/presentation/onboarding/src/main/res/drawable/img_complete.png differ
diff --git a/presentation/onboarding/src/main/res/drawable/img_guide1.png b/presentation/onboarding/src/main/res/drawable/img_guide1.png
new file mode 100644
index 00000000..59fe6a66
Binary files /dev/null and b/presentation/onboarding/src/main/res/drawable/img_guide1.png differ
diff --git a/presentation/onboarding/src/main/res/drawable/img_guide2.png b/presentation/onboarding/src/main/res/drawable/img_guide2.png
new file mode 100644
index 00000000..41756794
Binary files /dev/null and b/presentation/onboarding/src/main/res/drawable/img_guide2.png differ
diff --git a/presentation/onboarding/src/main/res/drawable/img_guide3.png b/presentation/onboarding/src/main/res/drawable/img_guide3.png
new file mode 100644
index 00000000..d7ab2644
Binary files /dev/null and b/presentation/onboarding/src/main/res/drawable/img_guide3.png differ
diff --git a/presentation/onboarding/src/main/res/values/strings.xml b/presentation/onboarding/src/main/res/values/strings.xml
new file mode 100644
index 00000000..bccfd5a4
--- /dev/null
+++ b/presentation/onboarding/src/main/res/values/strings.xml
@@ -0,0 +1,15 @@
+
+
+ 다음
+ 확인
+
+ 로그아웃 중 오류가 발생했습니다.
+
+ 반가워요
+ 목표를 세우고 지키는\n새로운 일상을 경험해보세요"
+ 시작하기
+ 온보딩 완료 상태 저장 중 오류가 발생했습니다.
+ 앱을 켤 때, 사용 시간을\n설정해보세요.
+ 사용 시간이 지나면, 딱 2번,\n5분 더 사용할 수 있어요.
+ 사용이 끝나면, 3분 동안\n앱을 절대 사용할 수 없어요.
+
diff --git a/presentation/permission/.gitignore b/presentation/permission/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/presentation/permission/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/presentation/permission/build.gradle.kts b/presentation/permission/build.gradle.kts
new file mode 100644
index 00000000..651a94c0
--- /dev/null
+++ b/presentation/permission/build.gradle.kts
@@ -0,0 +1,13 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.feature)
+}
+
+android {
+ setNamespace("presentation.permission")
+}
+
+dependencies {
+ implementation(projects.core.permission)
+}
diff --git a/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/PermissionScreen.kt b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/PermissionScreen.kt
new file mode 100644
index 00000000..9f68d534
--- /dev/null
+++ b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/PermissionScreen.kt
@@ -0,0 +1,353 @@
+package com.teambrake.brake.presentation.permission
+
+import android.annotation.SuppressLint
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.navOptions
+import com.teambrake.brake.presentation.permission.component.OnShowAccessibilityAgreementDialog
+import com.teambrake.brake.presentation.permission.model.PermissionItem
+import com.teambrake.brake.presentation.permission.model.PermissionModalState
+import com.teambrake.brake.presentation.permission.model.PermissionNavState
+import com.teambrake.brake.presentation.permission.model.PermissionUiState
+import com.teambrake.brake.core.designsystem.component.BrakeTopAppbar
+import com.teambrake.brake.core.designsystem.component.LargeButton
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.core.designsystem.theme.Gray700
+import com.teambrake.brake.core.designsystem.theme.LocalPadding
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.core.navigation.compositionlocal.LocalMainAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorProvider
+import com.teambrake.brake.core.navigation.route.InitialRoute
+import com.teambrake.brake.core.navigation.route.stringRoute
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.coroutines.launch
+
+@SuppressLint("ConfigurationScreenWidthHeight")
+@Composable
+fun PermissionRoute(
+ viewModel: PermissionViewModel = hiltViewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val screenWidth = LocalConfiguration.current.screenWidthDp.dp
+ val screenHorizontalPadding = LocalPadding.current.screenPaddingHorizontal
+ val navAction = LocalNavigatorAction.current
+ val mainAction = LocalMainAction.current
+ val navProvider = LocalNavigatorProvider.current
+ val modalState by viewModel.modalFlow.collectAsStateWithLifecycle()
+
+ // ViewModel 초기화,
+ // UiState 초기화시 context 사용이 필요하며, 이는 ViewModel 내부에서만 이루어질 수 없음
+ LaunchedEffect(Unit) {
+ viewModel.refreshPermissions(context)
+ }
+
+ LaunchedEffect(true) {
+ viewModel.snackBarFlow.collect {
+ mainAction.onShowErrorMessage(
+ message = it.asString(context = context),
+ )
+ }
+ }
+
+ // 외부 화면으로 이동 side effect 처리
+ LaunchedEffect(true) {
+ viewModel.navigationFlow.collect { effect ->
+ when (effect) {
+ PermissionNavState.NavigateToLogin -> {
+ navAction.navigateToLogin(navProvider.getNavOptionsClearingBackStack())
+ }
+
+ PermissionNavState.NavigateToBack -> navAction.popBackStack()
+
+ is PermissionNavState.RequestPermission -> {
+ // 권한 요청을 위한 Intent 실행
+ context.startActivity(effect.intent)
+ }
+
+ PermissionNavState.NavigateToMain -> {
+ navAction.navigateToHome(
+ navOptions = navProvider.getNavOptionsClearingBackStack(),
+ )
+ }
+
+ PermissionNavState.NavigateToComplete -> {
+ navAction.navigateToComplete(
+ navOptions {
+ popUpTo(InitialRoute.Permission) { inclusive = true }
+ },
+ )
+ }
+ }
+ }
+ }
+
+ // 권한 설정 창에서 복귀 시 권한 상태를 새로고침
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ viewModel.refreshPermissions(context)
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ when (modalState) {
+ is PermissionModalState.ShowLogoutModal -> mainAction.OnShowLogoutDialog(
+ onConfirm = viewModel::logout,
+ onDismiss = viewModel::dismissModal,
+ )
+ is PermissionModalState.ShowAccessibilityAgreementModal -> {
+ OnShowAccessibilityAgreementDialog(
+ onConfirm = { viewModel.requestAccessibilityPermission(context) },
+ onDismiss = viewModel::dismissModal,
+ )
+ }
+ else -> {}
+ }
+
+ PermissionScreen(
+ uiState = uiState,
+ screenWidth = screenWidth,
+ screenHorizontalPadding = screenHorizontalPadding,
+ onBackClick = {
+ if (navProvider.getPreviousDestination() ==
+ InitialRoute.Onboarding.Guide.stringRoute()
+ ) {
+ viewModel.popBackStack()
+ } else {
+ viewModel.tryLogout()
+ }
+ },
+ onRequestPermissionClick = { permissionItem ->
+ viewModel.requestPermission(context, permissionItem)
+ },
+ )
+}
+
+@Composable
+fun PermissionScreen(
+ uiState: PermissionUiState,
+ screenWidth: Dp,
+ screenHorizontalPadding: Dp,
+ onBackClick: () -> Unit,
+ onRequestPermissionClick: (PermissionItem) -> Unit,
+) {
+ val pageSize = uiState.permissions.size
+ val pagerState = rememberPagerState(pageCount = { pageSize })
+ val scope = rememberCoroutineScope()
+ val handleBackPress: () -> Unit = {
+ if (pagerState.currentPage == 0) {
+ onBackClick()
+ } else {
+ scope.launch {
+ pagerState.animateScrollToPage(pagerState.currentPage - 1)
+ }
+ }
+ }
+
+ BackHandler {
+ handleBackPress()
+ }
+
+ Scaffold(
+ modifier = Modifier
+ .fillMaxSize()
+ .navigationBarsPadding()
+ .statusBarsPadding(),
+ topBar = {
+ BrakeTopAppbar(
+ onClick = handleBackPress,
+ )
+ },
+ ) { paddingValues ->
+ ConstraintLayout(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues = paddingValues)
+ .padding(horizontal = screenHorizontalPadding),
+ ) {
+ val (content, pointer, button) = createRefs()
+
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .constrainAs(content) {
+ top.linkTo(parent.top)
+ bottom.linkTo(pointer.top)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ },
+ ) { index ->
+ // 각 페이지의 타이틀, 설명, 이미지
+ val contentMap = persistentMapOf>(
+ PermissionItem.OVERLAY to Triple(
+ stringResource(R.string.permission_overlay_title),
+ stringResource(R.string.permission_overlay_description),
+ R.drawable.img_permission1,
+ ),
+ PermissionItem.STATS to Triple(
+ stringResource(R.string.permission_stats_title),
+ stringResource(R.string.permission_stats_description),
+ R.drawable.img_permission1,
+ ),
+ PermissionItem.EXACT_ALARM to Triple(
+ stringResource(R.string.permission_exact_alarm_title),
+ stringResource(R.string.permission_exact_alarm_description),
+ R.drawable.img_permission2,
+ ),
+ PermissionItem.ACCESSIBILITY to Triple(
+ stringResource(R.string.permission_accessibility_title),
+ stringResource(R.string.permission_accessibility_description),
+ R.drawable.img_permission3,
+ ),
+ )
+
+ Column(
+ modifier = Modifier
+ .width(screenWidth)
+ .fillMaxHeight(0.8f)
+ .heightIn(max = 700.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ VerticalSpacer(29.dp)
+
+ Text(
+ text = uiState.permissions[index].let { contentMap[it]?.first } ?: "",
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ style = BrakeTheme.typography.subtitle22SB,
+ )
+
+ VerticalSpacer(20.dp)
+
+ Text(
+ text = uiState.permissions[index].let { contentMap[it]?.second } ?: "",
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ style = BrakeTheme.typography.body16M,
+ color = Gray200,
+ )
+
+ VerticalSpacer(80.dp)
+
+ uiState.permissions[index].run {
+ Image(
+ modifier = Modifier
+ .fillMaxWidth(
+ if (this == PermissionItem.OVERLAY ||
+ this == PermissionItem.STATS
+ ) {
+ 1.0f
+ } else {
+ 0.9f
+ },
+ )
+ .padding(horizontal = 0.dp)
+ .clickable(
+ interactionSource = null,
+ indication = null,
+ onClick = {
+ onRequestPermissionClick(uiState.permissions[pagerState.currentPage])
+ },
+ ),
+ painter = painterResource(
+ id = contentMap[this]!!.third,
+ ),
+ contentScale = ContentScale.FillWidth,
+ contentDescription = "Item $index",
+ )
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .constrainAs(pointer) {
+ bottom.linkTo(button.top)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ }
+ .padding(bottom = 24.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ ) {
+ repeat(pageSize) { index ->
+ Box(
+ modifier = Modifier
+ .width(8.dp)
+ .height(8.dp)
+ .clip(shape = CircleShape)
+ .background(if (index == pagerState.currentPage) White else Gray700),
+ )
+ }
+ }
+
+ LargeButton(
+ text = stringResource(R.string.permission_allow_button),
+ onClick = {
+ onRequestPermissionClick(uiState.permissions[pagerState.currentPage])
+ },
+ modifier = Modifier
+ .constrainAs(button) {
+ bottom.linkTo(parent.bottom)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ }
+ .fillMaxWidth()
+ .widthIn(max = 500.dp)
+ .padding(bottom = 24.dp),
+ )
+ }
+ }
+}
diff --git a/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/PermissionViewModel.kt b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/PermissionViewModel.kt
new file mode 100644
index 00000000..4c41e67f
--- /dev/null
+++ b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/PermissionViewModel.kt
@@ -0,0 +1,188 @@
+package com.teambrake.brake.presentation.permission
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.teambrake.brake.presentation.permission.model.PermissionItem
+import com.teambrake.brake.presentation.permission.model.PermissionModalState
+import com.teambrake.brake.presentation.permission.model.PermissionNavState
+import com.teambrake.brake.presentation.permission.model.PermissionUiState
+import com.teambrake.brake.core.permission.PermissionManager
+import com.teambrake.brake.core.permission.PermissionType
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.analytics.logEvent
+import com.teambrake.brake.core.model.user.Destination
+import com.teambrake.brake.core.ui.UiString
+import com.teambrake.brake.domain.usecase.DecideNextDestinationFromPermissionUseCase
+import com.teambrake.brake.domain.usecase.LogoutUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.PersistentList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class PermissionViewModel @Inject constructor(
+ private val permissionManager: PermissionManager,
+ private val decideDestinationUseCase: DecideNextDestinationFromPermissionUseCase,
+ private val logoutUseCase: LogoutUseCase,
+ private val firebaseAnalytics: FirebaseAnalytics,
+) : ViewModel() {
+
+ private val _snackBarFlow = MutableSharedFlow()
+ val snackBarFlow = _snackBarFlow.asSharedFlow()
+
+ private val _uiState =
+ MutableStateFlow(PermissionUiState(permissions = persistentListOf()))
+ val uiState = _uiState.asStateFlow()
+
+ private val _navigationFlow = MutableSharedFlow()
+ val navigationFlow = _navigationFlow.asSharedFlow()
+
+ private val _modalFlow =
+ MutableStateFlow(PermissionModalState.PermissionIdle)
+ val modalFlow = _modalFlow.asStateFlow()
+
+ private fun stackPermissions(context: Context): PersistentList {
+ try {
+ val permissionList = mutableListOf()
+ if (!permissionManager.isGranted(context, PermissionType.OVERLAY)) {
+ permissionList.add(PermissionItem.OVERLAY)
+ }
+ if (!permissionManager.isGranted(context, PermissionType.STATS)) {
+ permissionList.add(PermissionItem.STATS)
+ }
+ if (!permissionManager.isGranted(context, PermissionType.EXACT_ALARM)) {
+ permissionList.add(PermissionItem.EXACT_ALARM)
+ }
+ if (!permissionManager.isGranted(context, PermissionType.ACCESSIBILITY)) {
+ permissionList.add(PermissionItem.ACCESSIBILITY)
+ }
+ return permissionList.toPersistentList()
+ } catch (_: Exception) {
+ viewModelScope.launch {
+ _snackBarFlow.emit(
+ UiString.ResourceString(R.string.permission_snackbar_permission_stack_error),
+ )
+ }
+ // 모든 권한을 포함
+ return persistentListOf(
+ PermissionItem.OVERLAY,
+ PermissionItem.STATS,
+ PermissionItem.EXACT_ALARM,
+ PermissionItem.ACCESSIBILITY,
+ )
+ }
+ }
+
+ private fun decideNextDestination() {
+ viewModelScope.launch {
+ val status = decideDestinationUseCase.invoke(
+ onError = {
+ _snackBarFlow.emit(
+ UiString.ResourceString(R.string.permission_snackbar_decide_destination_error),
+ )
+ },
+ )
+ firebaseAnalytics.logEvent("done_permission", null)
+ when (status) {
+ is Destination.PermissionOrHome -> _navigationFlow.emit(PermissionNavState.NavigateToMain)
+ is Destination.Onboarding -> _navigationFlow.emit(
+ PermissionNavState.NavigateToComplete,
+ )
+
+ else -> {}
+ }
+ }
+ }
+
+ fun refreshPermissions(context: Context) {
+ val permissionList = stackPermissions(context)
+ if (permissionList.isEmpty()) {
+ decideNextDestination()
+ } else {
+ _uiState.value = PermissionUiState(permissions = permissionList)
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "permission_screen")
+ }
+ }
+ }
+
+ fun requestPermission(context: Context, type: PermissionItem) {
+ if (type == PermissionItem.ACCESSIBILITY) {
+ _modalFlow.value = PermissionModalState.ShowAccessibilityAgreementModal
+ } else {
+ viewModelScope.launch {
+ _navigationFlow.emit(
+ PermissionNavState.RequestPermission(
+ permissionManager.getIntent(context, parsePermissionItemToType(type)),
+ ),
+ )
+ }
+ }
+ }
+
+ fun requestAccessibilityPermission(context: Context) {
+ _modalFlow.value = PermissionModalState.PermissionIdle
+ viewModelScope.launch {
+ _navigationFlow.emit(
+ PermissionNavState.RequestPermission(
+ permissionManager.getIntent(context, PermissionType.ACCESSIBILITY),
+ ),
+ )
+ }
+ }
+
+ fun tryLogout() {
+ _modalFlow.value = PermissionModalState.ShowLogoutModal
+ }
+
+ fun dismissModal() {
+ _modalFlow.value = PermissionModalState.PermissionIdle
+ }
+
+ fun logout() {
+ viewModelScope.launch {
+ val dest = logoutUseCase(
+ onError = { error ->
+ _snackBarFlow.emit(
+ UiString.ResourceString(
+ resId = R.string.permission_snackbar_logout_error,
+ ),
+ )
+ },
+ )
+ if (dest is Destination.Login) {
+ firebaseAnalytics.logEvent("app_logout") {
+ param(FirebaseAnalytics.Param.METHOD, "user_logout")
+ }
+ _navigationFlow.emit(PermissionNavState.NavigateToLogin)
+ }
+ }
+ }
+
+ fun popBackStack() {
+ viewModelScope.launch {
+ _navigationFlow.emit(PermissionNavState.NavigateToBack)
+ }
+ firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
+ param(FirebaseAnalytics.Param.SCREEN_NAME, "onboarding_guide_screen")
+ }
+ }
+
+ companion object {
+ private val parsePermissionItemToType = { permissionItem: PermissionItem ->
+ when (permissionItem) {
+ PermissionItem.OVERLAY -> PermissionType.OVERLAY
+ PermissionItem.STATS -> PermissionType.STATS
+ PermissionItem.EXACT_ALARM -> PermissionType.EXACT_ALARM
+ PermissionItem.ACCESSIBILITY -> PermissionType.ACCESSIBILITY
+ }
+ }
+ }
+}
diff --git a/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/component/OnShowAccessibilityAgreementDialog.kt b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/component/OnShowAccessibilityAgreementDialog.kt
new file mode 100644
index 00000000..504e127c
--- /dev/null
+++ b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/component/OnShowAccessibilityAgreementDialog.kt
@@ -0,0 +1,71 @@
+package com.teambrake.brake.presentation.permission.component
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.BaseDialog
+import com.teambrake.brake.core.designsystem.component.DialogButton
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray300
+import com.teambrake.brake.core.designsystem.theme.Gray800
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.presentation.permission.R
+
+@Composable
+fun OnShowAccessibilityAgreementDialog(
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ BaseDialog(
+ onDismissRequest = {},
+ dismissButton = {
+ DialogButton(
+ text = stringResource(R.string.permission_dialog_accessibility_agreement_dismiss_button),
+ onClick = onDismiss,
+ containerColor = Gray800,
+ contentColor = White,
+ )
+ },
+ confirmButton = {
+ DialogButton(
+ text = stringResource(R.string.permission_dialog_accessibility_agreement_confirm_button),
+ onClick = onConfirm,
+ )
+ },
+ ) {
+ BackHandler {
+ onDismiss()
+ }
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.permission_dialog_accessibility_agreement_title),
+ style = BrakeTheme.typography.subtitle22B,
+ color = Gray300,
+ textAlign = TextAlign.Center,
+ )
+
+ VerticalSpacer(24.dp)
+
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.permission_dialog_accessibility_agreement_body),
+ style = BrakeTheme.typography.body16M,
+ color = Gray300,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
diff --git a/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionItem.kt b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionItem.kt
new file mode 100644
index 00000000..bc94fa73
--- /dev/null
+++ b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionItem.kt
@@ -0,0 +1,11 @@
+package com.teambrake.brake.presentation.permission.model
+
+import androidx.compose.runtime.Stable
+
+@Stable
+enum class PermissionItem {
+ OVERLAY,
+ STATS,
+ EXACT_ALARM,
+ ACCESSIBILITY,
+}
diff --git a/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionModalState.kt b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionModalState.kt
new file mode 100644
index 00000000..4bc7cae9
--- /dev/null
+++ b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionModalState.kt
@@ -0,0 +1,14 @@
+package com.teambrake.brake.presentation.permission.model
+
+import androidx.compose.runtime.Immutable
+
+sealed interface PermissionModalState {
+ @Immutable
+ data object PermissionIdle : PermissionModalState
+
+ @Immutable
+ data object ShowLogoutModal : PermissionModalState
+
+ @Immutable
+ data object ShowAccessibilityAgreementModal : PermissionModalState
+}
diff --git a/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionNavState.kt b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionNavState.kt
new file mode 100644
index 00000000..e4c166d0
--- /dev/null
+++ b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionNavState.kt
@@ -0,0 +1,26 @@
+package com.teambrake.brake.presentation.permission.model
+
+import android.content.Intent
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+
+@Stable
+interface PermissionNavState {
+
+ @Immutable
+ data object NavigateToBack : PermissionNavState
+
+ @Immutable
+ data object NavigateToLogin : PermissionNavState
+
+ @Immutable
+ data class RequestPermission(
+ val intent: Intent,
+ ) : PermissionNavState
+
+ @Immutable
+ data object NavigateToComplete : PermissionNavState
+
+ @Immutable
+ data object NavigateToMain : PermissionNavState
+}
diff --git a/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionUiState.kt b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionUiState.kt
new file mode 100644
index 00000000..f6041b73
--- /dev/null
+++ b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/model/PermissionUiState.kt
@@ -0,0 +1,9 @@
+package com.teambrake.brake.presentation.permission.model
+
+import androidx.compose.runtime.Immutable
+import kotlinx.collections.immutable.PersistentList
+
+@Immutable
+data class PermissionUiState(
+ val permissions: PersistentList,
+)
diff --git a/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/navigation/PermissionNavigation.kt b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/navigation/PermissionNavigation.kt
new file mode 100644
index 00000000..a1fada56
--- /dev/null
+++ b/presentation/permission/src/main/java/com/teambrake/brake/presentation/permission/navigation/PermissionNavigation.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.presentation.permission.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.teambrake.brake.presentation.permission.PermissionRoute
+import com.teambrake.brake.core.navigation.route.InitialRoute
+
+fun NavController.navigateToPermission(navOptions: NavOptions? = null) {
+ navigate(InitialRoute.Permission, navOptions)
+}
+
+fun NavGraphBuilder.permissionNavGraph() {
+ composable {
+ PermissionRoute()
+ }
+}
diff --git a/presentation/permission/src/main/res/drawable/img_permission1.png b/presentation/permission/src/main/res/drawable/img_permission1.png
new file mode 100644
index 00000000..79e972ab
Binary files /dev/null and b/presentation/permission/src/main/res/drawable/img_permission1.png differ
diff --git a/presentation/permission/src/main/res/drawable/img_permission2.png b/presentation/permission/src/main/res/drawable/img_permission2.png
new file mode 100644
index 00000000..de652e13
Binary files /dev/null and b/presentation/permission/src/main/res/drawable/img_permission2.png differ
diff --git a/presentation/permission/src/main/res/drawable/img_permission3.png b/presentation/permission/src/main/res/drawable/img_permission3.png
new file mode 100644
index 00000000..487955cc
Binary files /dev/null and b/presentation/permission/src/main/res/drawable/img_permission3.png differ
diff --git a/presentation/permission/src/main/res/values/strings.xml b/presentation/permission/src/main/res/values/strings.xml
new file mode 100644
index 00000000..2f1eb2f4
--- /dev/null
+++ b/presentation/permission/src/main/res/values/strings.xml
@@ -0,0 +1,25 @@
+
+
+ 다른 앱 위 표시 권한을\n허용해주세요
+ 앱 사용 시간이 다되면 차단창을 띄워드릴게요.
+
+ 사용정보 접근 권한을\n허용해주세요
+ 앱 사용량을 모니터링할게요.
+
+ 정확한 알람 설정 권한을\n허용해주세요
+ 정확한 타이머 알림을 보내드릴게요.
+
+ 접근성 서비스 권한을\n허용해주세요
+ 앱이 실행되는 것을 감지해요.
+
+ 허용하기
+
+ 로그아웃 중에 문제가 생겼어요.
+ 권한 허용 상태 확인 중에 문제가 생겼어요.
+ 온보딩 상태 저장에 문제가 생겼어요.
+
+ Brake!는 사용자가 설정한 앱의 사용을 시작할 때 ‘세션 타이머’ 기능을 사용 설정하기 위해 ‘접근성 서비스’ 권한을 활용하여 현재 실행 중인 앱 이름 및 사용 상태 정보를 수집합니다.\n\n이 권한을 통해 현재 화면에 어떤 앱이 실행 중인지 확인하여, 사용자가 미리 설정한 ‘관리 앱’일 경우 그 위에 ‘세션 타이머’를 표시합니다.\n\n수집한 정보는 오직 ‘세션 타이머’ 기능을 제공하는 목적으로만 사용되며, 다른 어떠한 개인 정보도 절대 수집하지 않습니다.\n\n이 기능 활성화에 동의하시겠습니까?
+ 핵심 기능 동작을 위해 필요해요
+ 동의하지 않음
+ 동의
+
diff --git a/presentation/report/.gitignore b/presentation/report/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/presentation/report/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/presentation/report/build.gradle.kts b/presentation/report/build.gradle.kts
new file mode 100644
index 00000000..01d3d986
--- /dev/null
+++ b/presentation/report/build.gradle.kts
@@ -0,0 +1,13 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.feature)
+}
+
+android {
+ setNamespace("presentation.report")
+}
+
+dependencies {
+ implementation(projects.core.util)
+}
diff --git a/presentation/report/src/main/AndroidManifest.xml b/presentation/report/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..568741e5
--- /dev/null
+++ b/presentation/report/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/presentation/report/src/main/java/com/teambrake/brake/presentation/report/ReportScreen.kt b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/ReportScreen.kt
new file mode 100644
index 00000000..e180df84
--- /dev/null
+++ b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/ReportScreen.kt
@@ -0,0 +1,89 @@
+package com.teambrake.brake.presentation.report
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.teambrake.brake.presentation.report.body.ReportBody
+import com.teambrake.brake.presentation.report.contract.ReportUiState
+import com.teambrake.brake.core.designsystem.theme.LocalDynamicPaddings
+import com.teambrake.brake.core.navigation.compositionlocal.LocalMainAction
+import com.teambrake.brake.core.ui.ErrorBody
+
+@Composable
+internal fun ReportRoute(
+ padding: PaddingValues,
+ viewModel: ReportViewModel = hiltViewModel(),
+) {
+ val reportUiState by viewModel.reportUiState.collectAsStateWithLifecycle()
+ val mainAction = LocalMainAction.current
+ val context = LocalContext.current
+ val bottomPadding = LocalDynamicPaddings.current.paddings.bottomNavBarHeight
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(bottom = bottomPadding),
+ ) {
+ ReportContent(
+ reportUiState = reportUiState,
+ onRetry = viewModel::refreshReport,
+ loadingContent = {
+ mainAction.OnShowLoading()
+ },
+ )
+ }
+
+ LaunchedEffect(true) {
+ viewModel.snackBarFlow.collect {
+ mainAction.onShowErrorMessage(
+ message = it.asString(context = context),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ReportContent(
+ reportUiState: ReportUiState,
+ onRetry: () -> Unit,
+ loadingContent: @Composable () -> Unit,
+) {
+ AnimatedContent(
+ targetState = reportUiState,
+ transitionSpec = {
+ fadeIn() togetherWith fadeOut()
+ },
+ label = "ReportContent",
+ ) { state ->
+ when (state) {
+ ReportUiState.Error -> {
+ ErrorBody(
+ onRetry = onRetry,
+ )
+ }
+
+ ReportUiState.Loading -> {
+ loadingContent()
+ }
+
+ is ReportUiState.Success -> {
+ ReportBody(
+ reportUiState = state,
+ )
+ }
+ }
+ }
+}
diff --git a/presentation/report/src/main/java/com/teambrake/brake/presentation/report/ReportViewModel.kt b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/ReportViewModel.kt
new file mode 100644
index 00000000..12c2aa89
--- /dev/null
+++ b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/ReportViewModel.kt
@@ -0,0 +1,59 @@
+package com.teambrake.brake.presentation.report
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.teambrake.brake.presentation.report.contract.ReportUiState
+import com.teambrake.brake.core.ui.UiString
+import com.teambrake.brake.domain.repository.StatisticRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+internal class ReportViewModel @Inject constructor(
+ private val statisticRepository: StatisticRepository,
+) : ViewModel() {
+
+ private val trigger = MutableSharedFlow(replay = 1)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val reportUiState: StateFlow = trigger.onStart {
+ refreshReport()
+ }.flatMapLatest {
+ statisticRepository.getStatistics {
+ _snackBarFlow.emit(
+ UiString.DynamicString(it.message ?: "Unknown error occurred"),
+ )
+ }
+ }.map { statistics ->
+ if (statistics == null) {
+ ReportUiState.Error
+ } else {
+ ReportUiState.Success(
+ statisticList = statistics,
+ )
+ }
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = ReportUiState.Loading,
+ )
+
+ private val _snackBarFlow = MutableSharedFlow()
+ val snackBarFlow = _snackBarFlow.asSharedFlow()
+
+ fun refreshReport() {
+ viewModelScope.launch {
+ trigger.emit(Unit)
+ }
+ }
+}
diff --git a/presentation/report/src/main/java/com/teambrake/brake/presentation/report/body/ReportBody.kt b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/body/ReportBody.kt
new file mode 100644
index 00000000..15ad10cf
--- /dev/null
+++ b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/body/ReportBody.kt
@@ -0,0 +1,157 @@
+package com.teambrake.brake.presentation.report.body
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.teambrake.brake.presentation.report.component.WeeklyChart
+import com.teambrake.brake.presentation.report.contract.ReportUiState
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray100
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.core.designsystem.theme.Gray300
+import com.teambrake.brake.core.designsystem.theme.Gray850
+import com.teambrake.brake.core.model.app.Statistics
+import com.teambrake.brake.core.util.extensions.toShortDateFormat
+import com.teambrake.brake.presentation.report.R
+import java.time.Duration
+import java.time.LocalDate
+
+@Composable
+internal fun ReportBody(
+ reportUiState: ReportUiState.Success,
+) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ VerticalSpacer(100.dp)
+ Text(
+ text = stringResource(R.string.today_total_usage_time),
+ style = BrakeTheme.typography.body12M,
+ color = Gray200,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 36.dp),
+ )
+ VerticalSpacer(4.dp)
+ Row(
+ verticalAlignment = Alignment.Bottom,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 36.dp),
+ ) {
+ Text(
+ text = reportUiState.todayUsageHours.toString(),
+ style = BrakeTheme.typography.body16M,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = 48.sp,
+ )
+ Text(
+ text = stringResource(R.string.hours),
+ style = BrakeTheme.typography.subtitle22SB,
+ color = Gray300,
+ modifier = Modifier.padding(bottom = 6.dp),
+ )
+ HorizontalSpacer(8.dp)
+ Text(
+ text = reportUiState.todayUsageMinutes.toString(),
+ style = BrakeTheme.typography.body16M,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = 48.sp,
+ )
+ Text(
+ text = stringResource(R.string.minutes),
+ style = BrakeTheme.typography.subtitle22SB,
+ color = Gray300,
+ modifier = Modifier.padding(bottom = 6.dp),
+ )
+ }
+ VerticalSpacer(24.dp)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .padding(horizontal = 16.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(Gray850),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ top = 16.dp,
+ bottom = 20.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.ic_calendar),
+ contentDescription = null,
+ )
+ HorizontalSpacer(8.dp)
+ Text(
+ text = LocalDate.now().toShortDateFormat(),
+ style = BrakeTheme.typography.body14M,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ WeeklyChart(
+ statistics = reportUiState.weeklyStatistics,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ VerticalSpacer(20.dp)
+ Text(
+ text = stringResource(R.string.usage_feedback_less),
+ style = BrakeTheme.typography.body16M,
+ color = Gray100,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth(),
+ )
+ VerticalSpacer(30.dp)
+ }
+}
+
+@Preview
+@Composable
+private fun ReportBodyPreview() {
+ BrakeTheme {
+ ReportBody(
+ reportUiState = ReportUiState.Success(
+ statisticList = listOf(
+ Statistics(
+ date = LocalDate.now(),
+ dayOfWeek = "THURSDAY",
+ actualTime = Duration.ofMinutes(120),
+ goalTime = Duration.ofMinutes(180),
+ ),
+ ),
+ ),
+ )
+ }
+}
diff --git a/presentation/report/src/main/java/com/teambrake/brake/presentation/report/component/WeeklyChart.kt b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/component/WeeklyChart.kt
new file mode 100644
index 00000000..a1eb1798
--- /dev/null
+++ b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/component/WeeklyChart.kt
@@ -0,0 +1,362 @@
+package com.teambrake.brake.presentation.report.component
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.clipPath
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.teambrake.brake.core.designsystem.component.HorizontalSpacer
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.BrakeYellow
+import com.teambrake.brake.core.designsystem.theme.Gray100
+import com.teambrake.brake.core.designsystem.theme.Gray400
+import com.teambrake.brake.core.designsystem.theme.Gray700
+import com.teambrake.brake.core.designsystem.theme.pretendard
+import com.teambrake.brake.core.model.app.Statistics
+import com.teambrake.brake.core.util.DateUtil
+import com.teambrake.brake.presentation.report.R
+import java.time.Duration
+import java.time.LocalDate
+import kotlin.math.max
+
+@Composable
+internal fun WeeklyChart(
+ statistics: List,
+ modifier: Modifier = Modifier,
+) {
+ val colorScheme = BrakeTheme.colorScheme
+ val textMeasurer = rememberTextMeasurer()
+ var startAnimation by remember { mutableStateOf(false) }
+
+ val animatedProgress by animateFloatAsState(
+ targetValue = if (startAnimation) 1f else 0f,
+ animationSpec = tween(
+ durationMillis = 1200,
+ easing = LinearEasing,
+ ),
+ label = "chartAnimation",
+ )
+
+ LaunchedEffect(statistics) {
+ startAnimation = true
+ }
+
+ val maxHours = statistics.maxOfOrNull {
+ max(it.actualTime.toMinutes(), it.goalTime.toMinutes()) / 60.0
+ }?.let { rawMax ->
+ when {
+ rawMax <= 2.5 -> 2.5
+ rawMax <= 5 -> 5.0
+ rawMax <= 10 -> 10.0
+ rawMax <= 20 -> 20.0
+ else -> kotlin.math.ceil(rawMax / 4.0) * 4.0
+ }
+ } ?: 2.5
+
+ val timeLabels = remember(maxHours) {
+ when {
+ maxHours <= 2.5 -> (0..4).map { it * 0.5 }
+ maxHours <= 5 -> (0..4).map { it * 1.0 }
+ maxHours <= 10 -> (0..4).map { it * 2.0 }
+ else -> (0..4).map { it * (maxHours / 4.0) }
+ }
+ }
+
+ val dayLabels = remember {
+ DateUtil.getShortDayOfWeekNames()
+ }
+
+ Column(modifier = modifier) {
+ Canvas(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ ) {
+ val chartSpacing = 2.dp.toPx()
+ val availableWidth = size.width - 40.dp.toPx()
+ val totalSpacing = chartSpacing * 6
+ val barWidth = (availableWidth - totalSpacing) / 7
+ val startX = 20.dp.toPx()
+
+ val labelTextStyle = TextStyle(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ fontFamily = pretendard,
+ color = Gray700,
+ )
+ val labelHalf = textMeasurer.measure("0", labelTextStyle).size.height / 2f
+
+ val chartHeight = size.height - (labelHalf * 2)
+
+ timeLabels.forEach { time ->
+ val y = labelHalf + chartHeight - ((time / maxHours) * chartHeight).toFloat()
+
+ val displayText = if (time % 1.0 == 0.0) {
+ time.toInt().toString()
+ } else {
+ time.toString()
+ }
+
+ drawText(
+ textMeasurer = textMeasurer,
+ text = displayText,
+ topLeft = Offset(startX - 20.dp.toPx(), y - labelHalf),
+ style = labelTextStyle,
+ )
+ }
+
+ statistics.forEachIndexed { index, stat ->
+ val x = startX + index * (barWidth + chartSpacing)
+ val cornerRadius = CornerRadius(7.dp.toPx())
+
+ val goalHeight =
+ (stat.goalTime.toMinutes() / 60.0 / maxHours * chartHeight).toFloat()
+
+ drawRoundRect(
+ color = Color(0x40DADFED),
+ topLeft = Offset(x, labelHalf + chartHeight - goalHeight),
+ size = androidx.compose.ui.geometry.Size(barWidth, goalHeight),
+ cornerRadius = cornerRadius,
+ )
+
+ val clipPath = Path().apply {
+ addRoundRect(
+ androidx.compose.ui.geometry.RoundRect(
+ left = x,
+ top = labelHalf + chartHeight - goalHeight,
+ right = x + barWidth,
+ bottom = labelHalf + chartHeight,
+ cornerRadius = cornerRadius,
+ ),
+ )
+ }
+
+ clipPath(clipPath) {
+ drawStripePattern(
+ x = x,
+ y = labelHalf + chartHeight - goalHeight,
+ width = barWidth,
+ height = goalHeight,
+ )
+ }
+
+ val fullActualHeight =
+ (stat.actualTime.toMinutes() / 60.0 / maxHours * chartHeight).toFloat()
+ val animatedActualHeight = fullActualHeight * animatedProgress
+
+ val isToday = stat.date == LocalDate.now()
+ val actualBarColor = if (isToday) BrakeYellow else Color(0xFFa2ab4c)
+
+ drawRoundRect(
+ color = actualBarColor,
+ topLeft = Offset(x, labelHalf + chartHeight - animatedActualHeight),
+ size = androidx.compose.ui.geometry.Size(barWidth, animatedActualHeight),
+ cornerRadius = cornerRadius,
+ )
+ }
+ }
+
+ Canvas(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(24.dp),
+ ) {
+ val chartSpacing = 2.dp.toPx()
+ val availableWidth = size.width - 40.dp.toPx()
+ val totalSpacing = chartSpacing * 6
+ val barWidth = (availableWidth - totalSpacing) / 7
+ val startX = 20.dp.toPx()
+
+ dayLabels.forEachIndexed { index, day ->
+ val x = startX + index * (barWidth + chartSpacing) + barWidth / 2
+ val isToday = statistics.getOrNull(index)?.date == LocalDate.now()
+
+ if (isToday) {
+ drawCircle(
+ color = Gray100,
+ radius = 16.dp.toPx(),
+ center = Offset(x, 12.dp.toPx()),
+ )
+ }
+
+ val textStyle = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ fontFamily = pretendard,
+ color = if (isToday) colorScheme.onPrimary else Gray100,
+ )
+
+ val textLayoutResult = textMeasurer.measure(day, textStyle)
+ val textWidth = textLayoutResult.size.width
+ val textHeight = textLayoutResult.size.height
+
+ drawText(
+ textMeasurer = textMeasurer,
+ text = day,
+ topLeft = Offset(
+ x - textWidth / 2,
+ 12.dp.toPx() - textHeight / 2,
+ ),
+ style = textStyle,
+ )
+ }
+ }
+ VerticalSpacer(30.dp)
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp),
+ horizontalArrangement = Arrangement.Start,
+ ) {
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.weight(1f),
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_circle),
+ contentDescription = null,
+ )
+ HorizontalSpacer(4.dp)
+ Text(
+ text = stringResource(R.string.chart_legend_used_time),
+ style = BrakeTheme.typography.body12M,
+ color = Gray400,
+ )
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.weight(1f),
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_stripe_circle),
+ contentDescription = null,
+ )
+ HorizontalSpacer(4.dp)
+ Text(
+ text = stringResource(R.string.chart_legend_planned_time),
+ style = BrakeTheme.typography.body12M,
+ color = Gray400,
+ )
+ }
+ }
+ }
+}
+
+private fun DrawScope.drawStripePattern(
+ x: Float,
+ y: Float,
+ width: Float,
+ height: Float,
+) {
+ val rotationAngle = -136.03f
+ val strokeWidth = 6.dp.toPx()
+ val lineSpacing = 70f
+
+ val extendedSize = width + height
+ val centerX = x + width / 2
+ val centerY = y + height / 2
+
+ rotate(rotationAngle, Offset(centerX, centerY)) {
+ var offset = -extendedSize
+ while (offset < extendedSize) {
+ drawLine(
+ color = Color(0xFF373B44),
+ start = Offset(centerX + offset, centerY - extendedSize),
+ end = Offset(centerX + offset, centerY + extendedSize),
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ )
+ offset += lineSpacing
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun WeeklyChartPreview() {
+ BrakeTheme {
+ WeeklyChart(
+ statistics = listOf(
+ Statistics(
+ date = LocalDate.now().minusDays(6),
+ dayOfWeek = "MONDAY",
+ actualTime = Duration.ofMinutes(180),
+ goalTime = Duration.ofMinutes(240),
+ ),
+ Statistics(
+ date = LocalDate.now().minusDays(5),
+ dayOfWeek = "TUESDAY",
+ actualTime = Duration.ofMinutes(120),
+ goalTime = Duration.ofMinutes(240),
+ ),
+ Statistics(
+ date = LocalDate.now().minusDays(4),
+ dayOfWeek = "WEDNESDAY",
+ actualTime = Duration.ofMinutes(300),
+ goalTime = Duration.ofMinutes(240),
+ ),
+ Statistics(
+ date = LocalDate.now().minusDays(3),
+ dayOfWeek = "THURSDAY",
+ actualTime = Duration.ofMinutes(90),
+ goalTime = Duration.ofMinutes(240),
+ ),
+ Statistics(
+ date = LocalDate.now().minusDays(2),
+ dayOfWeek = "FRIDAY",
+ actualTime = Duration.ofMinutes(150),
+ goalTime = Duration.ofMinutes(240),
+ ),
+ Statistics(
+ date = LocalDate.now().minusDays(1),
+ dayOfWeek = "SATURDAY",
+ actualTime = Duration.ofMinutes(60),
+ goalTime = Duration.ofMinutes(240),
+ ),
+ Statistics(
+ date = LocalDate.now(),
+ dayOfWeek = "SUNDAY",
+ actualTime = Duration.ofMinutes(200),
+ goalTime = Duration.ofMinutes(240),
+ ),
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
diff --git a/presentation/report/src/main/java/com/teambrake/brake/presentation/report/contract/ReportUiState.kt b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/contract/ReportUiState.kt
new file mode 100644
index 00000000..e1a06769
--- /dev/null
+++ b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/contract/ReportUiState.kt
@@ -0,0 +1,55 @@
+package com.teambrake.brake.presentation.report.contract
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import com.teambrake.brake.core.model.app.Statistics
+import java.time.Duration
+import java.time.DayOfWeek
+import java.time.LocalDate
+import java.time.temporal.TemporalAdjusters
+
+@Stable
+internal sealed interface ReportUiState {
+
+ @Immutable
+ data object Loading : ReportUiState
+
+ @Immutable
+ data object Error : ReportUiState
+
+ @Immutable
+ data class Success(
+ val statisticList: List,
+ ) : ReportUiState {
+
+ private val todayUsageTime: Duration
+ get() {
+ val today = LocalDate.now()
+ return statisticList.find { it.date == today }?.actualTime ?: Duration.ZERO
+ }
+
+ val todayUsageHours: Long
+ get() = todayUsageTime.toHours()
+
+ val todayUsageMinutes: Long
+ get() = todayUsageTime.toMinutes() % 60
+
+ val weeklyStatistics: List
+ get() {
+ val today = LocalDate.now()
+ val startOfWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
+
+ return (0..6).map { dayOffset ->
+ val currentDate = startOfWeek.plusDays(dayOffset.toLong())
+ val dayOfWeek = currentDate.dayOfWeek
+
+ statisticList.find { it.date == currentDate } ?: Statistics(
+ date = currentDate,
+ dayOfWeek = dayOfWeek.name,
+ actualTime = Duration.ZERO,
+ goalTime = Duration.ZERO,
+ )
+ }
+ }
+ }
+}
diff --git a/presentation/report/src/main/java/com/teambrake/brake/presentation/report/navigation/ReportNavigation.kt b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/navigation/ReportNavigation.kt
new file mode 100644
index 00000000..3b0042b7
--- /dev/null
+++ b/presentation/report/src/main/java/com/teambrake/brake/presentation/report/navigation/ReportNavigation.kt
@@ -0,0 +1,33 @@
+package com.teambrake.brake.presentation.report.navigation
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import androidx.navigation.navOptions
+import com.teambrake.brake.presentation.report.ReportRoute
+import com.teambrake.brake.core.navigation.route.MainTabRoute
+
+fun NavController.navigateReport(
+ shouldClearBackstack: Boolean = false,
+ navOptions: NavOptions? = null,
+) {
+ navigate(
+ route = MainTabRoute.Report,
+ navOptions = navOptions {
+ if (shouldClearBackstack) {
+ popUpTo(graph.id) { inclusive = true }
+ }
+ navOptions
+ },
+ )
+}
+
+fun NavGraphBuilder.reportNavGraph(
+ padding: PaddingValues,
+) {
+ composable {
+ ReportRoute(padding = padding)
+ }
+}
diff --git a/presentation/report/src/main/res/drawable/ic_calendar.xml b/presentation/report/src/main/res/drawable/ic_calendar.xml
new file mode 100644
index 00000000..75f81dc3
--- /dev/null
+++ b/presentation/report/src/main/res/drawable/ic_calendar.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/presentation/report/src/main/res/drawable/ic_circle.xml b/presentation/report/src/main/res/drawable/ic_circle.xml
new file mode 100644
index 00000000..be919eb0
--- /dev/null
+++ b/presentation/report/src/main/res/drawable/ic_circle.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/presentation/report/src/main/res/drawable/ic_stripe_circle.xml b/presentation/report/src/main/res/drawable/ic_stripe_circle.xml
new file mode 100644
index 00000000..efe94c26
--- /dev/null
+++ b/presentation/report/src/main/res/drawable/ic_stripe_circle.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/presentation/report/src/main/res/values/strings.xml b/presentation/report/src/main/res/values/strings.xml
new file mode 100644
index 00000000..7bf17aa5
--- /dev/null
+++ b/presentation/report/src/main/res/values/strings.xml
@@ -0,0 +1,9 @@
+
+
+ 오늘 총 사용 시간
+ 시간
+ 분
+ 오늘은 계획보다 적게 사용하셨네요!
+ : 사용한 시간
+ : 계획한 시간
+
diff --git a/presentation/setting/.gitignore b/presentation/setting/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/presentation/setting/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/presentation/setting/build.gradle.kts b/presentation/setting/build.gradle.kts
new file mode 100644
index 00000000..edac472b
--- /dev/null
+++ b/presentation/setting/build.gradle.kts
@@ -0,0 +1,24 @@
+import com.teambrake.brake.setNamespace
+
+plugins {
+ alias(libs.plugins.brake.android.feature)
+}
+
+android {
+ setNamespace("presentation.setting")
+
+ defaultConfig {
+ val versionName = libs.versions.versionName.get()
+ buildConfigField("String", "VERSION_NAME", "\"$versionName\"")
+ }
+
+ buildFeatures {
+ buildConfig = true
+ }
+}
+
+dependencies {
+ implementation(projects.core.auth)
+
+ implementation(libs.androidx.browser)
+}
diff --git a/presentation/setting/src/main/AndroidManifest.xml b/presentation/setting/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..568741e5
--- /dev/null
+++ b/presentation/setting/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/inquiry/InquiryScreen.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/inquiry/InquiryScreen.kt
new file mode 100644
index 00000000..0ec75191
--- /dev/null
+++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/inquiry/InquiryScreen.kt
@@ -0,0 +1,55 @@
+package com.teambrake.brake.presentation.feeback.inquiry
+
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_OFF
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.graphics.drawable.toBitmap
+import androidx.core.net.toUri
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.teambrake.brake.core.designsystem.R
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+
+@Composable
+fun InquiryRoute() {
+ val navAction = LocalNavigatorAction.current
+
+ InquiryScreen(
+ onBack = navAction::popBackStack,
+ )
+}
+
+@Composable
+fun InquiryScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ DisposableEffect(Unit) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ onBack()
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ val customTabsIntent = CustomTabsIntent.Builder().apply {
+ setBookmarksButtonEnabled(false)
+ setDownloadButtonEnabled(false)
+ setShareState(SHARE_STATE_OFF)
+ setUrlBarHidingEnabled(true)
+ setShowTitle(true)
+ setInstantAppsEnabled(false)
+ setCloseButtonIcon(context.getDrawable(R.drawable.ic_back_24)!!.toBitmap())
+ }.build()
+ customTabsIntent.launchUrl(context, TERMS_BASE_URL.toUri())
+}
+
+private const val TERMS_BASE_URL =
+ "https://ahnsh.notion.site/245b76e304028000ade7ec331648fecc?pvs=105"
diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/inquiry/navigation/InquiryNavigation.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/inquiry/navigation/InquiryNavigation.kt
new file mode 100644
index 00000000..84366713
--- /dev/null
+++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/inquiry/navigation/InquiryNavigation.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.presentation.feeback.inquiry.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.teambrake.brake.core.navigation.route.SubRoute
+import com.teambrake.brake.presentation.feeback.inquiry.InquiryRoute
+
+fun NavController.navigateToInquiry(navOptions: NavOptions? = null) {
+ navigate(SubRoute.Feedback.Inquiry, navOptions)
+}
+
+fun NavGraphBuilder.inquiryNavGraph() {
+ composable {
+ InquiryRoute()
+ }
+}
diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/opinion/OpinionScreen.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/opinion/OpinionScreen.kt
new file mode 100644
index 00000000..127ae2c3
--- /dev/null
+++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/opinion/OpinionScreen.kt
@@ -0,0 +1,55 @@
+package com.teambrake.brake.presentation.feeback.opinion
+
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_OFF
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.graphics.drawable.toBitmap
+import androidx.core.net.toUri
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.teambrake.brake.core.designsystem.R
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+
+@Composable
+fun OpinionRoute() {
+ val navAction = LocalNavigatorAction.current
+
+ OpinionScreen(
+ onBack = navAction::popBackStack,
+ )
+}
+
+@Composable
+fun OpinionScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ DisposableEffect(Unit) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ onBack()
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ val customTabsIntent = CustomTabsIntent.Builder().apply {
+ setBookmarksButtonEnabled(false)
+ setDownloadButtonEnabled(false)
+ setShareState(SHARE_STATE_OFF)
+ setUrlBarHidingEnabled(true)
+ setShowTitle(true)
+ setInstantAppsEnabled(false)
+ setCloseButtonIcon(context.getDrawable(R.drawable.ic_back_24)!!.toBitmap())
+ }.build()
+ customTabsIntent.launchUrl(context, TERMS_BASE_URL.toUri())
+}
+
+private const val TERMS_BASE_URL =
+ "https://ahnsh.notion.site/245b76e304028092925dd625cd38ceeb?pvs=105"
diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/opinion/navigation/OpinionNavigation.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/opinion/navigation/OpinionNavigation.kt
new file mode 100644
index 00000000..88077bd5
--- /dev/null
+++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/feeback/opinion/navigation/OpinionNavigation.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.presentation.feeback.opinion.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.teambrake.brake.core.navigation.route.SubRoute
+import com.teambrake.brake.presentation.feeback.opinion.OpinionRoute
+
+fun NavController.navigateToOpinion(navOptions: NavOptions? = null) {
+ navigate(SubRoute.Feedback.Opinion, navOptions)
+}
+
+fun NavGraphBuilder.opinionNavGraph() {
+ composable {
+ OpinionRoute()
+ }
+}
diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/NicknameScreen.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/NicknameScreen.kt
new file mode 100644
index 00000000..d88d7f27
--- /dev/null
+++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/NicknameScreen.kt
@@ -0,0 +1,197 @@
+package com.teambrake.brake.presentation.nickname
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.teambrake.brake.core.designsystem.component.BaseScaffold
+import com.teambrake.brake.core.designsystem.component.BrakeTopAppbar
+import com.teambrake.brake.core.designsystem.component.CircleImage
+import com.teambrake.brake.core.designsystem.component.LargeButton
+import com.teambrake.brake.core.designsystem.component.TopAppbarType
+import com.teambrake.brake.core.designsystem.theme.LocalPadding
+import com.teambrake.brake.core.navigation.compositionlocal.LocalMainAction
+import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction
+import com.teambrake.brake.core.ui.SnackBarState
+import com.teambrake.brake.core.ui.isValidInput
+import com.teambrake.brake.presentation.nickname.component.SettingNicknameTextField
+import com.teambrake.brake.presentation.nickname.model.NicknameNavState
+import com.teambrake.brake.presentation.nickname.model.NicknameUiState
+import com.teambrake.brake.presentation.setting.R
+import com.teambrake.brake.core.designsystem.R as DesignSystemR
+
+@Composable
+fun NicknameRoute(
+ viewModel: NicknameViewModel = hiltViewModel(),
+) {
+ val padding = LocalPadding.current.screenPaddingHorizontal
+ val uiState by viewModel.nicknameUiState.collectAsStateWithLifecycle()
+ val navAction = LocalNavigatorAction.current
+ val mainAction = LocalMainAction.current
+ val context = LocalContext.current
+ val focusManager = LocalFocusManager.current
+ val density = LocalDensity.current
+ val imeVisible = WindowInsets.ime.getBottom(density) > 0
+ var prevVisible by remember { mutableStateOf(imeVisible) }
+
+ // 키보드가 사라졌을 때 포커스를 해제
+ SideEffect {
+ if (prevVisible && !imeVisible) {
+ focusManager.clearFocus()
+ }
+ prevVisible = imeVisible
+ }
+
+ if (uiState is NicknameUiState.NicknameUpdating) {
+ mainAction.OnShowLoading()
+ }
+
+ LaunchedEffect(true) {
+ viewModel.navigationFlow.collect {
+ when (it) {
+ NicknameNavState.NavigateToSetting -> navAction.popBackStack()
+ NicknameNavState.PopBackStack -> {
+ navAction.popBackStack()
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(true) {
+ viewModel.snackBarFlow.collect {
+ when (it) {
+ is SnackBarState.Success -> {
+ mainAction.onShowSuccessMessage(
+ message = it.uiString.asString(context = context),
+ )
+ }
+
+ is SnackBarState.Error -> {
+ mainAction.onShowErrorMessage(
+ message = it.uiString.asString(context = context),
+ )
+ }
+ }
+ }
+ }
+
+ NicknameScreen(
+ padding = padding,
+ focusManager = focusManager,
+ uiState = uiState,
+ onTypeNickname = viewModel::typeNickname,
+ onCompleteClick = viewModel::updateNickname,
+ onCancelUpdate = viewModel::cancelUpdateNickname,
+ onBackClick = navAction::popBackStack,
+ )
+}
+
+@Composable
+fun NicknameScreen(
+ padding: Dp,
+ focusManager: FocusManager,
+ uiState: NicknameUiState,
+ onTypeNickname: (String) -> Unit = {},
+ onCompleteClick: () -> Unit,
+ onCancelUpdate: () -> Unit,
+ onBackClick: () -> Unit,
+) {
+ BackHandler {
+ if (uiState is NicknameUiState.NicknameUpdating) {
+ onCancelUpdate()
+ } else {
+ onBackClick()
+ }
+ }
+
+ BaseScaffold(
+ contentPadding = PaddingValues(horizontal = padding),
+ topBar = {
+ BrakeTopAppbar(
+ title = stringResource(R.string.nickname_title),
+ appbarType = TopAppbarType.Back,
+ onClick = onBackClick,
+ )
+ },
+ ) {
+ ConstraintLayout(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ val (image, input, button) = createRefs()
+
+ CircleImage(
+ modifier = Modifier
+ .constrainAs(image) {
+ top.linkTo(parent.top, margin = 48.dp)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ }
+ .size(100.dp),
+ imageUrl = null,
+ )
+
+ SettingNicknameTextField(
+ modifier = Modifier
+ .constrainAs(input) {
+ top.linkTo(image.bottom, margin = 57.dp)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ },
+ value = uiState.nickname,
+ onValueChange = onTypeNickname,
+ label = stringResource(R.string.nickname_input_label),
+ placeholder = stringResource(R.string.nickname_input_hint),
+ trailingIcon = painterResource(DesignSystemR.drawable.ic_check),
+ warningGuideText = stringResource(R.string.nickname_input_warning_guide_text),
+ validGuideText = stringResource(R.string.nickname_input_valid_guide_text),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ },
+ ),
+ )
+
+ LargeButton(
+ text = stringResource(R.string.nickname_button_complete),
+ onClick = {
+ focusManager.clearFocus()
+ onCompleteClick()
+ },
+ modifier = Modifier
+ .constrainAs(button) {
+ bottom.linkTo(parent.bottom, margin = 24.dp)
+ start.linkTo(parent.start)
+ end.linkTo(parent.end)
+ }
+ .navigationBarsPadding()
+ .imePadding(),
+ // 임의 제한 조건
+ enabled = uiState.nickname.isValidInput(),
+ )
+ }
+ }
+}
diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/NicknameViewModel.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/NicknameViewModel.kt
new file mode 100644
index 00000000..08641379
--- /dev/null
+++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/NicknameViewModel.kt
@@ -0,0 +1,94 @@
+package com.teambrake.brake.presentation.nickname
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.analytics.logEvent
+import com.teambrake.brake.core.ui.SnackBarState
+import com.teambrake.brake.core.ui.UiString
+import com.teambrake.brake.domain.usecase.GetNicknameUseCase
+import com.teambrake.brake.domain.usecase.UpdateNicknameUseCase
+import com.teambrake.brake.presentation.nickname.model.NicknameNavState
+import com.teambrake.brake.presentation.nickname.model.NicknameUiState
+import com.teambrake.brake.presentation.setting.R
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class NicknameViewModel @Inject constructor(
+ getNicknameUseCase: GetNicknameUseCase,
+ private val updateNicknameUseCase: UpdateNicknameUseCase,
+ private val firebaseAnalytics: FirebaseAnalytics,
+) : ViewModel() {
+
+ private var updateJob: Job? = null
+
+ private val _nicknameUiState: MutableStateFlow =
+ MutableStateFlow(NicknameUiState.NicknameIdle(nickname = ""))
+ val nicknameUiState = _nicknameUiState.asStateFlow()
+
+ private val _snackBarFlow = MutableSharedFlow()
+ val snackBarFlow = _snackBarFlow.asSharedFlow()
+
+ private val _navigationFlow = MutableSharedFlow()
+ val navigationFlow = _navigationFlow.asSharedFlow()
+
+ init {
+ viewModelScope.launch {
+ val nickname = getNicknameUseCase(
+ onError = { /* 에러 핸들링 스킵*/ },
+ ).first()
+ _nicknameUiState.value = NicknameUiState.NicknameIdle(nickname = nickname)
+ }
+ }
+
+ fun typeNickname(nickname: String) {
+ _nicknameUiState.value = NicknameUiState.NicknameIdle(nickname = nickname)
+ }
+
+ fun updateNickname() {
+ val nickname = _nicknameUiState.value.nickname
+ _nicknameUiState.value = NicknameUiState.NicknameUpdating(
+ nickname = nickname,
+ )
+ updateJob = viewModelScope.launch {
+ updateNicknameUseCase(
+ nickname = nickname,
+ onError = {
+ _snackBarFlow.emit(
+ SnackBarState.Error(
+ uiString = UiString.ResourceString(R.string.nickname_snackbar_update_error),
+ ),
+ )
+ },
+ onSuccess = {
+ _snackBarFlow.emit(
+ SnackBarState.Success(
+ uiString = UiString.ResourceString(R.string.nickname_snackbar_update_success),
+ ),
+ )
+ firebaseAnalytics.logEvent("update_nickname") {
+ param("nickname", nickname)
+ }
+ _navigationFlow.emit(NicknameNavState.NavigateToSetting)
+ },
+ )
+ }
+ }
+
+ fun cancelUpdateNickname() {
+ updateJob?.run {
+ cancel()
+ _nicknameUiState.value = NicknameUiState.NicknameIdle(
+ nickname = _nicknameUiState.value.nickname,
+ )
+ }
+ }
+}
diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/component/SettingNicknameTextFeld.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/component/SettingNicknameTextFeld.kt
new file mode 100644
index 00000000..6d3c4c08
--- /dev/null
+++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/component/SettingNicknameTextFeld.kt
@@ -0,0 +1,96 @@
+package com.teambrake.brake.presentation.nickname.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.unit.dp
+import com.teambrake.brake.core.designsystem.component.BaseTextField
+import com.teambrake.brake.core.designsystem.component.VerticalSpacer
+import com.teambrake.brake.core.designsystem.theme.BrakeTheme
+import com.teambrake.brake.core.designsystem.theme.Gray200
+import com.teambrake.brake.core.designsystem.theme.Green
+import com.teambrake.brake.core.designsystem.theme.Red
+import com.teambrake.brake.core.designsystem.theme.White
+import com.teambrake.brake.core.ui.isValidInput
+
+@Composable
+internal fun SettingNicknameTextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: String,
+ placeholder: String,
+ trailingIcon: Painter,
+ warningGuideText: String,
+ validGuideText: String,
+ keyboardActions: KeyboardActions,
+) {
+ Column(
+ modifier = modifier,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.Absolute.SpaceBetween,
+ ) {
+ Text(
+ text = label,
+ color = Gray200,
+ style = BrakeTheme.typography.body16M,
+ )
+
+ Text(
+ text = "${value.length} / 10",
+ color = when {
+ value.isValidInput() -> Green
+ value.isEmpty() -> White
+ else -> Red
+ },
+ style = BrakeTheme.typography.body12M,
+ )
+ }
+
+ VerticalSpacer(8.dp)
+
+ BaseTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = value,
+ onValueChange = onValueChange,
+ placeholder = placeholder,
+ trailingIcon = if (value.isValidInput()) trailingIcon else null,
+ supportingText = {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Start,
+ ) {
+ if (!value.isValidInput()) {
+ Text(
+ text = if (value.isEmpty()) {
+ " "
+ } else {
+ warningGuideText
+ },
+ color = Red,
+ style = BrakeTheme.typography.body12M,
+ )
+ } else {
+ Text(
+ text = validGuideText,
+ color = Green,
+ style = BrakeTheme.typography.body12M,
+ )
+ }
+ }
+ },
+ keyboardActions = keyboardActions,
+ )
+ }
+}
diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/model/NicknameNavState.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/model/NicknameNavState.kt
new file mode 100644
index 00000000..bcc157a4
--- /dev/null
+++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/model/NicknameNavState.kt
@@ -0,0 +1,12 @@
+package com.teambrake.brake.presentation.nickname.model
+
+import androidx.compose.runtime.Immutable
+
+sealed interface NicknameNavState {
+
+ @Immutable
+ data object PopBackStack : NicknameNavState
+
+ @Immutable
+ data object NavigateToSetting : NicknameNavState
+}
diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/model/NicknameUiState.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/model/NicknameUiState.kt
new file mode 100644
index 00000000..07af2fa4
--- /dev/null
+++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/model/NicknameUiState.kt
@@ -0,0 +1,15 @@
+package com.teambrake.brake.presentation.nickname.model
+
+import androidx.compose.runtime.Stable
+
+@Stable
+sealed interface NicknameUiState {
+
+ val nickname: String
+
+ @Stable
+ data class NicknameIdle(override val nickname: String) : NicknameUiState
+
+ @Stable
+ data class NicknameUpdating(override val nickname: String) : NicknameUiState
+}
diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/navigation/NicknameNavigation.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/navigation/NicknameNavigation.kt
new file mode 100644
index 00000000..0848aff9
--- /dev/null
+++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/nickname/navigation/NicknameNavigation.kt
@@ -0,0 +1,18 @@
+package com.teambrake.brake.presentation.nickname.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.teambrake.brake.core.navigation.route.SubRoute
+import com.teambrake.brake.presentation.nickname.NicknameRoute
+
+fun NavController.navigateToNickname(navOptions: NavOptions? = null) {
+ navigate(SubRoute.Nickname, navOptions)
+}
+
+fun NavGraphBuilder.nicknameNavGraph() {
+ composable