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! + +brake_github +
+ +## Download + + GetItOnGooglePlay_Badge_Digital_color_Finnish + +
+
+ + one_downloadbadge_red_white + + +## Overview + + + + + + + + + + + + + +
구글 로그인카카오 로그인 (REST API)카카오 간편 로그인 (JS)
google_loginkakao_login_restapikakao_login_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 { + NicknameRoute() + } +} diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/SettingScreen.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/SettingScreen.kt new file mode 100644 index 00000000..daf57668 --- /dev/null +++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/SettingScreen.kt @@ -0,0 +1,282 @@ +package com.teambrake.brake.presentation.setting + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +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.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.teambrake.brake.core.designsystem.component.HorizontalSpacer +import com.teambrake.brake.core.designsystem.component.CircleImage +import com.teambrake.brake.core.designsystem.component.SettingRow +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.Gray600 +import com.teambrake.brake.core.designsystem.theme.Gray800 +import com.teambrake.brake.core.designsystem.theme.Gray850 +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.ui.SnackBarState +import com.teambrake.brake.presentation.setting.component.DeleteWarningDialog +import com.teambrake.brake.presentation.setting.model.SettingEffect +import com.teambrake.brake.presentation.setting.model.SettingUiState + +@Composable +fun SettingRoute( + paddingValue: PaddingValues, + onChangeDarkTheme: (Boolean) -> Unit, + viewModel: SettingViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val screenHorizontalPadding = LocalPadding.current.screenPaddingHorizontal + val context = LocalContext.current + val navAction = LocalNavigatorAction.current + val navProvider = LocalNavigatorProvider.current + val mainAction = LocalMainAction.current + + BackHandler { + if (uiState is SettingUiState.SettingDeletingAccount) { + viewModel.cancelDeletingAccount() + } else { + navAction.popBackStack() + } + } + + if (uiState is SettingUiState.SettingDeletingAccount) { + mainAction.OnShowLoading() + } + + LaunchedEffect(true) { + viewModel.navigationFlow.collect { + when (it) { + is SettingEffect.NavigateToLogin -> navAction.navigateToLogin( + navProvider.getNavOptionsClearingBackStack(), + ) + is SettingEffect.NavigateToNickname -> navAction.navigateToNickname() + is SettingEffect.NavigateToOpinion -> navAction.navigateToOpinion() + is SettingEffect.NavigateToInquiry -> navAction.navigateToInquiry() + is SettingEffect.NavigateToPrivacyPolicy -> navAction.navigateToPrivacy() + is SettingEffect.NavigateToTermsOfService -> navAction.navigateToTerms() + } + } + } + + 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), + ) + } + } + } + } + + when (uiState) { + is SettingUiState.SettingLogoutWarning -> { + mainAction.OnShowLogoutDialog( + onConfirm = { + viewModel.dismissDialog() + viewModel.logout() + }, + onDismiss = viewModel::dismissDialog, + ) + } + + is SettingUiState.SettingDeleteWarning -> { + DeleteWarningDialog( + onDismissRequest = viewModel::dismissDialog, + onConfirm = { + viewModel.dismissDialog() + viewModel.deleteAccount() + }, + ) + } + + else -> {} + } + + SettingScreen( + screenHorizontalPadding = screenHorizontalPadding, + paddingValue = paddingValue, + uiState = uiState, + onChangeProfile = viewModel::modifyNickname, + onOpinionClick = viewModel::showOpinion, + onInquiryClick = viewModel::showInquiry, + onPrivacyClick = viewModel::showPrivacyPolicy, + onTermsClick = viewModel::showTermsOfService, + onDeleteAccount = viewModel::tryDeleteAccount, + onLogout = viewModel::tryLogout, + ) +} + +@Composable +fun SettingScreen( + screenHorizontalPadding: Dp, + paddingValue: PaddingValues, + uiState: SettingUiState, + onChangeProfile: () -> Unit, + onOpinionClick: () -> Unit, + onInquiryClick: () -> Unit, + onPrivacyClick: () -> Unit, + onTermsClick: () -> Unit, + onDeleteAccount: () -> Unit, + onLogout: () -> Unit, +) { + var scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(screenHorizontalPadding) + .padding(paddingValue) + .scrollable(state = scrollState, orientation = Orientation.Vertical), + horizontalAlignment = Alignment.Start, + ) { + VerticalSpacer(52.dp) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + HorizontalSpacer(16.dp) + + CircleImage( + modifier = Modifier + .fillMaxWidth(0.166f) + .widthIn(max = 64.dp), + imageUrl = uiState.user.imageUrl, + ) + + HorizontalSpacer(20.dp) + + Text( + text = if (uiState.user.name.isBlank()) { + stringResource(R.string.setting_profile_name_not_set) + } else { + uiState.user.name + }, + style = BrakeTheme.typography.subtitle22SB, + color = White, + ) + } + + Text( + text = stringResource(R.string.setting_profile_image_change), + modifier = Modifier + .align(Alignment.CenterEnd) + .clip(RoundedCornerShape(8.dp)) + .clickable( + interactionSource = null, + indication = LocalIndication.current, + onClick = onChangeProfile, + ) + .padding(horizontal = 16.dp, vertical = 6.dp), + style = BrakeTheme.typography.body14SB, + color = Gray500, + ) + } + + VerticalSpacer(32.dp) + + Column( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Gray850), + ) { + SettingRow( + id = R.string.setting_opinion_title, + onClick = onOpinionClick, + ) + HorizontalDivider(thickness = 1.dp, color = Gray800) + SettingRow( + id = R.string.setting_inquiry, + onClick = onInquiryClick, + ) + } + + VerticalSpacer(16.dp) + + Column( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Gray850), + ) { + SettingRow( + id = R.string.setting_privacy_policy, + onClick = onPrivacyClick, + ) + HorizontalDivider(thickness = 1.dp, color = Gray800) + SettingRow( + id = R.string.setting_terms_of_service, + onClick = onTermsClick, + ) + HorizontalDivider(thickness = 1.dp, color = Gray800) + SettingRow( + id = R.string.setting_app_version, + onClick = {}, + ) { + Text( + text = uiState.appInfo.version, + style = BrakeTheme.typography.body14M, + color = Gray600, + ) + } + } + + VerticalSpacer(16.dp) + + Column( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Gray850), + ) { + SettingRow( + id = R.string.setting_logout, + onClick = onLogout, + ) + HorizontalDivider(thickness = 1.dp, color = Gray800) + SettingRow( + id = R.string.setting_delete_account, + onClick = onDeleteAccount, + ) + } + } +} diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/SettingViewModel.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/SettingViewModel.kt new file mode 100644 index 00000000..d7977b6c --- /dev/null +++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/SettingViewModel.kt @@ -0,0 +1,206 @@ +package com.teambrake.brake.presentation.setting + +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.ui.SnackBarState +import com.teambrake.brake.core.ui.UiString +import com.teambrake.brake.domain.usecase.DeleteAccountUseCase +import com.teambrake.brake.domain.usecase.GetNicknameUseCase +import com.teambrake.brake.domain.usecase.LogoutUseCase +import com.teambrake.brake.presentation.setting.model.SettingEffect +import com.teambrake.brake.presentation.setting.model.SettingUiState +import com.teambrake.brake.presentation.setting.model.SettingUser +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.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SettingViewModel @Inject constructor( + getNicknameUseCase: GetNicknameUseCase, + private val deleteAccountUseCase: DeleteAccountUseCase, + private val logoutUseCase: LogoutUseCase, + private val googleAuthManager: GoogleAuthManager, + private val firebaseAnalytics: FirebaseAnalytics, +) : ViewModel() { + + private var deleteJob: Job? = null + + private val _snackBarFlow = MutableSharedFlow() + val snackBarFlow = _snackBarFlow.asSharedFlow() + + private val _uiState = MutableStateFlow(SettingUiState.SettingIdle()) + val uiState = _uiState.asStateFlow() + + private val _navigationFlow = MutableSharedFlow() + val navigationFlow = _navigationFlow.asSharedFlow() + + init { + viewModelScope.launch { + getNicknameUseCase({}).collect { nickname -> + _uiState.update { + SettingUiState.SettingLoaded( + user = SettingUser( + imageUrl = null, + name = nickname, + ), + appInfo = _uiState.value.appInfo, + ) + } + } + } + } + + fun modifyNickname() { + viewModelScope.launch { + _navigationFlow.emit(SettingEffect.NavigateToNickname) + } + } + + fun showOpinion() { + viewModelScope.launch { + _navigationFlow.emit(SettingEffect.NavigateToOpinion) + } + } + + fun showInquiry() { + viewModelScope.launch { + _navigationFlow.emit(SettingEffect.NavigateToInquiry) + } + } + + fun showPrivacyPolicy() { + viewModelScope.launch { + _navigationFlow.emit(SettingEffect.NavigateToPrivacyPolicy) + } + } + + fun showTermsOfService() { + viewModelScope.launch { + _navigationFlow.emit(SettingEffect.NavigateToTermsOfService) + } + } + + fun dismissDialog() { + viewModelScope.launch { + _uiState.value = SettingUiState.SettingIdle( + user = _uiState.value.user, + appInfo = _uiState.value.appInfo, + ) + } + } + + fun tryLogout() { + Timber.e("Logout warning dialog shown") + viewModelScope.launch { + _uiState.value = SettingUiState.SettingLogoutWarning( + user = _uiState.value.user, + appInfo = _uiState.value.appInfo, + ) + } + } + + fun logout() { + Timber.e("Logout initiated") + viewModelScope.launch { + val dest = logoutUseCase( + onError = { + _snackBarFlow.emit( + SnackBarState.Error( + uiString = UiString.ResourceString(R.string.setting_snackbar_logout_error), + ), + ) + }, + ) + if (dest is Destination.Login) { + firebaseAnalytics.logEvent("app_logout") { + param(FirebaseAnalytics.Param.METHOD, "user_logout") + } + _navigationFlow.emit(SettingEffect.NavigateToLogin) + } else if (dest is Destination.PermissionOrHome) { + Timber.e("Logout failed with destination: $dest") + } else { + Timber.e("Logout failed with destination: $dest") + } + } + } + + fun tryDeleteAccount() { + viewModelScope.launch { + _uiState.value = SettingUiState.SettingDeleteWarning( + user = _uiState.value.user, + appInfo = _uiState.value.appInfo, + ) + } + } + + fun deleteAccount() { + _uiState.value = _uiState.value.let { + SettingUiState.SettingDeletingAccount( + user = it.user, + appInfo = it.appInfo, + ) + } + deleteJob = viewModelScope.launch { + val dest = deleteAccountUseCase( + onError = { + _uiState.value = SettingUiState.SettingLoaded( + user = _uiState.value.user, + appInfo = _uiState.value.appInfo, + ) + _snackBarFlow.emit( + SnackBarState.Error( + uiString = UiString.ResourceString(R.string.setting_snackbar_delete_error), + ), + ) + }, + ) + if (dest is Destination.Login) { + // 로딩창 먼저 제거 후 스낵바 띄우고 화면 이동: 유저 사용성 증가 + googleAuthManager.signOutGoogleAuth() + _uiState.value = SettingUiState.SettingLoaded( + user = _uiState.value.user, + appInfo = _uiState.value.appInfo, + ) + _snackBarFlow.emit( + SnackBarState.Success( + uiString = UiString.ResourceString(R.string.setting_snackbar_delete_success), + ), + ) + firebaseAnalytics.logEvent("app_delete_account") { + param(FirebaseAnalytics.Param.METHOD, "user_delete") + } + _navigationFlow.emit(SettingEffect.NavigateToLogin) + } + } + } + + fun cancelDeletingAccount() { + deleteJob?.run { + cancel() + _uiState.value = _uiState.value.let { + SettingUiState.SettingIdle( + user = it.user, + appInfo = it.appInfo, + ) + } + viewModelScope.launch { + _snackBarFlow.emit( + SnackBarState.Error( + uiString = UiString.ResourceString(R.string.setting_snackbar_delete_cancel), + ), + ) + } + } + } +} diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/component/DeleteWarningDialog.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/component/DeleteWarningDialog.kt new file mode 100644 index 00000000..4eb9f0cb --- /dev/null +++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/component/DeleteWarningDialog.kt @@ -0,0 +1,68 @@ +package com.teambrake.brake.presentation.setting.component + +import androidx.compose.foundation.Image +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.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.TwoButtonDialog +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.White +import com.teambrake.brake.presentation.setting.R +import com.teambrake.brake.core.designsystem.R as Res + +@Composable +internal fun DeleteWarningDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + TwoButtonDialog( + onDismissRequest = onDismissRequest, + dismissButtonText = stringResource(R.string.setting_delete_dialog_dismiss_text), + confirmButtonText = stringResource(R.string.setting_delete_dialog_confirm_text), + onConfirmButtonClick = onConfirm, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = Res.drawable.img_warning), + contentDescription = null, + ) + VerticalSpacer(16.dp) + Text( + text = stringResource(R.string.setting_delete_dialog_title), + style = BrakeTheme.typography.subtitle22SB, + color = White, + ) + VerticalSpacer(12.dp) + Text( + text = stringResource(R.string.setting_delete_dialog_description), + style = BrakeTheme.typography.body16M, + color = Gray300, + textAlign = TextAlign.Center, + ) + } + } +} + +@Preview +@Composable +fun DeleteWarningDialogPreview() { + BrakeTheme { + DeleteWarningDialog( + onDismissRequest = {}, + onConfirm = {}, + ) + } +} diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingAppInfo.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingAppInfo.kt new file mode 100644 index 00000000..8a86c619 --- /dev/null +++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingAppInfo.kt @@ -0,0 +1,17 @@ +package com.teambrake.brake.presentation.setting.model + +import com.teambrake.brake.presentation.setting.BuildConfig + +data class SettingAppInfo( + val version: String, + val privacyPolicy: String, + val termsOfService: String, +) { + companion object { + val EMPTY = SettingAppInfo( + version = BuildConfig.VERSION_NAME, + privacyPolicy = "", + termsOfService = "", + ) + } +} diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingEffect.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingEffect.kt new file mode 100644 index 00000000..2bfb771a --- /dev/null +++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingEffect.kt @@ -0,0 +1,25 @@ +package com.teambrake.brake.presentation.setting.model + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Stable +interface SettingEffect { + @Immutable + data object NavigateToLogin : SettingEffect + + @Immutable + data object NavigateToNickname : SettingEffect + + @Immutable + data object NavigateToOpinion : SettingEffect + + @Immutable + data object NavigateToInquiry : SettingEffect + + @Immutable + data object NavigateToTermsOfService : SettingEffect + + @Immutable + data object NavigateToPrivacyPolicy : SettingEffect +} diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingUiState.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingUiState.kt new file mode 100644 index 00000000..825f38b7 --- /dev/null +++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingUiState.kt @@ -0,0 +1,34 @@ +package com.teambrake.brake.presentation.setting.model + +import androidx.compose.runtime.Stable + +@Stable +sealed interface SettingUiState { + val user: SettingUser + val appInfo: SettingAppInfo + + data class SettingIdle( + override val user: SettingUser = SettingUser.EMPTY, + override val appInfo: SettingAppInfo = SettingAppInfo.EMPTY, + ) : SettingUiState + + data class SettingLoaded( + override val user: SettingUser, + override val appInfo: SettingAppInfo, + ) : SettingUiState + + data class SettingLogoutWarning( + override val user: SettingUser, + override val appInfo: SettingAppInfo, + ) : SettingUiState + + data class SettingDeleteWarning( + override val user: SettingUser, + override val appInfo: SettingAppInfo, + ) : SettingUiState + + data class SettingDeletingAccount( + override val user: SettingUser, + override val appInfo: SettingAppInfo, + ) : SettingUiState +} diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingUser.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingUser.kt new file mode 100644 index 00000000..00ca69b9 --- /dev/null +++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/model/SettingUser.kt @@ -0,0 +1,13 @@ +package com.teambrake.brake.presentation.setting.model + +data class SettingUser( + val imageUrl: String?, + val name: String, +) { + companion object { + val EMPTY = SettingUser( + imageUrl = null, + name = "", + ) + } +} diff --git a/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/navigation/SettingNavigation.kt b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/navigation/SettingNavigation.kt new file mode 100644 index 00000000..2543d7b2 --- /dev/null +++ b/presentation/setting/src/main/java/com/teambrake/brake/presentation/setting/navigation/SettingNavigation.kt @@ -0,0 +1,37 @@ +package com.teambrake.brake.presentation.setting.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.setting.SettingRoute + +fun NavController.navigateSetting( + shouldClearBackstack: Boolean = false, + navOptions: NavOptions? = null, +) { + navigate( + route = MainTabRoute.Setting, + navOptions = navOptions { + if (shouldClearBackstack) { + popUpTo(graph.id) { inclusive = true } + } + navOptions + }, + ) +} + +fun NavGraphBuilder.settingNavGraph( + padding: PaddingValues, + onChangeDarkTheme: (Boolean) -> Unit, +) { + composable { + SettingRoute( + paddingValue = padding, + onChangeDarkTheme = onChangeDarkTheme, + ) + } +} diff --git a/presentation/setting/src/main/res/values/strings.xml b/presentation/setting/src/main/res/values/strings.xml new file mode 100644 index 00000000..d5e9fdb0 --- /dev/null +++ b/presentation/setting/src/main/res/values/strings.xml @@ -0,0 +1,41 @@ + + + !이름 오류! + + 수정 + + 의견 남기기 + 문의하기 + + 개인정보 처리방침 + 서비스 약관 + 앱 버전 정보 + + 로그아웃 + 회원탈퇴 + + 로그아웃 하시겠습니까? + 로그아웃 + 취소 + + 정말 탈퇴하시겠어요? + 탈퇴하면 모든 계정 정보와 이용 기록이 \n삭제되며, 복구할 수 없습니다. + 탈퇴 + 취소 + + 로그아웃 중 문제가 생겼습니다. + 회원탈퇴 중 문제가 생겼습니다. + 회원탈퇴가 취소되었습니다. + 회원탈퇴가 완료되었습니다. + + + 프로필 편집 + 저장 + 닉네임 + 닉네임을 입력해주세요. + 공백, 특수문자 없이 2~10자를 입력해 주세요. + 사용 가능한 닉네임입니다. + 닉네임이 변경되었습니다. + 닉네임 변경을 실패했습니다. 다시 시도해주세요. + + diff --git a/presentation/signup/.gitignore b/presentation/signup/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/presentation/signup/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/presentation/signup/build.gradle.kts b/presentation/signup/build.gradle.kts new file mode 100644 index 00000000..ba1c1a52 --- /dev/null +++ b/presentation/signup/build.gradle.kts @@ -0,0 +1,9 @@ +import com.teambrake.brake.setNamespace + +plugins { + alias(libs.plugins.brake.android.feature) +} + +android { + setNamespace("presentation.signup") +} diff --git a/presentation/signup/src/main/AndroidManifest.xml b/presentation/signup/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/presentation/signup/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/SignupScreen.kt b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/SignupScreen.kt new file mode 100644 index 00000000..6ba37604 --- /dev/null +++ b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/SignupScreen.kt @@ -0,0 +1,212 @@ +package com.teambrake.brake.presentation.signup + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +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.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.teambrake.brake.presentation.signup.component.NicknameTextField +import com.teambrake.brake.presentation.signup.model.SignupEffect +import com.teambrake.brake.presentation.signup.model.SignupUiState +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.LocalPadding +import com.teambrake.brake.core.designsystem.modifier.clearFocusOnKeyboardDismiss +import com.teambrake.brake.core.navigation.compositionlocal.LocalMainAction +import com.teambrake.brake.core.navigation.compositionlocal.LocalNavigatorAction +import com.teambrake.brake.core.ui.isValidInput +import com.teambrake.brake.core.designsystem.R as D + +@Composable +fun SignupRoute(viewModel: SignupViewModel = hiltViewModel()) { + val padding = LocalPadding.current.screenPaddingHorizontal + val navAction = LocalNavigatorAction.current + val mainAction = LocalMainAction.current + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + 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 + } + + BackHandler { + if (uiState is SignupUiState.SignupNameRegistering) { + viewModel.cancelNameSubmit() + } else { + navAction.popBackStack() + } + } + + if (uiState is SignupUiState.SignupNameRegistering) { + mainAction.OnShowLoading() + } + + LaunchedEffect(true) { + viewModel.navigationFlow.collect { + when (it) { + SignupEffect.NavigateToBack -> navAction.popBackStack() + SignupEffect.NavigateToOnboarding -> navAction.navigateToGuide() + } + } + } + + LaunchedEffect(true) { + viewModel.snackBarFlow.collect { + mainAction.onShowErrorMessage( + message = it.asString(context = context), + ) + } + } + + SignupScreen( + padding = padding, + focusManager = focusManager, + typedName = uiState.name, + onBackClick = viewModel::onBackPressed, + onNameType = viewModel::onNameType, + onContinueClick = viewModel::onNameSubmit, + ) +} + +@Composable +fun SignupScreen( + padding: Dp, + focusManager: FocusManager, + typedName: String, + onBackClick: () -> Unit, + onNameType: (String) -> Unit, + onContinueClick: (String) -> Unit, +) { + Scaffold( + modifier = Modifier + .navigationBarsPadding() + .statusBarsPadding() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + focusManager.clearFocus() + }, + topBar = { + BrakeTopAppbar( + onClick = onBackClick, + ) + }, + ) { paddingValues -> + ConstraintLayout( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = padding) + .padding(paddingValues = paddingValues), + ) { + val (content, continueButton) = createRefs() + + Column( + modifier = Modifier + .constrainAs(content) { + top.linkTo(parent.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + } + .fillMaxSize(), + ) { + VerticalSpacer(32.dp) + + Text( + modifier = Modifier.padding(start = 16.dp), + text = stringResource(R.string.signup_title_text), + textAlign = TextAlign.Start, + style = BrakeTheme.typography.title24B, + ) + + VerticalSpacer(4.dp) + + Text( + modifier = Modifier.padding(start = 16.dp), + text = stringResource(R.string.signup_subtitle_text), + textAlign = TextAlign.Start, + style = BrakeTheme.typography.body16M, + ) + + VerticalSpacer(36.dp) + + NicknameTextField( + modifier = Modifier + .fillMaxWidth() + .clearFocusOnKeyboardDismiss(), + value = typedName, + onValueChange = { + onNameType(it) + }, + placeholder = stringResource(R.string.signup_text_field_placeholder_text), + trailingIcon = painterResource(D.drawable.ic_check), + warningGuideText = stringResource(R.string.signup_text_field_helper_warning_text), + validGuideText = stringResource(R.string.signup_text_field_helper_valid_text), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + }, + ), + ) + } + + LargeButton( + text = stringResource(R.string.signup_continue_button_text), + onClick = { + focusManager.clearFocus() + onContinueClick(typedName) + }, + modifier = Modifier + .constrainAs(continueButton) { + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + end.linkTo(parent.end) + } + .padding(bottom = 24.dp) + .imePadding(), + // 임의 제한 조건 + enabled = typedName.isValidInput(), + ) + } + } +} diff --git a/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/SignupViewModel.kt b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/SignupViewModel.kt new file mode 100644 index 00000000..0228e2a3 --- /dev/null +++ b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/SignupViewModel.kt @@ -0,0 +1,80 @@ +package com.teambrake.brake.presentation.signup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.teambrake.brake.presentation.signup.model.SignupEffect +import com.teambrake.brake.presentation.signup.model.SignupUiState +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.UpdateNicknameUseCase +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.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SignupViewModel @Inject constructor( + private val updateNicknameUseCase: UpdateNicknameUseCase, + private val firebaseAnalytics: FirebaseAnalytics, +) : ViewModel() { + private var updateJob: Job? = null + + private val _snackBarFlow = MutableSharedFlow() + val snackBarFlow = _snackBarFlow.asSharedFlow() + + private val _uiState = MutableStateFlow(SignupUiState.SignupIdle("")) + val uiState = _uiState.asStateFlow() + + private val _navigationFlow = MutableSharedFlow() + val navigationFlow = _navigationFlow.asSharedFlow() + + fun onBackPressed() { + viewModelScope.launch { + _navigationFlow.emit(SignupEffect.NavigateToBack) + } + firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) { + param(FirebaseAnalytics.Param.SCREEN_NAME, "login_screen") + } + } + + fun onNameType(name: String) { + _uiState.value = SignupUiState.SignupIdle(name) + } + + fun onNameSubmit(name: String) { + _uiState.value = SignupUiState.SignupNameRegistering(name) + updateJob = viewModelScope.launch { + runCatching { + updateNicknameUseCase( + nickname = name, + onError = { + _snackBarFlow.emit(UiString.ResourceString(R.string.signup_snackbar_register_error)) + _uiState.value = SignupUiState.SignupIdle(name) + }, + onSuccess = { + _uiState.value = SignupUiState.SignupIdle(name) + firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SIGN_UP) { + param(FirebaseAnalytics.Param.METHOD, "nickname_registration") + } + _navigationFlow.emit(SignupEffect.NavigateToOnboarding) + }, + ) + }.onFailure { + Timber.e(it, "닉네임 업데이트 중 에러 발생") + } + } + } + + fun cancelNameSubmit() { + updateJob?.run { + cancel() + _uiState.value = SignupUiState.SignupIdle(_uiState.value.name) + } + } +} diff --git a/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/component/NicknameTextField.kt b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/component/NicknameTextField.kt new file mode 100644 index 00000000..e24059b9 --- /dev/null +++ b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/component/NicknameTextField.kt @@ -0,0 +1,70 @@ +package com.teambrake.brake.presentation.signup.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +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 com.teambrake.brake.core.designsystem.component.BaseTextField +import com.teambrake.brake.core.designsystem.theme.BrakeTheme +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 NicknameTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + placeholder: String = "", + trailingIcon: Painter, + warningGuideText: String, + validGuideText: String, + keyboardActions: KeyboardActions, +) { + BaseTextField( + modifier = modifier, + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + trailingIcon = if (value.isValidInput()) trailingIcon else null, + supportingText = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Absolute.SpaceBetween, + ) { + 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, + ) + } + Text( + text = "${value.length} / 10", + color = when { + value.isValidInput() -> Green + value.isEmpty() -> White + else -> Red + }, + style = BrakeTheme.typography.body12M, + ) + } + }, + keyboardActions = keyboardActions, + ) +} diff --git a/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/model/SignupEffect.kt b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/model/SignupEffect.kt new file mode 100644 index 00000000..f1a8598d --- /dev/null +++ b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/model/SignupEffect.kt @@ -0,0 +1,12 @@ +package com.teambrake.brake.presentation.signup.model + +import androidx.compose.runtime.Immutable + +interface SignupEffect { + + @Immutable + data object NavigateToBack : SignupEffect + + @Immutable + data object NavigateToOnboarding : SignupEffect +} diff --git a/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/model/SignupUiState.kt b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/model/SignupUiState.kt new file mode 100644 index 00000000..d79e354a --- /dev/null +++ b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/model/SignupUiState.kt @@ -0,0 +1,16 @@ +package com.teambrake.brake.presentation.signup.model + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Stable +sealed interface SignupUiState { + + val name: String + + @Immutable + data class SignupIdle(override val name: String) : SignupUiState + + @Immutable + data class SignupNameRegistering(override val name: String) : SignupUiState +} diff --git a/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/navigation/SignupNavigation.kt b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/navigation/SignupNavigation.kt new file mode 100644 index 00000000..f78cf53d --- /dev/null +++ b/presentation/signup/src/main/java/com/teambrake/brake/presentation/signup/navigation/SignupNavigation.kt @@ -0,0 +1,18 @@ +package com.teambrake.brake.presentation.signup.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.teambrake.brake.presentation.signup.SignupRoute +import com.teambrake.brake.core.navigation.route.InitialRoute + +fun NavController.navigateToSignup(navOptions: NavOptions? = null) { + navigate(InitialRoute.SignUp, navOptions) +} + +fun NavGraphBuilder.signupNavGraph() { + composable { + SignupRoute() + } +} diff --git a/presentation/signup/src/main/res/values/strings.xml b/presentation/signup/src/main/res/values/strings.xml new file mode 100644 index 00000000..7a922fbb --- /dev/null +++ b/presentation/signup/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + 어떻게 불러드릴까요? + 나중에 변경할 수 있어요 + 닉네임을 입력해 주세요 + 다음 + 공백, 특수문자 없이 2~10자를 입력해 주세요. + 사용 가능한 이름입니다. + 이름 등록 중 오류가 발생했습니다. + diff --git a/settings.gradle.kts b/settings.gradle.kts index ef174055..6b085c83 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,10 +13,11 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = java.net.URI("https://devrepo.kakao.com/nexus/content/groups/public/") } } } -rootProject.name = "Breake" +rootProject.name = "Brake" // Application include(":app") @@ -24,8 +25,23 @@ include(":app") // Presentation include( ":presentation:main", + ":presentation:signup", ":presentation:login", - ":presentation:home" + ":presentation:legal", + ":presentation:onboarding", + ":presentation:permission", + ":presentation:home", + ":presentation:report", + ":presentation:setting", +) + +// overlay +include( + "overlay:main", + "overlay:ui", + "overlay:timer", + "overlay:snooze", + "overlay:blocking", ) // Domain @@ -34,12 +50,24 @@ include(":domain") // Data include(":data") +// Data Test +include(":data-test") + // Core include( + ":core:appscanner", + ":core:auth", + ":core:alarm", + ":core:common", ":core:datastore", ":core:database", + ":core:designsystem", + ":core:ui", ":core:model", ":core:navigation", - ":core:designsystem", - ":core:testing" + ":core:permission", + ":core:detection", + ":core:service", + ":core:testing", + ":core:util", )