diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..512feddf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: 버그 리포트 +about: 버그를 발견하셨나요? 알려주세요! +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## 버그 설명 +버그에 대한 명확하고 간결한 설명을 작성해주세요. + +## 재현 방법 +버그를 재현하는 단계: +1. '...'로 이동 +2. '...' 클릭 +3. '...'까지 스크롤 +4. 오류 확인 + +## 예상 동작 +예상했던 동작을 설명해주세요. + +## 실제 동작 +실제로 발생한 동작을 설명해주세요. + +## 스크린샷 +가능하다면 스크린샷을 추가해주세요. + +## 환경 정보 +- 기기: [예: Samsung Galaxy S21] +- OS 버전: [예: Android 12] +- 앱 버전: [예: 1.0.0] + +## 추가 정보 +기타 추가 정보나 컨텍스트를 작성해주세요. + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..7e72d02d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +blank_issues_enabled: false +contact_links: + - name: 문서 + url: https://github.com/YOUR_ORG/FoodDiary-Android/wiki + about: 프로젝트 문서를 확인하세요 + - name: 질문 + url: https://github.com/YOUR_ORG/FoodDiary-Android/discussions + about: 질문이나 토론이 필요하신가요? + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..5bc574db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: 기능 제안 +about: 새로운 기능 아이디어를 제안해주세요! +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## 기능 설명 +제안하는 기능에 대한 명확하고 간결한 설명을 작성해주세요. + +## 문제 상황 +이 기능이 해결하고자 하는 문제나 개선하고자 하는 점을 설명해주세요. +예: 현재 [문제점] 때문에 [불편함]을 느끼고 있습니다. + +## 제안하는 해결책 +원하는 기능이 어떻게 동작하면 좋을지 설명해주세요. + +## 대안 +고려했던 다른 해결 방법이나 대안이 있다면 설명해주세요. + +## 추가 정보 +기타 추가 정보나 참고 자료를 작성해주세요. + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..22d4c5fb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## 변경 내용 + + +## 체크리스트 +- [ ] 빌드 정상 동작 +- [ ] 불필요한 파일 없음 + diff --git a/.github/workflows/firebase-deploy.yml b/.github/workflows/firebase-deploy.yml new file mode 100644 index 00000000..73b3e9da --- /dev/null +++ b/.github/workflows/firebase-deploy.yml @@ -0,0 +1,107 @@ +name: Firebase App Distribution + +on: + push: + branches: + - develop + workflow_dispatch: # 수동 실행 가능 + +jobs: + deploy: + name: Build and Deploy to Firebase + runs-on: ubuntu-latest + env: + # CI에서 local.properties 없이 주입 + WEB_CLIENT_ID: ${{ secrets.WEB_CLIENT_ID }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + API_BASE_URL: ${{ secrets.API_BASE_URL }} + RELEASE_STORE_FILE: mumuk-release.jks + RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for git log + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Create google-services.json + run: | + # trace/출력 끄기 + set +x + mkdir -p app + printf "%s" "$GOOGLE_SERVICES_JSON" | base64 --decode > app/google-services.json + echo "google-services.json safely created" + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + + - name: Create release keystore + run: | + set +x + umask 077 + if [ -z "$RELEASE_KEY_BASE64" ]; then + echo "RELEASE_KEY_BASE64 is not set" + exit 1 + fi + printf "%s" "$RELEASE_KEY_BASE64" | base64 --decode > mumuk-release.jks + chmod 600 mumuk-release.jks + echo "mumuk-release.jks safely created" + env: + RELEASE_KEY_BASE64: ${{ secrets.RELEASE_KEY_BASE64 }} + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Build APK (PR preview) + if: github.event_name == 'pull_request' + env: + VERSION_CODE: ${{ github.run_number }} + run: | + bundle exec fastlane build + + - name: Deploy to Firebase App Distribution + env: + VERSION_CODE: ${{ github.run_number }} + FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID_RELEASE }} + FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} + FIREBASE_TESTER_GROUPS: mumuk + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + bundle exec fastlane distribute + + - name: Clean sensitive files + if: always() + run: | + rm -f mumuk-release.jks app/google-services.json + + - name: Upload APK artifact + if: success() + uses: actions/upload-artifact@v4 + with: + name: app-release-${{ github.run_number }} + path: app/build/outputs/apk/release/app-release.apk + retention-days: 30 diff --git a/.gitignore b/.gitignore index 583de7b3..d92da58b 100644 --- a/.gitignore +++ b/.gitignore @@ -181,8 +181,46 @@ crashlytics.properties crashlytics-build.properties fabric.properties +sentry.properties +.sentryclirc +**/sentry.properties + +node_modules/ +functions/node_modules/ +functions/lib/ + ### AndroidStudio Patch ### !/gradle/wrapper/gradle-wrapper.jar -# End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,kotlin \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,kotlin + +### Fastlane ### +# fastlane specific +fastlane/report.xml + +# fastlane screenshots +fastlane/screenshots + +# fastlane test output +fastlane/test_output + +# fastlane temporary files +fastlane/Preview.html + +# deliver temporary files +fastlane/Deliverfile + +# snapshot generated screenshots +fastlane/screenshots + +# scan temporary files +fastlane/test_output + +### Ruby/Bundler ### +# Ignore bundler cache +.bundle +vendor/bundle + +# Ignore Gemfile.lock (optional - some prefer to commit it) +# Gemfile.lock \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..86418506 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "fastlane", "~> 2.220" + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 041bddca..2998177f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,47 +1,185 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.google.gms.services) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) + alias(libs.plugins.sentry.android.gradle) +} + +val localProperties = Properties().apply { + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use { load(it) } + } +} + +fun localOrEnv(localKeys: List, envKey: String): String { + val localValue = localKeys + .asSequence() + .map { key -> localProperties.getProperty(key, "").trim() } + .firstOrNull { it.isNotEmpty() } + .orEmpty() + + return localValue.ifEmpty { System.getenv(envKey).orEmpty().trim() } } +val devStoreFile = localOrEnv(listOf("dev.store.file"), "DEV_KEYSTORE_PATH") +val devStorePassword = localOrEnv(listOf("dev.store.password"), "DEV_KEYSTORE_PASSWORD") +val devKeyAlias = localOrEnv(listOf("dev.key.alias"), "DEV_KEY_ALIAS") +val devKeyPassword = localOrEnv(listOf("dev.key.password"), "DEV_KEY_PASSWORD") +val hasDevSigningConfig = listOf( + devStoreFile, + devStorePassword, + devKeyAlias, + devKeyPassword +).all { it.isNotBlank() } + +val releaseStoreFile = localOrEnv(listOf("store.file"), "RELEASE_STORE_FILE") +val releaseStorePassword = localOrEnv(listOf("store.password"), "RELEASE_STORE_PASSWORD") +val releaseKeyAlias = localOrEnv(listOf("key.alias"), "RELEASE_KEY_ALIAS") +val releaseKeyPassword = localOrEnv(listOf("key.password"), "RELEASE_KEY_PASSWORD") +val hasReleaseSigningConfig = listOf( + releaseStoreFile, + releaseStorePassword, + releaseKeyAlias, + releaseKeyPassword +).all { it.isNotBlank() } +val sentryAuthToken = localOrEnv(listOf("sentry.auth.token"), "SENTRY_AUTH_TOKEN") + android { namespace = "com.nexters.fooddiary" - compileSdk { - version = release(36) - } + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { applicationId = "com.nexters.fooddiary" - minSdk = 24 - targetSdk = 36 - versionCode = 1 - versionName = "1.0" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = project.findProperty("versionCode")?.toString()?.toInt() ?: 1 + versionName = "1.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + val webClientId = localProperties.getProperty("web.client.id", "") + .ifEmpty { System.getenv("WEB_CLIENT_ID").orEmpty() } + + if (webClientId.isNotEmpty()) { + resValue("string", "custom_web_client_id", webClientId) + } + + val sentryDsn = localProperties.getProperty("sentry.dsn", "") + .ifEmpty { System.getenv("SENTRY_DSN").orEmpty() } + .trim() + buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"") + manifestPlaceholders["sentryDsn"] = sentryDsn + } + + signingConfigs { + if (hasDevSigningConfig) { + create("dev") { + storeFile = rootProject.file(devStoreFile) + storePassword = devStorePassword + keyAlias = devKeyAlias + keyPassword = devKeyPassword + } + } + if (hasReleaseSigningConfig) { + create("release") { + storeFile = rootProject.file(releaseStoreFile) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + } + } } buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = project.findProperty("releaseMinify") != "false" proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + buildConfigField("boolean", "USE_MOCK_API", "false") + signingConfig = signingConfigs.findByName("release") + } + create("debugRelease") { + initWith(getByName("debug")) + matchingFallbacks += listOf("debug") + applicationIdSuffix = ".dev" + versionNameSuffix = "-dev" + signingConfig = signingConfigs.findByName("dev") + buildConfigField("boolean", "USE_MOCK_API", "false") + } + debug { + applicationIdSuffix = ".dev" + versionNameSuffix = "-dev" + buildConfigField("boolean", "USE_MOCK_API", "false") } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "11" + jvmTarget = "17" } buildFeatures { + buildConfig = true compose = true } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF" + } + } +} + +sentry { + org.set(localProperties.getProperty("sentry.org", "")) + projectName.set(localProperties.getProperty("sentry.project", "")) + authToken.set(sentryAuthToken) + includeSourceContext.set(true) + autoUploadProguardMapping.set(false) + autoInstallation { + enabled.set(true) + sentryVersion.set(libs.versions.sentryAndroid.get()) + } +} + +tasks.matching { task -> + task.name.startsWith("uploadSentry") || task.name.startsWith("sentryUpload") +}.configureEach { + enabled = false } dependencies { + // Modules + implementation(projects.core.common) + implementation(projects.core.ui) + implementation(projects.core.classification) + implementation(projects.domain) + implementation(projects.data) + + implementation(projects.presentation.home) + implementation(projects.presentation.widget) + implementation(projects.presentation.image) + implementation(projects.presentation.auth) + implementation(projects.presentation.mypage) + implementation(projects.presentation.webview) + implementation(projects.presentation.splash) + implementation(projects.presentation.detail) + implementation(projects.presentation.modify) + implementation(projects.presentation.onboarding) + implementation(projects.presentation.search) + implementation(projects.presentation.insight) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -50,6 +188,27 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.haze) + + // Navigation Compose + implementation(libs.androidx.navigation.compose) + implementation(libs.kotlinx.serialization.core) + + // Hilt + implementation(libs.hilt.android) + implementation(libs.hilt.navigation.compose) + ksp(libs.hilt.compiler) + + // Mavericks + implementation(libs.mavericks.compose) + + // Firebase Cloud Messaging + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging) + + implementation(libs.sentry.android) + + // Testing testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -57,4 +216,4 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6332a67d..f710fc37 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,15 +2,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/com/nexters/fooddiary/FoodDiaryApplication.kt b/app/src/main/java/com/nexters/fooddiary/FoodDiaryApplication.kt new file mode 100644 index 00000000..17be4b22 --- /dev/null +++ b/app/src/main/java/com/nexters/fooddiary/FoodDiaryApplication.kt @@ -0,0 +1,31 @@ +package com.nexters.fooddiary + +import android.app.Application +import android.util.Log +import com.airbnb.mvrx.Mavericks +import dagger.hilt.android.HiltAndroidApp +import io.sentry.android.core.SentryAndroid + +@HiltAndroidApp +class FoodDiaryApplication : Application() { + override fun onCreate() { + super.onCreate() + Mavericks.initialize(this) + initSentry() + } + + private fun initSentry() { + BuildConfig.SENTRY_DSN + .takeIf { it.isNotBlank() } + ?.let { dsn -> + SentryAndroid.init(this) { options -> + options.dsn = dsn + options.environment = BuildConfig.BUILD_TYPE + options.isEnableAutoSessionTracking = true + options.isAttachStacktrace = true + options.isDebug = BuildConfig.DEBUG + } + } + ?: Log.w("Sentry", "DSN 미설정. local.properties에 sentry.dsn 추가 후 Clean + Rebuild 필요.") + } +} diff --git a/app/src/main/java/com/nexters/fooddiary/MainActivity.kt b/app/src/main/java/com/nexters/fooddiary/MainActivity.kt index d1e47b79..76053de6 100644 --- a/app/src/main/java/com/nexters/fooddiary/MainActivity.kt +++ b/app/src/main/java/com/nexters/fooddiary/MainActivity.kt @@ -1,47 +1,150 @@ package com.nexters.fooddiary +import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -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.tooling.preview.Preview -import com.nexters.fooddiary.ui.theme.FoodDiaryTheme +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.ui.platform.LocalContext +import com.nexters.fooddiary.core.ui.alert.AppDialogData +import com.nexters.fooddiary.core.ui.alert.DeleteAccountDialogData +import com.nexters.fooddiary.core.ui.alert.DialogData +import com.nexters.fooddiary.core.ui.alert.SnackBarData +import com.nexters.fooddiary.core.ui.component.FoodDiaryDeleteAccountDialog +import com.nexters.fooddiary.core.ui.component.FoodDiaryDialog +import com.nexters.fooddiary.core.ui.component.FoodDiarySnackBar +import com.nexters.fooddiary.core.ui.theme.FoodDiaryTheme +import com.nexters.fooddiary.navigation.FoodDiaryNavHost +import com.nexters.fooddiary.navigation.NavigationConstants +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +@AndroidEntryPoint class MainActivity : ComponentActivity() { + private var launchDeepLink: Uri? by mutableStateOf(null) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + launchDeepLink = consumeDeepLink(intent) enableEdgeToEdge() setContent { + val hazeState = rememberHazeState() + var customSnackBarData by remember { mutableStateOf(null) } + var snackBarRequestId by remember { mutableStateOf(0) } + var dialogData by remember { mutableStateOf(null) } + + LaunchedEffect(snackBarRequestId) { + if (snackBarRequestId == 0) return@LaunchedEffect + val delayMillis = customSnackBarData?.delayMillis ?: 2_000L + delay(delayMillis) + customSnackBarData = null + } + FoodDiaryTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + .navigationBarsPadding(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState) + ) { + FoodDiaryNavHost( + initialDeepLink = launchDeepLink, + onFinish = { finish() }, + onShowDialog = { data -> + dialogData = data + }, + onShowSnackBar = { snackBarData -> + customSnackBarData = snackBarData + snackBarRequestId += 1 + }, + onShowToast = { message -> + customSnackBarData = SnackBarData(message = message) + snackBarRequestId += 1 + } + ) + + dialogData?.let { data -> + when (data) { + is DialogData -> { + FoodDiaryDialog( + dialogData = data, + onDismissRequest = { dialogData = null } + ) + } + + is DeleteAccountDialogData -> { + FoodDiaryDeleteAccountDialog( + dialogData = data, + onDismissRequest = { dialogData = null } + ) + } + } + } + } + + customSnackBarData?.let { snackBarData -> + FoodDiarySnackBar( + message = snackBarData.message, + iconRes = snackBarData.iconRes, + hazeState = hazeState, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } } } } } -} -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + launchDeepLink = consumeDeepLink(intent) + } + + private fun consumeDeepLink(intent: Intent?): Uri? { + val directDeepLink = intent?.data + if (directDeepLink != null) { + intent.data = null + return directDeepLink + } + + val pushType = intent?.getStringExtra(PUSH_TYPE_EXTRA).orEmpty() + val pushDiaryDate = intent?.getStringExtra(PUSH_DIARY_DATE_EXTRA).orEmpty() + if (pushType != PUSH_TYPE_ANALYSIS_COMPLETE || pushDiaryDate.isBlank()) { + return null + } + + intent?.removeExtra(PUSH_TYPE_EXTRA) + intent?.removeExtra(PUSH_DIARY_DATE_EXTRA) + return Uri.Builder() + .scheme("fooddiary") + .authority(NavigationConstants.DEEP_LINK_HOST_DETAIL) + .appendQueryParameter(NavigationConstants.DEEP_LINK_QUERY_DATE, pushDiaryDate) + .build() + } -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - FoodDiaryTheme { - Greeting("Android") + companion object { + private const val PUSH_TYPE_EXTRA = "push_type" + private const val PUSH_DIARY_DATE_EXTRA = "push_diary_date" + private const val PUSH_TYPE_ANALYSIS_COMPLETE = "analysis_complete" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/nexters/fooddiary/di/AppModule.kt b/app/src/main/java/com/nexters/fooddiary/di/AppModule.kt new file mode 100644 index 00000000..f522f3b9 --- /dev/null +++ b/app/src/main/java/com/nexters/fooddiary/di/AppModule.kt @@ -0,0 +1,20 @@ +package com.nexters.fooddiary.di + +import com.nexters.fooddiary.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Named + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Provides + @Named("isDebug") + fun provideIsDebug(): Boolean = BuildConfig.DEBUG + + @Provides + @Named("useMockApi") + fun provideUseMockApi(): Boolean = BuildConfig.USE_MOCK_API +} diff --git a/app/src/main/java/com/nexters/fooddiary/navigation/FoodDiaryNavHost.kt b/app/src/main/java/com/nexters/fooddiary/navigation/FoodDiaryNavHost.kt new file mode 100644 index 00000000..3a628a61 --- /dev/null +++ b/app/src/main/java/com/nexters/fooddiary/navigation/FoodDiaryNavHost.kt @@ -0,0 +1,455 @@ +package com.nexters.fooddiary.navigation + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.core.content.ContextCompat +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.nexters.fooddiary.R +import com.nexters.fooddiary.core.common.push.PushSyncConstants +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import com.nexters.fooddiary.core.ui.alert.AppDialogData +import com.nexters.fooddiary.core.ui.alert.SnackBarData +import com.nexters.fooddiary.push.PushSyncEventBus +import com.nexters.fooddiary.presentation.auth.AuthUiState +import com.nexters.fooddiary.presentation.auth.navigation.LoginRoute +import com.nexters.fooddiary.presentation.auth.navigation.loginScreen +import com.nexters.fooddiary.presentation.detail.navigation.DetailRoute +import com.nexters.fooddiary.presentation.detail.navigation.detailScreen +import com.nexters.fooddiary.presentation.home.HomeCoachmarkOverlay +import com.nexters.fooddiary.presentation.home.navigation.HomeRoute +import com.nexters.fooddiary.presentation.home.navigation.homeScreen +import com.nexters.fooddiary.presentation.insight.navigation.InsightRoute +import com.nexters.fooddiary.presentation.insight.navigation.insightScreen +import com.nexters.fooddiary.presentation.image.navigation.ImagePickerRoute +import com.nexters.fooddiary.presentation.image.navigation.imageScreen +import com.nexters.fooddiary.presentation.onboarding.navigation.OnboardingRoute +import com.nexters.fooddiary.presentation.onboarding.navigation.onboardingScreen +import com.nexters.fooddiary.presentation.mypage.navigation.MyPageRoute +import com.nexters.fooddiary.presentation.mypage.navigation.WebViewPage +import com.nexters.fooddiary.presentation.mypage.navigation.myPageScreen +import com.nexters.fooddiary.presentation.modify.navigation.ModifyRoute +import com.nexters.fooddiary.presentation.modify.navigation.MODIFY_SEARCH_RESULT_NAME +import com.nexters.fooddiary.presentation.modify.navigation.MODIFY_SEARCH_RESULT_ROAD_ADDRESS +import com.nexters.fooddiary.presentation.modify.navigation.MODIFY_SEARCH_RESULT_URL +import com.nexters.fooddiary.presentation.modify.navigation.modifyScreen +import com.nexters.fooddiary.presentation.search.navigation.SearchRoute +import com.nexters.fooddiary.presentation.search.navigation.searchScreen +import com.nexters.fooddiary.presentation.webview.navigation.WebViewRoute +import com.nexters.fooddiary.presentation.webview.navigation.webViewScreen +import com.nexters.fooddiary.presentation.splash.navigation.SplashRoute +import com.nexters.fooddiary.presentation.splash.navigation.splashScreen +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import com.nexters.fooddiary.core.common.R as CommonR +import com.nexters.fooddiary.core.ui.R as CoreUiR + +@Composable +fun FoodDiaryNavHost( + initialDeepLink: Uri? = null, + onFinish: () -> Unit, + navController: NavHostController = rememberNavController(), + onShowDialog: (AppDialogData) -> Unit = {}, + onShowSnackBar: (SnackBarData) -> Unit = {}, + onShowToast: (String) -> Unit = {} +) { + val context = LocalContext.current + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = {} + ) + var authUiState by remember { mutableStateOf(null) } + var deleteAccountRequestId by remember { mutableIntStateOf(0) } + var onboardingCompleteEventId by remember { mutableIntStateOf(0) } + var pendingDetailDate by remember { mutableStateOf(initialDeepLink.getDetailDateOrNull()) } + var isHomeMonthlyCalendarView by rememberSaveable { mutableStateOf(false) } + var hasNavigatedFromSplash by remember { mutableStateOf(false) } + val bottomBarHazeState = rememberHazeState() + var showHomeCoachmarkOnEntry by rememberSaveable { mutableStateOf(false) } + + val startDestination = if (initialDeepLink?.host == NavigationConstants.DEEP_LINK_HOST_IMAGE) { + ImagePickerRoute(dateString = null) + } else { + SplashRoute + } + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + val isHomeRoute = + currentDestination?.hierarchy?.any { it.hasRoute(HomeRoute::class) } == true + val isInsightRoute = + currentDestination?.hierarchy?.any { it.hasRoute(InsightRoute::class) } == true + val isLoginRoute = + currentDestination?.hierarchy?.any { it.hasRoute(LoginRoute::class) } == true + val shouldShowHomeInsightBottomBar = isHomeRoute || isInsightRoute + val selectedTab = if (isInsightRoute) HomeInsightTab.INSIGHT else HomeInsightTab.HOME + + val navigateToPendingDetailIfNeeded: () -> Unit = { + pendingDetailDate?.let { detailDate -> + navController.navigate(DetailRoute(dateString = detailDate)) + pendingDetailDate = null + } + } + + fun navigateToImagePicker(dateString: String?) { + navController.navigate(ImagePickerRoute(dateString = dateString)) + } + + LaunchedEffect(initialDeepLink) { + initialDeepLink.getDetailDateOrNull()?.let { date -> + pendingDetailDate = date + if (hasNavigatedFromSplash) { + navigateToPendingDetailIfNeeded() + } + } + } + + LaunchedEffect(authUiState?.signInError) { + authUiState?.signInError?.let { error -> + onShowToast(error) + } + } + + LaunchedEffect(Unit) { + PushSyncEventBus.analysisCompleteEvents.collect { event -> + val currentEntry = navController.currentBackStackEntry + val isSyncTarget = currentEntry?.destination?.hierarchy?.any { destination -> + destination.hasRoute(HomeRoute::class) || destination.hasRoute(DetailRoute::class) + } == true + if (isSyncTarget) { + currentEntry.savedStateHandle[PushSyncConstants.PUSH_SYNC_DIARY_DATE] = event.diaryDate + } + onShowSnackBar( + SnackBarData( + message = context.getString(R.string.push_analysis_complete_snackbar), + iconRes = CoreUiR.drawable.ic_ai_analysis + ) + ) + } + } + + LaunchedEffect(onboardingCompleteEventId) { + if (onboardingCompleteEventId == 0) return@LaunchedEffect + + val isNotificationPermissionNeeded = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + if (isNotificationPermissionNeeded) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + // Splash 이후 인증 상태 변경 감지 (Login 후 Onboarding/Home 이동, Logout 후 Login 이동) + LaunchedEffect(authUiState?.isAuthenticated, authUiState?.isFirst) { + if (!hasNavigatedFromSplash) return@LaunchedEffect + + // 회원탈퇴 재인증 흐름 완료 시 requestId 리셋 + if (deleteAccountRequestId > 0 && authUiState?.isAuthenticated == false) { + deleteAccountRequestId = 0 + } + + authUiState?.isAuthenticated?.let { isAuthenticated -> + if (!isAuthenticated) { + // 로그아웃 → LoginRoute로 이동 + if (!isLoginRoute) { //이미 LoginRoute일때는 예외 처리 + navController.navigate(LoginRoute) { + popUpTo(0) { inclusive = false } + launchSingleTop = true + } + } + } else if (deleteAccountRequestId == 0) { + // 로그인 → isFirst 체크해서 Onboarding 또는 Home으로 이동 (단, 회원탈퇴 재인증 중이 아닐 때만) + val destination = if (authUiState?.isFirst == true) { + OnboardingRoute + } else { + HomeRoute + } + if (destination == HomeRoute) { + onboardingCompleteEventId += 1 + } else { + showHomeCoachmarkOnEntry = false + } + navController.navigate(destination) { + popUpTo(0) { inclusive = false } + launchSingleTop = true + } + if (destination == HomeRoute) { + navigateToPendingDetailIfNeeded() + } + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + bottomBar = { + if (shouldShowHomeInsightBottomBar) { + HomeInsightBottomBar( + selectedTab = selectedTab, + isMonthlyCalendarView = isHomeMonthlyCalendarView, + showCalendarToggle = isHomeRoute, + onToggleClick = { + if (selectedTab == HomeInsightTab.HOME) { + navController.navigate(InsightRoute) { + launchSingleTop = true + } + } else { + val movedBackToHome = navController.popBackStack() + if (!movedBackToHome) { + navController.navigate(HomeRoute) { + launchSingleTop = true + } + } + } + }, + onCalendarViewToggle = { + if (isHomeRoute) { + isHomeMonthlyCalendarView = !isHomeMonthlyCalendarView + } + }, + hazeState = bottomBarHazeState, + modifier = Modifier + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(start = 20.dp, top = 26.dp, end = 20.dp, bottom = 24.dp) + ) + } + } + ) { _ -> + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier + .fillMaxSize() + .hazeSource(bottomBarHazeState), + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None } + ) { + splashScreen( + onNavigateToHome = { + hasNavigatedFromSplash = true + showHomeCoachmarkOnEntry = false + onboardingCompleteEventId += 1 + navController.navigate(HomeRoute) { + popUpTo(SplashRoute) { inclusive = true } + } + navigateToPendingDetailIfNeeded() + }, + onNavigateToLogin = { + hasNavigatedFromSplash = true + navController.navigate(LoginRoute) { + popUpTo(SplashRoute) { inclusive = true } + } + } + ) + + loginScreen( + onAuthStateChange = { state -> + authUiState = state + }, + deleteAccountRequestId = { deleteAccountRequestId } + ) + + onboardingScreen( + onComplete = { + showHomeCoachmarkOnEntry = true + onboardingCompleteEventId += 1 + navController.navigate(HomeRoute) { + popUpTo(OnboardingRoute) { inclusive = true } + launchSingleTop = true + } + navigateToPendingDetailIfNeeded() + } + ) + + homeScreen( + onNavigateToImagePicker = { date -> + navController.navigate(ImagePickerRoute(dateString = date.toString())) + }, + onNavigateToDetail = { date -> + navController.navigate(DetailRoute(dateString = date.toString())) + }, + onNavigateToMyPage = { navController.navigate(MyPageRoute) }, + isMonthlyCalendarView = { isHomeMonthlyCalendarView }, + onShowSnackBar = onShowSnackBar, + ) + + insightScreen( + onNavigateToMyPage = { navController.navigate(MyPageRoute) }, + onBack = onFinish, + ) + + detailScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToImagePicker = { dateString -> + navController.navigate(ImagePickerRoute(dateString = dateString.toString())) + }, + onNavigateToModify = { diaryId -> + navController.navigate(ModifyRoute(diaryId = diaryId)) + }, + onShowToast = onShowToast, + ) + + modifyScreen( + onBack = { navController.popBackStack() }, + onNavigateToSearch = { query -> + navController.navigate( + SearchRoute(keyword = query.takeIf { it.isNotBlank() }) + ) + }, + onShowDialog = { dialog -> onShowDialog(dialog) }, + onShowSnackBar = onShowSnackBar, + ) + + imageScreen( + onClose = { selectedDateString -> + val previousIsDetail = + navController.previousBackStackEntry?.destination?.hasRoute(DetailRoute::class) == true + if (previousIsDetail && !selectedDateString.isNullOrBlank()) { + navController.popBackStack() + navController.popBackStack() + navController.navigate(DetailRoute(dateString = selectedDateString)) { + launchSingleTop = true + } + } else { + navController.popBackStack() + } + }, + onUploadSuccess = { uploadedDate -> + val previousIsHome = + navController.previousBackStackEntry?.destination?.hasRoute(HomeRoute::class) == true + val previousIsDetail = + navController.previousBackStackEntry?.destination?.hasRoute(DetailRoute::class) == true + if (previousIsHome) { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(PushSyncConstants.UPLOAD_PENDING_DIARY_DATE, uploadedDate.toString()) + } + navController.popBackStack() + if (previousIsDetail) { + navController.navigate(DetailRoute(dateString = uploadedDate.toString())) { + launchSingleTop = true + } + } else if (!previousIsHome) { + navController.navigate(HomeRoute) { + launchSingleTop = true + } + } + } + ) + + searchScreen( + onClose = { + if (!navController.popBackStack()) { + onFinish() + } + }, + onSelectRestaurant = { restaurant -> + navController.previousBackStackEntry?.savedStateHandle?.set( + MODIFY_SEARCH_RESULT_NAME, + restaurant.name, + ) + navController.previousBackStackEntry?.savedStateHandle?.set( + MODIFY_SEARCH_RESULT_ROAD_ADDRESS, + restaurant.roadAddress, + ) + navController.previousBackStackEntry?.savedStateHandle?.set( + MODIFY_SEARCH_RESULT_URL, + restaurant.url, + ) + navController.popBackStack() + } + ) + + myPageScreen( + navigateToWebView = { page -> + val url = when (page) { + WebViewPage.TermsOfService -> context.getString(CommonR.string.webview_url_terms_of_service) + WebViewPage.PrivacyPolicy -> context.getString(CommonR.string.webview_url_privacy_policy) + } + navController.navigate(WebViewRoute(url = url)) + }, + onShowDialog = onShowDialog, + onShowToast = onShowToast, + onBack = { navController.popBackStack() }, + onSignOut = { + navController.navigate(LoginRoute) { + popUpTo(0) { inclusive = false } + launchSingleTop = true + } + }, + onRequireReAuthForDeleteAccount = { + deleteAccountRequestId++ + navController.navigate(LoginRoute) { + popUpTo(HomeRoute) { inclusive = false } + } + }, + onNavigateToAlarmSettings = { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + } + ) + webViewScreen( + onClose = { + if (!navController.popBackStack()) { + onFinish() + } + } + ) + } + } + + if (isHomeRoute && showHomeCoachmarkOnEntry) { + HomeCoachmarkOverlay( + onDismiss = { showHomeCoachmarkOnEntry = false }, + hazeState = bottomBarHazeState, + weeklyHeaderBounds = null, + modifier = Modifier + .fillMaxSize() + .zIndex(1f), + ) + } + } +} + +private fun Uri?.getDetailDateOrNull(): String? { + if (this?.host != NavigationConstants.DEEP_LINK_HOST_DETAIL) return null + return getQueryParameter(NavigationConstants.DEEP_LINK_QUERY_DATE) + ?.takeIf { it.isNotBlank() } +} diff --git a/app/src/main/java/com/nexters/fooddiary/navigation/HomeInsightBottomBar.kt b/app/src/main/java/com/nexters/fooddiary/navigation/HomeInsightBottomBar.kt new file mode 100644 index 00000000..84b3a7fc --- /dev/null +++ b/app/src/main/java/com/nexters/fooddiary/navigation/HomeInsightBottomBar.kt @@ -0,0 +1,198 @@ +package com.nexters.fooddiary.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion.Transparent +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nexters.fooddiary.core.common.R.string +import com.nexters.fooddiary.core.ui.R.drawable +import com.nexters.fooddiary.core.ui.theme.GlassmorphismStyle +import com.nexters.fooddiary.core.ui.theme.Gray050 +import com.nexters.fooddiary.core.ui.theme.White +import com.nexters.fooddiary.core.ui.theme.glassmorphism +import com.nexters.fooddiary.core.ui.theme.neonShadow +import dev.chrisbanes.haze.HazeState + +internal enum class HomeInsightTab { + HOME, + INSIGHT, +} + +private val BottomBarGlassStyle = GlassmorphismStyle( + cornerRadius = 999.dp, + blurRadius = 30.dp, +) +private val BottomBarNeonBackgroundBrush = Brush.verticalGradient( + colors = listOf(Color(0xFFFE670E), Color(0xFFFF853D)) +) +private val BottomBarNeonBorderBrush = Brush.verticalGradient( + colors = listOf(Color.White.copy(alpha = 0.3f), Color.Transparent) +) + +@Composable +internal fun HomeInsightBottomBar( + selectedTab: HomeInsightTab, + isMonthlyCalendarView: Boolean, + showCalendarToggle: Boolean, + onToggleClick: () -> Unit, + onCalendarViewToggle: () -> Unit, + hazeState: HazeState?, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + HomeInsightToggle( + selectedTab = selectedTab, + onToggleClick = onToggleClick, + hazeState = hazeState, + ) + if (showCalendarToggle) { + IconButton( + modifier = Modifier + .size(60.dp) + .glassmorphism( + hazeState = hazeState, + style = BottomBarGlassStyle, + ), + onClick = onCalendarViewToggle, + shape = CircleShape, + colors = remember { + IconButtonColors( + containerColor = Transparent, + contentColor = Gray050, + disabledContainerColor = Transparent, + disabledContentColor = Gray050, + ) + }, + ) { + Icon( + painter = painterResource( + id = if (isMonthlyCalendarView) drawable.ic_weekly_calendar else drawable.ic_monthly_calendar + ), + contentDescription = stringResource(string.calendar), + tint = Gray050, + ) + } + } else { + Spacer(modifier = Modifier.size(60.dp)) + } + } +} + +@Composable +private fun HomeInsightToggle( + selectedTab: HomeInsightTab, + onToggleClick: () -> Unit, + hazeState: HazeState?, + modifier: Modifier = Modifier, +) { + val isHomeSelected = selectedTab == HomeInsightTab.HOME + val isInsightSelected = selectedTab == HomeInsightTab.INSIGHT + + Row( + modifier = modifier + .height(60.dp) + .glassmorphism( + hazeState = hazeState, + style = BottomBarGlassStyle, + ) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onToggleClick, + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier + .height(44.dp) + .width(75.dp) + .neonSelectionBackground(isHomeSelected) + .padding(12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(drawable.ic_home), + contentDescription = stringResource(string.home_nav_home), + tint = if (isHomeSelected) White else Gray050, + modifier = Modifier.size(20.dp), + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(string.home_nav_home), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (isHomeSelected) White else Gray050, + ) + } + Row( + modifier = Modifier + .height(44.dp) + .width(105.dp) + .neonSelectionBackground(isInsightSelected) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(drawable.ic_insights), + contentDescription = stringResource(string.home_nav_insight), + tint = if (isInsightSelected) White else Gray050, + modifier = Modifier.size(20.dp), + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(string.home_nav_insight), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (isInsightSelected) White else Gray050, + ) + } + } +} + +private fun Modifier.neonSelectionBackground(isSelected: Boolean): Modifier { + return if (isSelected) { + this + .neonShadow( + color = Color(0x66FF8842), + blurRadius = 16.dp, + borderRadius = 22.dp, + ) + .border(width = 1.dp, brush = BottomBarNeonBorderBrush, shape = CircleShape) + .background(brush = BottomBarNeonBackgroundBrush, shape = CircleShape) + } else { + this.background(color = Transparent, shape = CircleShape) + } +} diff --git a/app/src/main/java/com/nexters/fooddiary/navigation/NavigationConstants.kt b/app/src/main/java/com/nexters/fooddiary/navigation/NavigationConstants.kt new file mode 100644 index 00000000..ff76be33 --- /dev/null +++ b/app/src/main/java/com/nexters/fooddiary/navigation/NavigationConstants.kt @@ -0,0 +1,7 @@ +package com.nexters.fooddiary.navigation + +internal object NavigationConstants { + const val DEEP_LINK_HOST_IMAGE = "image" + const val DEEP_LINK_HOST_DETAIL = "detail" + const val DEEP_LINK_QUERY_DATE = "date" +} diff --git a/app/src/main/java/com/nexters/fooddiary/push/FoodDiaryFirebaseMessagingService.kt b/app/src/main/java/com/nexters/fooddiary/push/FoodDiaryFirebaseMessagingService.kt new file mode 100644 index 00000000..2d847b4f --- /dev/null +++ b/app/src/main/java/com/nexters/fooddiary/push/FoodDiaryFirebaseMessagingService.kt @@ -0,0 +1,184 @@ +package com.nexters.fooddiary.push + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.nexters.fooddiary.MainActivity +import com.nexters.fooddiary.R +import com.nexters.fooddiary.domain.usecase.SyncDeviceTokenUseCase +import com.nexters.fooddiary.navigation.NavigationConstants +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeParseException +import javax.inject.Inject +import com.nexters.fooddiary.core.ui.R as coreR + +@AndroidEntryPoint +class FoodDiaryFirebaseMessagingService : FirebaseMessagingService() { + @Inject + lateinit var syncDeviceTokenUseCase: SyncDeviceTokenUseCase + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + + val type = message.data["type"].orEmpty() + val diaryDate = message.data["diary_date"].orEmpty() + + if (type == ANALYSIS_COMPLETE_TYPE && diaryDate.isNotBlank()) { + PushSyncEventBus.publishAnalysisComplete(diaryDate) + } + + showNotification(message) + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + if (token.isBlank()) return + + serviceScope.launch { + syncDeviceTokenUseCase(token) + } + } + + override fun onDestroy() { + serviceScope.cancel() + super.onDestroy() + } + + companion object { + private const val TAG = "FD-FCM-Service" + private const val DEFAULT_CHANNEL_ID = "food_diary_general" + private const val ANALYSIS_COMPLETE_TYPE = "analysis_complete" + } + + private fun showNotification(message: RemoteMessage) { + if (!hasNotificationPermission()) { + return + } + + val type = message.data["type"].orEmpty() + val diaryDate = message.data["diary_date"].orEmpty() + val channelId = message.notification?.channelId + ?.takeIf { it.isNotBlank() } + ?: DEFAULT_CHANNEL_ID + + ensureNotificationChannel(channelId) + + val title = message.notification?.title + ?.takeIf { it.isNotBlank() } + ?: getDefaultTitle(type) + val body = message.notification?.body + ?.takeIf { it.isNotBlank() } + ?: getDefaultBody(type, diaryDate) + + val pendingIntent = PendingIntent.getActivity( + this, + (type + diaryDate).hashCode(), + createLaunchIntent(type, diaryDate), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(this, channelId) + .setSmallIcon(coreR.drawable.ic_notification) + .setLargeIcon(BitmapFactory.decodeResource(resources, coreR.drawable.ic_app_icon)) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + NotificationManagerCompat.from(this).notify((type + diaryDate + title).hashCode(), notification) + } + + private fun createLaunchIntent(type: String, diaryDate: String): Intent { + val deepLink = Uri.Builder() + .scheme("fooddiary") + .authority(NavigationConstants.DEEP_LINK_HOST_DETAIL) + .appendQueryParameter(NavigationConstants.DEEP_LINK_QUERY_DATE, diaryDate) + .build() + + return Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + data = deepLink + putExtra("push_type", type) + putExtra("push_diary_date", diaryDate) + } + } + + private fun getDefaultTitle(type: String): String { + return when (type) { + ANALYSIS_COMPLETE_TYPE -> getString(R.string.push_analysis_complete_title) + else -> getString(R.string.push_default_title) + } + } + + private fun getDefaultBody(type: String, diaryDate: String): String { + return when (type) { + ANALYSIS_COMPLETE_TYPE -> { + val displayDate = formatDiaryDate(diaryDate) ?: diaryDate + if (displayDate.isBlank()) { + getString(R.string.push_analysis_complete_body_no_date) + } else { + getString(R.string.push_analysis_complete_body, displayDate) + } + } + + else -> getString(R.string.push_default_body) + } + } + + private fun formatDiaryDate(rawDate: String): String? { + if (rawDate.isBlank()) return null + return try { + val date = runCatching { LocalDate.parse(rawDate) } + .getOrElse { LocalDateTime.parse(rawDate).toLocalDate() } + "${date.monthValue}월 ${date.dayOfMonth}일" + } catch (_: DateTimeParseException) { + rawDate + } + } + + private fun ensureNotificationChannel(channelId: String) { + val manager = getSystemService(NotificationManager::class.java) + if (manager.getNotificationChannel(channelId) != null) return + + val channel = NotificationChannel( + channelId, + getString(R.string.push_channel_name), + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = getString(R.string.push_channel_description) + } + manager.createNotificationChannel(channel) + } + + private fun hasNotificationPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/app/src/main/java/com/nexters/fooddiary/push/PushSyncEventBus.kt b/app/src/main/java/com/nexters/fooddiary/push/PushSyncEventBus.kt new file mode 100644 index 00000000..eb36a5a9 --- /dev/null +++ b/app/src/main/java/com/nexters/fooddiary/push/PushSyncEventBus.kt @@ -0,0 +1,23 @@ +package com.nexters.fooddiary.push + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +data class AnalysisCompleteSyncEvent( + val diaryDate: String, +) + +object PushSyncEventBus { + private val _analysisCompleteEvents = MutableSharedFlow( + extraBufferCapacity = 32 + ) + val analysisCompleteEvents: SharedFlow = + _analysisCompleteEvents.asSharedFlow() + + fun publishAnalysisComplete(diaryDate: String) { + if (diaryDate.isBlank()) return + _analysisCompleteEvents.tryEmit(AnalysisCompleteSyncEvent(diaryDate = diaryDate)) + } +} + diff --git a/app/src/main/java/com/nexters/fooddiary/ui/theme/Color.kt b/app/src/main/java/com/nexters/fooddiary/ui/theme/Color.kt deleted file mode 100644 index bd4b98fc..00000000 --- a/app/src/main/java/com/nexters/fooddiary/ui/theme/Color.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.nexters.fooddiary.ui.theme - -import androidx.compose.ui.graphics.Color - -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/nexters/fooddiary/ui/theme/Theme.kt b/app/src/main/java/com/nexters/fooddiary/ui/theme/Theme.kt deleted file mode 100644 index 71f2bedc..00000000 --- a/app/src/main/java/com/nexters/fooddiary/ui/theme/Theme.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.nexters.fooddiary.ui.theme - -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext - -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ -) - -@Composable -fun FoodDiaryTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit -) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/nexters/fooddiary/ui/theme/Type.kt b/app/src/main/java/com/nexters/fooddiary/ui/theme/Type.kt deleted file mode 100644 index 68eeb575..00000000 --- a/app/src/main/java/com/nexters/fooddiary/ui/theme/Type.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.nexters.fooddiary.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -// Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9c..5bcb8f1c 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d11..56151744 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,7 @@ - - - - - - - - - - - \ No newline at end of file + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index c209e78e..2e9b7903 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..9d71604b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp 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 index b2dfe3d1..e732cdd1 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_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 index 4f0f1d64..7d9da85f 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..70657e1f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp 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 index 62b611da..6e268426 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_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 index 948a3070..a62de696 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..8e21a5b0 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp 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 index 1b9a6956..19ecd4f3 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_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 index 28d4b77f..213c8ced 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..b2a99a38 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp 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 index 9287f508..f65838eb 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_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 index aa7d6427..81a38821 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..cdc5c3d7 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp 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 index 9126ae37..b1c9486c 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0cae0c19..da8759ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,16 @@ - FoodDiary - \ No newline at end of file + 뭐먹었지? + Web Client ID가 설정되지 않았습니다. strings.xml을 확인하세요. + 로그인 실패 + 알 수 없는 오류가 발생했습니다. + 계정 삭제에 실패했습니다. + 로그아웃 + 뭐먹었지 알림 + 음식 사진 분석 및 앱 주요 알림 + 뭐먹었지 + 새로운 알림이 도착했습니다. + 음식 사진 분석이 완료됐어요 + %1$s사진을 AI가 기록을 완료했어요. + 음식 사진 분석이 완료됐어요. + AI가 기록을 완료했습니다. + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index f3ddee4d..098f78cb 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,5 @@ -