diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..10cfabf9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +max_line_length = off + +[*.{kt,kts}] +ij_kotlin_name_count_to_use_star_import = 999 +ij_kotlin_name_count_to_use_star_import_for_members = 999 + +# noinspection EditorConfigKeyCorrectness +ktlint_function_naming_ignore_when_annotated_with = Composable + +# noinspection EditorConfigKeyCorrectness +ktlint_standard_no-unused-imports = enabled \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md new file mode 100644 index 00000000..fadf25c7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue_template.md @@ -0,0 +1,15 @@ +--- +name: ISSUE_TEMPLATE +about: "Custom Issue Template" +title: '[]' +labels: '' +assignees: '' + +--- + +## ✏️ 이슈 요약 (Summary) + +이슈 요약 + +## 📌 상세 내용 (Description) +- [ ] 할 일 \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..f6c5ee65 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +## Related issue 🛠 +- closed #이슈넘버 + +## Work Description ✏️ +- 작업 내용 + +## Screenshot 📸 +- 영상/이미지 첨부 + +## Uncompleted Tasks 😅 +- [ ] Task + +## To Reviewers 📢 \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..e2988005 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,32 @@ +# Runtime Code Review Rules (for Copilot) + +Scope: The following rules apply to all code review comments. + +- Review language: Respond in Korean. +- Architecture: Verify compliance with Clean Architecture and MVI principles. +- Side-effect APIs +- Accessibility +- When implementing components in `core:designsystem`, review whether they are well designed in the Compose component style. +- Analyze Compose layout + - State Hoisting: Move state up to the lowest common ancestor that needs access to it. This improves testability and makes components more reusable. + - Unidirectional Data Flow: Data flows down, events flow up. This makes debugging and reasoning about your UI easier. Use remember to store state, and pass lambdas for events. + - Immutable Data: Use data classes and Immutable annotations where possible. This helps Compose optimize recompositions. + - Using 'remember' to cache results, but only for values that should survive recompositions (as this could cause a memory leak) + - Using the 'key' parameter in lazy layouts to avoid unnecessary recompositions + - Using 'derivedStateOf' for rapidly changing states + - Avoiding backwards writes, changing state after it has been in a composable to prevent recomposition loops + - Ensuring breaking down UI into smaller composables that do one thing well + - Proper state management, hoisting state to parent composables and using lifecycle-aware coroutine scopes like 'viewModelScope' or 'lifecycleScope' for async operations + - Adhering to Jetpack Compose API guidelines for naming, layering components, and ensuring accessibility + - Using Baseline Profiles and R8 optimizations + - Passing a 'Modifier' parameter in composables to allow customization and maintain consistency + - Not excessively overusing modifiers, resulting in reduced readability and clutter + - Unnecessary use of WebView inside of composables +- Refactor the Kotlin code below to be more idiomatic, efficient, and readable. Focus on + using Kotlin's features like extension functions, data classes, sealed classes, and + coroutines where appropriate. Explain the changes you made and why they improve the + code. Also, suggest potential performance optimizations. +- Analyze the code to identify the specific performance bottlenecks. + Suggest and implement optimizations to address these bottlenecks. + Include comments explaining the changes and their expected impact on performance. + If applicable, add logging or other performance metrics to measure the improvements. \ No newline at end of file diff --git a/.github/workflows/dplay_ci.yml b/.github/workflows/dplay_ci.yml new file mode 100644 index 00000000..0e3d8c8d --- /dev/null +++ b/.github/workflows/dplay_ci.yml @@ -0,0 +1,57 @@ +name: Android CI +on: + pull_request: + branches: [ develop, main ] + +jobs: + build: + name: CI + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + + - name: Setup Gradle Cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Touch local properties + run: touch local.properties + + - name: Access local properties + env: + BASE_URL: ${{ secrets.BASE_URL }} + KAKAO_APP_KEY: ${{ secrets.KAKAO_APP_KEY }} + run: | + echo "base.url=$BASE_URL" >> local.properties + echo "kakao.app.key=$KAKAO_APP_KEY" >> local.properties + + - name: Decode Keystore + run : | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > ./keystore.jks + + - name: Run Ktlint Check + run: ./gradlew ktlintCheck + + - name: Build with Gradle + run: ./gradlew build + env: + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} + STORE_FILE: ./keystore.jks \ No newline at end of file diff --git a/.gitignore b/.gitignore index d3cc19d8..c1407aaf 100644 --- a/.gitignore +++ b/.gitignore @@ -181,4 +181,7 @@ fabric.properties !/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 + +output-metadata.json +app-release.dm \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 32849a1c..f708ba05 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,56 +1,46 @@ +import java.io.StringReader +import java.util.Properties + plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) + alias(libs.plugins.dplay.android.application) + alias(libs.plugins.dplay.hilt) + alias(libs.plugins.dplay.test) } +val localProperties = + providers + .fileContents(isolated.rootProject.projectDirectory.file("local.properties")) + .asText + .map { text -> + val props = Properties() + props.load(StringReader(text)) + props + } + +val kakaoNativeKey: String = + providers.gradleProperty("KAKAO_APP_KEY").orNull + ?: System.getenv("KAKAO_APP_KEY") + ?: localProperties.get().getProperty("kakao.app.key") + ?: throw GradleException("KAKAO_APP_KEY (or local kakao.app.key) is missing") + android { - namespace = "com.dplay" - compileSdk = 36 + namespace = "com.diggingplay" defaultConfig { - applicationId = "com.dplay" - minSdk = 33 - targetSdk = 36 - versionCode = 1 - versionName = "1.0" + applicationId = "com.diggingplay" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = "11" - } - buildFeatures { - compose = true + buildConfigField("String", "KAKAO_APP_KEY", "\"$kakaoNativeKey\"") + manifestPlaceholders["kakaoScheme"] = "kakao$kakaoNativeKey" } } dependencies { - - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) -} \ No newline at end of file + implementation(projects.feature.main) + implementation(projects.core.navigation) + implementation(projects.core.data) + testImplementation(kotlin("test")) + implementation(libs.kakao.user) + implementation(libs.androidx.work.runtime.ktx) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb434..29312afb 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,21 +1,3 @@ -# 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 \ No newline at end of file +# 카카오 sdk 내부에 모델이 이름이 바뀌어 NoSuchFieldException 발생 방지 +-keep class com.kakao.sdk.auth.model.** { *; } +-keep class com.kakao.sdk.common.model.** { *; } \ No newline at end of file diff --git a/app/src/androidTest/java/com/diggingplay/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/diggingplay/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..5600ce62 --- /dev/null +++ b/app/src/androidTest/java/com/diggingplay/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.diggingplay + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import junit.framework.TestCase.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.diggingplay", appContext.packageName) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 95730721..9cb0665d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,8 +1,12 @@ - + + + + + + android:launchMode="singleTask"> - - - + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 00000000..a0fc27d9 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/diggingplay/AppModule.kt b/app/src/main/java/com/diggingplay/AppModule.kt new file mode 100644 index 00000000..0761bafb --- /dev/null +++ b/app/src/main/java/com/diggingplay/AppModule.kt @@ -0,0 +1,17 @@ +package com.diggingplay + +import com.example.navigation.Navigator +import com.example.navigation.Splash +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@Module +@InstallIn(ActivityRetainedComponent::class) +object AppModule { + @Provides + @ActivityRetainedScoped + fun provideNavigator(): Navigator = Navigator(startDestination = Splash) +} diff --git a/app/src/main/java/com/diggingplay/DPlayApplication.kt b/app/src/main/java/com/diggingplay/DPlayApplication.kt new file mode 100644 index 00000000..30544000 --- /dev/null +++ b/app/src/main/java/com/diggingplay/DPlayApplication.kt @@ -0,0 +1,57 @@ +package com.diggingplay + +import android.app.Application +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.diggingplay.worker.DailyQuestionWorker +import com.kakao.sdk.common.KakaoSdk +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber +import java.util.Calendar +import java.util.concurrent.TimeUnit + +@HiltAndroidApp +class DPlayApplication : Application() { + override fun onCreate() { + super.onCreate() + + KakaoSdk.init(this, BuildConfig.KAKAO_APP_KEY) + + setTimber() + scheduleDailyNotification() + } + + private fun scheduleDailyNotification() { + val currentDate = Calendar.getInstance() + val dueDate = Calendar.getInstance() + + dueDate.set(Calendar.HOUR_OF_DAY, 9) + dueDate.set(Calendar.MINUTE, 0) + dueDate.set(Calendar.SECOND, 0) + + if (dueDate.before(currentDate)) { + dueDate.add(Calendar.HOUR_OF_DAY, 24) + } + + val timeDiff = dueDate.timeInMillis - currentDate.timeInMillis + + val dailyWorkRequest = + PeriodicWorkRequestBuilder(24, TimeUnit.HOURS) + .setInitialDelay(timeDiff, TimeUnit.MILLISECONDS) + .addTag(DailyQuestionWorker.TAG) + .build() + + WorkManager + .getInstance(this) + .enqueueUniquePeriodicWork( + DailyQuestionWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + dailyWorkRequest, + ) + } + + private fun setTimber() { + if (BuildConfig.DEBUG) Timber.Forest.plant(Timber.DebugTree()) + } +} diff --git a/app/src/main/java/com/diggingplay/worker/DailyQuestionWorker.kt b/app/src/main/java/com/diggingplay/worker/DailyQuestionWorker.kt new file mode 100644 index 00000000..790a940e --- /dev/null +++ b/app/src/main/java/com/diggingplay/worker/DailyQuestionWorker.kt @@ -0,0 +1,78 @@ +package com.diggingplay.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.diggingplay.R +import com.example.main.MainActivity + +class DailyQuestionWorker( + private val context: Context, + workerParams: WorkerParameters, +) : Worker(context, workerParams) { + override fun doWork(): Result { + showNotification() + return Result.success() + } + + private fun showNotification() { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val title = context.getString(R.string.notification_title) + val content = context.getString(R.string.notification_content) + + // 명시적으로 버전 관리 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = + NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = CHANNEL_DESCRIPTION + } + notificationManager.createNotificationChannel(channel) + } + + val intent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent = + PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(content) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } + + companion object { + const val CHANNEL_ID = "daily_question_channel" + const val CHANNEL_NAME = "Daily Question" + const val CHANNEL_DESCRIPTION = "Daily Question Notification" + const val NOTIFICATION_ID = 1001 + const val WORK_NAME = "daily_question_work" + const val TAG = "DailyQuestionWorker" + } +} diff --git a/app/src/main/java/com/dplay/MainActivity.kt b/app/src/main/java/com/dplay/MainActivity.kt deleted file mode 100644 index 734f09b3..00000000 --- a/app/src/main/java/com/dplay/MainActivity.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.dplay - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -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.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.dplay.ui.theme.DPlayTheme - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - DPlayTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } - } - } - } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - DPlayTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/dplay/ui/theme/Color.kt b/app/src/main/java/com/dplay/ui/theme/Color.kt deleted file mode 100644 index ad2036a2..00000000 --- a/app/src/main/java/com/dplay/ui/theme/Color.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.dplay.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/dplay/ui/theme/Theme.kt b/app/src/main/java/com/dplay/ui/theme/Theme.kt deleted file mode 100644 index d452d793..00000000 --- a/app/src/main/java/com/dplay/ui/theme/Theme.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.dplay.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 DPlayTheme( - 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/dplay/ui/theme/Type.kt b/app/src/main/java/com/dplay/ui/theme/Type.kt deleted file mode 100644 index 396f8288..00000000 --- a/app/src/main/java/com/dplay/ui/theme/Type.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.dplay.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..ca3826a4 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,74 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + xmlns:android="http://schemas.android.com/apk/res/android"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 56% rename from app/src/main/res/mipmap-anydpi/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6f3b755b..c4a603d4 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,5 @@ - - - + + \ 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-v26/ic_launcher_round.xml similarity index 56% rename from app/src/main/res/mipmap-anydpi/ic_launcher_round.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6f3b755b..c4a603d4 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,5 @@ - - - + + \ 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..19e9fe85 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..a5353565 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..400a74ec 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..d4fc4ea0 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..919992ad 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..504a3f70 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..763456da 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..93a18ee8 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..ad2481bd 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..20a0b08a 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..fd95781c 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..6d4e317a 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..0818605f 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..94577a3e 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..29fbf084 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 596703b5..6727d01a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ - DPlay + 디플레이 + 디플레이 + 오늘의 질문이 도착했어요 \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 9f7de84f..13076935 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,7 @@ - \ No newline at end of file diff --git a/app/src/test/java/com/diggingplay/ExampleUnitTest.kt b/app/src/test/java/com/diggingplay/ExampleUnitTest.kt new file mode 100644 index 00000000..34290b74 --- /dev/null +++ b/app/src/test/java/com/diggingplay/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.diggingplay + +import junit.framework.TestCase.assertEquals +import org.junit.Test + +/** + * 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) + } +} diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts new file mode 100644 index 00000000..fb3e779e --- /dev/null +++ b/build-logic/convention/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + `kotlin-dsl` +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +dependencies { + compileOnly(libs.android.gradlePlugin) + compileOnly(libs.kotlin.gradlePlugin) + compileOnly(libs.compose.compiler.gradlePlugin) + compileOnly(libs.ksp.gradlePlugin) +} + +gradlePlugin { + plugins { + register("androidApplication") { + id = "dplay.android.application" + implementationClass = "com.example.convention.plugin.AndroidApplicationConventionPlugin" + } + + register("androidLibrary") { + id = "dplay.android.library" + implementationClass = "com.example.convention.plugin.AndroidLibraryConventionPlugin" + } + + register("androidCompose") { + id = "dplay.android.compose" + implementationClass = "com.example.convention.plugin.AndroidComposeConventionPlugin" + } + + register("feature") { + id = "dplay.feature" + implementationClass = "com.example.convention.plugin.FeatureConventionPlugin" + } + + register("data") { + id = "dplay.data" + implementationClass = "com.example.convention.plugin.DataConventionPlugin" + } + + register("domain"){ + id = "dplay.domain" + implementationClass = "com.example.convention.plugin.JavaLibraryPlugin" + } + + register("hilt") { + id = "dplay.hilt" + implementationClass = "com.example.convention.plugin.HiltConventionPlugin" + } + + register("test") { + id = "dplay.test" + implementationClass = "com.example.convention.plugin.TestPlugin" + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/example/convention/plugin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/java/com/example/convention/plugin/AndroidApplicationConventionPlugin.kt new file mode 100644 index 00000000..bcedc734 --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/plugin/AndroidApplicationConventionPlugin.kt @@ -0,0 +1,82 @@ +package com.example.convention.plugin + +import com.android.build.api.dsl.ApplicationExtension +import com.example.convention.util.configureComposeAndroid +import com.example.convention.util.configureKotlinAndroid +import com.example.convention.util.getLibrary +import com.example.convention.util.getVersion +import com.example.convention.util.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import java.io.FileInputStream +import java.util.Properties + +class AndroidApplicationConventionPlugin: Plugin { + override fun apply(target: Project) { + target.run { + pluginManager + .apply{ + apply("com.android.application") + apply("org.jlleitschuh.gradle.ktlint") + } + + val keystoreProperties = Properties() + val keystorePropertiesFile = rootProject.file("local.properties") + val isLocalPropertiesExists = keystorePropertiesFile.exists() + if (isLocalPropertiesExists) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + } + + extensions.configure { + configureKotlinAndroid(this) + configureComposeAndroid(this) + + defaultConfig { + targetSdk = libs.getVersion("targetSdk").requiredVersion.toInt() + versionCode = libs.getVersion("versionCode").requiredVersion.toInt() + versionName = libs.getVersion("versionName").requiredVersion + } + + signingConfigs { + create("release") { + keyAlias = (keystoreProperties["keyAlias"] as? String) + ?: System.getenv("KEY_ALIAS") + + keyPassword = (keystoreProperties["keyPassword"] as? String) + ?: System.getenv("KEY_PASSWORD") + + storePassword = (keystoreProperties["storePassword"] as? String) + ?: System.getenv("STORE_PASSWORD") + + val keyStoreFile = (keystoreProperties["storeFile"] as? String) + ?: System.getenv("STORE_FILE") + + if (keyStoreFile != null) { + storeFile = rootProject.file(keyStoreFile) + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + if (keystoreProperties.getProperty("storeFile") != null || System.getenv("STORE_FILE") != null) { + signingConfig = signingConfigs.getByName("release") + } + } + } + + dependencies { + add("implementation", libs.getLibrary("timber")) + } + } + } + } +} diff --git a/build-logic/convention/src/main/java/com/example/convention/plugin/AndroidComposeConventionPlugin.kt b/build-logic/convention/src/main/java/com/example/convention/plugin/AndroidComposeConventionPlugin.kt new file mode 100644 index 00000000..d1f6616c --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/plugin/AndroidComposeConventionPlugin.kt @@ -0,0 +1,33 @@ +package com.example.convention.plugin + +import com.android.build.api.dsl.LibraryExtension +import com.example.convention.util.configureComposeAndroid +import com.example.convention.util.getLibrary +import com.example.convention.util.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidComposeConventionPlugin: Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply { + apply("dplay.android.library") + apply("org.jetbrains.kotlin.plugin.compose") + apply("org.jlleitschuh.gradle.ktlint") + } + + extensions.configure { + configureComposeAndroid(this) + } + + dependencies { + add("implementation",libs.getLibrary("kotlinx.immutable")) + add("implementation", libs.getLibrary("coil.compose")) + add("implementation", libs.getLibrary("coil.network.okhttp")) + add("implementation",libs.getLibrary("androidx.paging.compose")) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/example/convention/plugin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/java/com/example/convention/plugin/AndroidLibraryConventionPlugin.kt new file mode 100644 index 00000000..cf59ae4c --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/plugin/AndroidLibraryConventionPlugin.kt @@ -0,0 +1,29 @@ +package com.example.convention.plugin + +import com.android.build.api.dsl.LibraryExtension +import com.example.convention.util.configureKotlinAndroid +import com.example.convention.util.getLibrary +import com.example.convention.util.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply { + apply("com.android.library") + apply("org.jlleitschuh.gradle.ktlint") + } + + extensions.configure { + configureKotlinAndroid(this) + } + + dependencies { + add("implementation",libs.getLibrary("timber")) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/example/convention/plugin/DataConventionPlugin.kt b/build-logic/convention/src/main/java/com/example/convention/plugin/DataConventionPlugin.kt new file mode 100644 index 00000000..1bf7b9e8 --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/plugin/DataConventionPlugin.kt @@ -0,0 +1,32 @@ +package com.example.convention.plugin + +import com.example.convention.util.getBundle +import com.example.convention.util.getLibrary +import com.example.convention.util.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class DataConventionPlugin: Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply { + apply("dplay.android.library") + apply("org.jetbrains.kotlin.plugin.serialization") + } + + dependencies { + val retrofitBom = libs.getLibrary("retrofit-bom") + val okhttpBom = libs.getLibrary("okhttp-bom") + add("implementation",platform(retrofitBom)) + add("implementation",platform(okhttpBom)) + add("implementation",libs.getLibrary("kotlinx.serialization.json")) + add("implementation",libs.getBundle("retrofit")) + add("implementation",libs.getBundle("okhttp")) + add("implementation",libs.getLibrary("kakao.user")) + add("implementation",libs.getLibrary("androidx.datastore.preferences")) + add("implementation",libs.getLibrary("androidx.paging.compose")) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/example/convention/plugin/FeatureConventionPlugin.kt b/build-logic/convention/src/main/java/com/example/convention/plugin/FeatureConventionPlugin.kt new file mode 100644 index 00000000..9ac185f9 --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/plugin/FeatureConventionPlugin.kt @@ -0,0 +1,27 @@ +package com.example.convention.plugin + +import com.example.convention.util.getBundle +import com.example.convention.util.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class FeatureConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply { + apply("dplay.android.compose") + } + + dependencies { + add("implementation",libs.getBundle("compose")) + add("implementation",libs.getBundle("navigation")) + add("implementation",project(":core:designsystem")) + add("implementation",project(":core:common")) + add("implementation",project(":core:navigation")) + add("implementation",project(":core:ui")) + add("implementation",project(":core:domain")) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/example/convention/plugin/HiltConventionPlugin.kt b/build-logic/convention/src/main/java/com/example/convention/plugin/HiltConventionPlugin.kt new file mode 100644 index 00000000..43c032c7 --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/plugin/HiltConventionPlugin.kt @@ -0,0 +1,23 @@ +package com.example.convention.plugin + +import com.example.convention.util.getLibrary +import com.example.convention.util.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class HiltConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply { + apply("dagger.hilt.android.plugin") + apply("com.google.devtools.ksp") + } + + dependencies { + add("implementation",libs.getLibrary("hilt.android")) + add("ksp",libs.getLibrary("hilt.android.compiler")) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/example/convention/plugin/JavaLibraryPlugin.kt b/build-logic/convention/src/main/java/com/example/convention/plugin/JavaLibraryPlugin.kt new file mode 100644 index 00000000..0850b087 --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/plugin/JavaLibraryPlugin.kt @@ -0,0 +1,38 @@ +package com.example.convention.plugin + +import com.example.convention.util.getLibrary +import com.example.convention.util.getVersion +import com.example.convention.util.libs +import org.gradle.api.JavaVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension + +class JavaLibraryPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply { + apply("org.jetbrains.kotlin.jvm") + apply("java-library") + } + + extensions.configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + extensions.configure { + jvmToolchain(libs.getVersion("jdkVersion").requiredVersion.toInt()) + } + + dependencies { + add("implementation", libs.getLibrary("javax.inject")) + add("implementation", libs.getLibrary("kotlinx.coroutines.core")) + add("implementation", libs.getLibrary("androidx.paging.common")) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/example/convention/plugin/TestPlugin.kt b/build-logic/convention/src/main/java/com/example/convention/plugin/TestPlugin.kt new file mode 100644 index 00000000..5896e280 --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/plugin/TestPlugin.kt @@ -0,0 +1,19 @@ +package com.example.convention.plugin + +import com.example.convention.util.getLibrary +import com.example.convention.util.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class TestPlugin: Plugin { + override fun apply(target: Project) { + with(target) { + dependencies { + add("testImplementation", libs.getLibrary("junit")) + add("androidTestImplementation", libs.getLibrary("androidx-junit")) + add("androidTestImplementation", libs.getLibrary("espresso-core")) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/example/convention/util/ConfigureComposeAndroid.kt b/build-logic/convention/src/main/java/com/example/convention/util/ConfigureComposeAndroid.kt new file mode 100644 index 00000000..d7de698f --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/util/ConfigureComposeAndroid.kt @@ -0,0 +1,24 @@ +package com.example.convention.util + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +internal fun Project.configureComposeAndroid(commonExtension: CommonExtension<*, *, *, *, *, *>) { + pluginManager.apply("org.jetbrains.kotlin.plugin.compose") + + commonExtension.apply { + buildFeatures { + compose = true + buildConfig = true + } + + dependencies { + val composeBom = libs.getLibrary("compose-bom") + add("implementation",platform(composeBom)) + add("androidTestImplementation", platform(composeBom)) + add("implementation",libs.getBundle("compose")) + add("debugImplementation",libs.getLibrary("compose-ui-tooling")) + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/example/convention/util/ConfigureKotlinAndroid.kt b/build-logic/convention/src/main/java/com/example/convention/util/ConfigureKotlinAndroid.kt new file mode 100644 index 00000000..7543b8b2 --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/util/ConfigureKotlinAndroid.kt @@ -0,0 +1,66 @@ +package com.example.convention.util + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.kotlin.dsl.provideDelegate +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension<*, *, *, *, *, *>) { + pluginManager.apply("org.jetbrains.kotlin.android") + + commonExtension.apply { + compileSdk = libs.getVersion("compileSdk").requiredVersion.toInt() + + defaultConfig { + minSdk = libs.getVersion("minSdk").requiredVersion.toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { + getByName("debug") { + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-debug.pro", + ) + } + + getByName("release") { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-rules.pro", + ) + } + } + + lint { + abortOnError = false + } + + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + + val warningsAsErrors: String? by project + allWarningsAsErrors.set(warningsAsErrors.toBoolean()) + + freeCompilerArgs.addAll( + listOf( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", + "-opt-in=androidx.lifecycle.compose.ExperimentalLifecycleComposeApi", + "-opt-in=kotlinx.serialization.InternalSerializationApi", + ), + ) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/example/convention/util/ProjectExtension.kt b/build-logic/convention/src/main/java/com/example/convention/util/ProjectExtension.kt new file mode 100644 index 00000000..361436ea --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/util/ProjectExtension.kt @@ -0,0 +1,9 @@ +package com.example.convention.util + +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType + +val Project.libs: VersionCatalog + get() = extensions.getByType().named("libs") \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/example/convention/util/VersionCatalogExtension.kt b/build-logic/convention/src/main/java/com/example/convention/util/VersionCatalogExtension.kt new file mode 100644 index 00000000..83cc1ae0 --- /dev/null +++ b/build-logic/convention/src/main/java/com/example/convention/util/VersionCatalogExtension.kt @@ -0,0 +1,22 @@ +package com.example.convention.util + +import org.gradle.api.artifacts.ExternalModuleDependencyBundle +import org.gradle.api.artifacts.MinimalExternalModuleDependency +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionConstraint +import org.gradle.api.provider.Provider + +fun VersionCatalog.getBundle(bundleName: String): Provider = + findBundle(bundleName).orElseThrow { + NoSuchElementException("Bundle with name $bundleName not found in the catalog") + } + +fun VersionCatalog.getLibrary(libraryName: String): Provider = + findLibrary(libraryName).orElseThrow { + NoSuchElementException("Library with name $libraryName not found in the catalog") + } + +fun VersionCatalog.getVersion(versionName: String): VersionConstraint = + findVersion(versionName).orElseThrow { + NoSuchElementException("Version with name $versionName not found in the catalog") + } \ No newline at end of file diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties new file mode 100644 index 00000000..6977b719 --- /dev/null +++ b/build-logic/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configureondemand=true \ No newline at end of file diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 00000000..fd968aff --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,16 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":convention") \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 952b9306..0fe42b29 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.hilt) apply false } \ No newline at end of file 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..50ea4a5c --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.dplay.android.library) + alias(libs.plugins.dplay.android.compose) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.common" +} + +dependencies { + implementation(libs.media3.exoplayer) + implementation(libs.media3.session) +} diff --git a/core/common/src/main/java/com/example/common/audio/AudioPlayer.kt b/core/common/src/main/java/com/example/common/audio/AudioPlayer.kt new file mode 100644 index 00000000..c642cf19 --- /dev/null +++ b/core/common/src/main/java/com/example/common/audio/AudioPlayer.kt @@ -0,0 +1,152 @@ +package com.example.common.audio + +import android.content.ComponentName +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors +import dagger.hilt.android.qualifiers.ApplicationContext +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 AudioPlayer + @Inject + constructor( + @ApplicationContext private val context: Context, + ) { + private var controllerFuture: ListenableFuture? = null + private var controller: MediaController? = null + + private val _playbackState = MutableStateFlow(PlaybackState()) + val playbackState: StateFlow = _playbackState.asStateFlow() + + private val playerListener = + object : Player.Listener { + override fun onPlaybackStateChanged(state: Int) { + updatePlaybackState() + if (state == Player.STATE_ENDED) { + stop() + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + updatePlaybackState() + } + } + + private fun initializeController() { + if (controllerFuture != null) return + + val sessionToken = + SessionToken(context, ComponentName(context, PlaybackService::class.java)) + controllerFuture = + MediaController.Builder(context, sessionToken).buildAsync().also { future -> + future.addListener( + { + controller = future.get() + controller?.addListener(playerListener) + }, + MoreExecutors.directExecutor(), + ) + } + } + + fun play( + url: String, + trackId: String, + title: String = "", + artist: String = "", + ) { + initializeController() + + val currentController = controller + if (currentController == null) { + controllerFuture?.addListener( + { + playInternal(url, trackId, title, artist) + }, + MoreExecutors.directExecutor(), + ) + return + } + + playInternal(url, trackId, title, artist) + } + + private fun playInternal( + url: String, + trackId: String, + title: String, + artist: String, + ) { + val currentController = controller ?: return + + currentController.stop() + currentController.clearMediaItems() + + val mediaMetadata = + MediaMetadata + .Builder() + .setTitle(title.ifEmpty { "DPlay" }) + .setArtist(artist.ifEmpty { "미리듣기" }) + .build() + + val mediaItem = + MediaItem + .Builder() + .setUri(url) + .setMediaId(trackId) + .setMediaMetadata(mediaMetadata) + .build() + + currentController.setMediaItem(mediaItem) + currentController.prepare() + currentController.play() + + _playbackState.value = + PlaybackState( + isPlaying = true, + currentTrackId = trackId, + ) + } + + fun pause() { + controller?.pause() + _playbackState.value = _playbackState.value.copy(isPlaying = false) + } + + fun stop() { + controller?.stop() + controller?.clearMediaItems() + _playbackState.value = PlaybackState() + } + + fun release() { + controller?.removeListener(playerListener) + controllerFuture?.let { MediaController.releaseFuture(it) } + controllerFuture = null + controller = null + _playbackState.value = PlaybackState() + } + + private fun updatePlaybackState() { + val currentController = controller ?: return + _playbackState.value = + _playbackState.value.copy( + isPlaying = currentController.isPlaying, + ) + } + } + +data class PlaybackState( + val isPlaying: Boolean = false, + val currentTrackId: String? = null, +) diff --git a/core/common/src/main/java/com/example/common/audio/PlaybackService.kt b/core/common/src/main/java/com/example/common/audio/PlaybackService.kt new file mode 100644 index 00000000..9a62c4c8 --- /dev/null +++ b/core/common/src/main/java/com/example/common/audio/PlaybackService.kt @@ -0,0 +1,33 @@ +package com.example.common.audio + +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService + +class PlaybackService : MediaSessionService() { + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + val player = ExoPlayer.Builder(this).build() + mediaSession = MediaSession.Builder(this, player).build() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + mediaSession = null + } + super.onDestroy() + } + + override fun onTaskRemoved(rootIntent: android.content.Intent?) { + val player = mediaSession?.player + if (player?.playWhenReady == false || player?.mediaItemCount == 0) { + stopSelf() + } + } +} diff --git a/core/common/src/main/java/com/example/common/constant/Regex.kt b/core/common/src/main/java/com/example/common/constant/Regex.kt new file mode 100644 index 00000000..33addf21 --- /dev/null +++ b/core/common/src/main/java/com/example/common/constant/Regex.kt @@ -0,0 +1,6 @@ +package com.example.common.constant + +object Regex { + // 문자 허용 범위: 한글 완성형(가–힣), 영문 A–Z/a–z, 숫자 0–9 (특수문자/이모지/초성·자모 단독 불가) + val NICKNAME_REGEX = "^[가-힣a-zA-Z0-9]+\$".toRegex() +} diff --git a/core/common/src/main/java/com/example/common/constant/Url.kt b/core/common/src/main/java/com/example/common/constant/Url.kt new file mode 100644 index 00000000..97651853 --- /dev/null +++ b/core/common/src/main/java/com/example/common/constant/Url.kt @@ -0,0 +1,21 @@ +package com.example.common.constant + +object Url { + // 서비스 이용 약관 + const val TERMS_OF_SERVICE = "https://www.notion.so/2d13aeb558c9801fb8c2db2ae6ac2c3e?source=copy_link" + + // 개인정보 처리 방침 + const val PRIVACY_POLICY = "https://www.notion.so/2d13aeb558c98003b480f83b06245430?source=copy_link" + + // 공지사항 + const val ANNOUNCEMENT = "https://www.notion.so/2d13aeb558c980919796c2b4d7109369?source=copy_link" + + // 커뮤니티 가이드 + const val COMMUNITY_GUIDE = "https://www.notion.so/2d13aeb558c980c7915bf540db799aac?source=copy_link" + + // 문의/제안하기 + const val INQUIRY = "https://forms.gle/reQb2nmhjSqXVvnq7" + + // 에러 뷰 + const val ERROR = "" +} diff --git a/core/common/src/main/java/com/example/common/event/HomeRefreshTrigger.kt b/core/common/src/main/java/com/example/common/event/HomeRefreshTrigger.kt new file mode 100644 index 00000000..a2e0ad7d --- /dev/null +++ b/core/common/src/main/java/com/example/common/event/HomeRefreshTrigger.kt @@ -0,0 +1,16 @@ +package com.example.common.event + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HomeRefreshTrigger + @Inject + constructor() { + private val _refreshEvent = MutableSharedFlow() + val refreshEvent = _refreshEvent.asSharedFlow() + + suspend fun refresh() = _refreshEvent.emit(Unit) + } diff --git a/core/common/src/main/java/com/example/common/event/RegisteredTrackRefreshTrigger.kt b/core/common/src/main/java/com/example/common/event/RegisteredTrackRefreshTrigger.kt new file mode 100644 index 00000000..58f6e4b1 --- /dev/null +++ b/core/common/src/main/java/com/example/common/event/RegisteredTrackRefreshTrigger.kt @@ -0,0 +1,17 @@ +package com.example.common.event + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +// 추후 Type-safe 하게 재사용할 수 있도록 리팩토링 +@Singleton +class RegisteredTrackRefreshTrigger + @Inject + constructor() { + private val _refreshEvent = MutableSharedFlow() + val refreshEvent = _refreshEvent.asSharedFlow() + + suspend fun refresh() = _refreshEvent.emit(Unit) + } diff --git a/core/common/src/main/java/com/example/common/event/ScrappedTrackRefreshTrigger.kt b/core/common/src/main/java/com/example/common/event/ScrappedTrackRefreshTrigger.kt new file mode 100644 index 00000000..b9ecd937 --- /dev/null +++ b/core/common/src/main/java/com/example/common/event/ScrappedTrackRefreshTrigger.kt @@ -0,0 +1,16 @@ +package com.example.common.event + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ScrappedTrackRefreshTrigger + @Inject + constructor() { + private val _refreshEvent = MutableSharedFlow() + val refreshEvent = _refreshEvent.asSharedFlow() + + suspend fun refresh() = _refreshEvent.emit(Unit) + } diff --git a/core/common/src/main/java/com/example/common/type/TermType.kt b/core/common/src/main/java/com/example/common/type/TermType.kt new file mode 100644 index 00000000..722245ae --- /dev/null +++ b/core/common/src/main/java/com/example/common/type/TermType.kt @@ -0,0 +1,16 @@ +package com.example.common.type + +import com.example.common.constant.Url + +enum class TermType( + val isMandatory: Boolean, + val url: String, +) { + TERMS_OF_SERVICE(isMandatory = true, url = Url.TERMS_OF_SERVICE), // 서비스 이용약관 (필수) + PRIVACY_POLICY(isMandatory = true, url = Url.PRIVACY_POLICY), // 개인정보 처리방침 (필수) + ; + + companion object { + val mandatoryTerms = entries.filter { it.isMandatory }.toSet() + } +} diff --git a/core/data/.gitignore b/core/data/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/data/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 00000000..86306e73 --- /dev/null +++ b/core/data/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.dplay.data) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.data" +} + +dependencies { + implementation(project.projects.core.network) + implementation(project.projects.core.domain) +} diff --git a/core/data/src/main/java/com/example/data/TokenAuthenticator.kt b/core/data/src/main/java/com/example/data/TokenAuthenticator.kt new file mode 100644 index 00000000..e0b8a4c0 --- /dev/null +++ b/core/data/src/main/java/com/example/data/TokenAuthenticator.kt @@ -0,0 +1,84 @@ +package com.example.data + +import com.example.data.datasource.remote.AuthRemoteDataSource +import com.example.data.model.response.TokenResponse +import com.example.network.TokenManager +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject +import javax.inject.Provider + +class TokenAuthenticator + @Inject + constructor( + private val tokenManager: TokenManager, + private val authDataSourceProvider: Provider, + ) : Authenticator { + private val mutex = Mutex() + + override fun authenticate( + route: Route?, + response: Response, + ): Request? { + if (response.retryCount >= MAX_RETRY) return null + + return runBlocking { + mutex.withLock { + tryWithExistingToken(response) ?: refreshAndRetry(response) + } + } + } + + private val Response.retryCount: Int + get() = generateSequence(priorResponse) { it.priorResponse }.count() + + private suspend fun tryWithExistingToken(response: Response): Request? { + val currentToken = tokenManager.getAccessToken() + val requestToken = + response.request + .header("Authorization") + ?.removePrefix("Bearer ") + + return if (currentToken != requestToken && currentToken != null) { + response.request + .newBuilder() + .header("Authorization", "Bearer $currentToken") + .build() + } else { + null + } + } + + private suspend fun refreshAndRetry(response: Response): Request? { + val refreshToken = tokenManager.getRefreshToken() ?: return null + + return try { + val newTokens = getNewTokens(refreshToken) ?: return null + tokenManager.saveTokens(newTokens.accessToken, newTokens.refreshToken) + + response.request + .newBuilder() + .header("Authorization", "Bearer ${newTokens.accessToken}") + .build() + } catch (e: Exception) { + tokenManager.clearAllTokens() + null + } + } + + private suspend fun getNewTokens(refreshToken: String): TokenResponse? = + try { + authDataSourceProvider.get().reissue(refreshToken = refreshToken) + } catch (e: Exception) { + null + } + + companion object { + private const val MAX_RETRY = 2 + } + } diff --git a/core/data/src/main/java/com/example/data/constant/ApiConstants.kt b/core/data/src/main/java/com/example/data/constant/ApiConstants.kt new file mode 100644 index 00000000..697d91d0 --- /dev/null +++ b/core/data/src/main/java/com/example/data/constant/ApiConstants.kt @@ -0,0 +1,26 @@ +package com.example.data.constant + +object ApiConstants { + const val API = "api" + + const val VERSIONS = "v1" + + const val AUTH = "auth" + const val TOKEN = "token" + const val LOGIN = "login" + const val SIGNUP = "signup" + const val REISSUE = "reissue" + const val LOGOUT = "logout" + const val WITHDRAW = "withdraw" + + const val USERS = "users" + const val ME = "me" + const val NOTIFICATIONS = "notifications" + + const val QUESTIONS = "questions" + const val POSTS = "posts" + const val TRACKS = "tracks" + const val SCRAPS = "scraps" + + const val KAKAO_PLATFORM = "KAKAO" +} diff --git a/core/data/src/main/java/com/example/data/constant/ErrorCode.kt b/core/data/src/main/java/com/example/data/constant/ErrorCode.kt new file mode 100644 index 00000000..58f6a217 --- /dev/null +++ b/core/data/src/main/java/com/example/data/constant/ErrorCode.kt @@ -0,0 +1,7 @@ +package com.example.data.constant + +object ErrorCode { + const val USER_NOT_FOUND = 4041 + const val EXPIRED_ACCESS_TOKEN = 4012 + const val DUPLICATED_NICKNAME = 4090 +} diff --git a/core/data/src/main/java/com/example/data/datasource/local/FileLocalDataSource.kt b/core/data/src/main/java/com/example/data/datasource/local/FileLocalDataSource.kt new file mode 100644 index 00000000..9a40f4fa --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/local/FileLocalDataSource.kt @@ -0,0 +1,51 @@ +package com.example.data.datasource.local + +import android.content.Context +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import javax.inject.Inject + +class FileLocalDataSource + @Inject + constructor( + @ApplicationContext private val context: Context, + ) { + suspend fun createAndGetFile(uriString: String?): File? { + if (uriString.isNullOrEmpty()) return null + + return withContext(Dispatchers.IO) { + try { + val uri = uriString.toUri() + val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null + + val fileName = "profile_${System.currentTimeMillis()}.jpg" + val file = File(context.filesDir, fileName) + + inputStream.use { input -> + FileOutputStream(file).use { output -> + input.copyTo(output) + } + } + + file + } catch (e: Exception) { + null + } + } + } + + fun createEmptyFile(): File { + val emptyFile = File(context.cacheDir, "empty_profile_temp") + + if (emptyFile.exists()) { + emptyFile.delete() + } + emptyFile.createNewFile() + + return emptyFile + } + } diff --git a/core/data/src/main/java/com/example/data/datasource/local/TokenLocalDataSource.kt b/core/data/src/main/java/com/example/data/datasource/local/TokenLocalDataSource.kt new file mode 100644 index 00000000..223b7e64 --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/local/TokenLocalDataSource.kt @@ -0,0 +1,53 @@ +package com.example.data.datasource.local + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class TokenLocalDataSource + @Inject + constructor( + private val dataStore: DataStore, + ) { + val accessToken: Flow = + dataStore.data.map { preferences -> + preferences[ACCESS_TOKEN_KEY] + } + + val refreshToken: Flow = + dataStore.data.map { preferences -> + preferences[REFRESH_TOKEN_KEY] + } + + suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) { + dataStore.edit { preferences -> + preferences[ACCESS_TOKEN_KEY] = accessToken + preferences[REFRESH_TOKEN_KEY] = refreshToken + } + } + + suspend fun clearTokens() { + dataStore.edit { preferences -> + preferences.remove(ACCESS_TOKEN_KEY) + preferences.remove(REFRESH_TOKEN_KEY) + } + } + + suspend fun updateAccessToken(newAccessToken: String) { + dataStore.edit { preferences -> + preferences[ACCESS_TOKEN_KEY] = newAccessToken + } + } + + companion object { + private val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") + private val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token") + } + } diff --git a/core/data/src/main/java/com/example/data/datasource/local/UserLocalDataSource.kt b/core/data/src/main/java/com/example/data/datasource/local/UserLocalDataSource.kt new file mode 100644 index 00000000..2905fad0 --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/local/UserLocalDataSource.kt @@ -0,0 +1,66 @@ +package com.example.data.datasource.local + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.example.domain.model.User +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class UserLocalDataSource + @Inject + constructor( + private val dataStore: DataStore, + ) { + suspend fun saveUser(user: User) { + dataStore.edit { prefs -> + prefs[USER_ID] = user.id.toString() + prefs[NICKNAME] = user.nickname + prefs[PROFILE_IMAGE] = user.profileImagePath.orEmpty() + } + } + + suspend fun updateNickname(nickname: String) { + dataStore.edit { prefs -> + prefs[NICKNAME] = nickname + } + } + + suspend fun updateProfileImage(profileImagePath: String) { + dataStore.edit { prefs -> + prefs[PROFILE_IMAGE] = profileImagePath + } + } + + suspend fun removeProfileImage() { + dataStore.edit { prefs -> + prefs.remove(PROFILE_IMAGE) + } + } + + suspend fun clearUser() { + dataStore.edit { prefs -> + prefs.remove(USER_ID) + prefs.remove(NICKNAME) + prefs.remove(PROFILE_IMAGE) + } + } + + val userFlow: Flow = + dataStore.data.map { prefs -> + val id = prefs[USER_ID] ?: return@map null + User( + id = id.toLong(), + nickname = prefs[NICKNAME].orEmpty(), + profileImagePath = prefs[PROFILE_IMAGE], + ) + } + + companion object { + private val USER_ID = stringPreferencesKey("user_id") + private val NICKNAME = stringPreferencesKey("nickname") + private val PROFILE_IMAGE = stringPreferencesKey("profile_image") + } + } diff --git a/core/data/src/main/java/com/example/data/datasource/remote/AuthRemoteDataSource.kt b/core/data/src/main/java/com/example/data/datasource/remote/AuthRemoteDataSource.kt new file mode 100644 index 00000000..c6f3c46c --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/remote/AuthRemoteDataSource.kt @@ -0,0 +1,135 @@ +package com.example.data.datasource.remote + +import com.example.data.constant.ErrorCode +import com.example.data.model.request.LoginRequest +import com.example.data.model.request.SignupRequest +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.TokenResponse +import com.example.data.service.AuthService +import com.example.network.NetworkException +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.HttpException +import timber.log.Timber +import java.io.File +import java.net.HttpURLConnection +import javax.inject.Inject + +class AuthRemoteDataSource + @Inject + constructor( + private val authService: AuthService, + private val json: Json, + ) { + suspend fun login( + accessToken: String, + loginRequest: LoginRequest, + ): TokenResponse { + try { + val response = + authService.login( + accessToken = accessToken, + request = loginRequest, + ) + + return response.data ?: throw Exception("Data is null") + } catch (e: HttpException) { + if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) { + val errorString = e.response()?.errorBody()?.string() + + if (errorString != null) { + try { + val errorResponse = json.decodeFromString>(errorString) + + if (errorResponse.code == ErrorCode.USER_NOT_FOUND) { + throw NetworkException(ErrorCode.USER_NOT_FOUND, errorResponse.message) + } + } catch (e: SerializationException) { + // JSON 형식이 잘못됨 (괄호 누락 등) + } catch (e: IllegalArgumentException) { + // 데이터 타입 불일치 + } + } + } + throw e + } + } + + suspend fun signup( + kakaoAccessToken: String?, + imageFile: File?, + signupRequest: SignupRequest, + ): TokenResponse { + try { + val imagePart = + if (imageFile != null) { + val requestFile = imageFile.asRequestBody("image/*".toMediaTypeOrNull()) + MultipartBody.Part.createFormData("profileImg", imageFile.name, requestFile) + } else { + null + } + + val jsonString = json.encodeToString(signupRequest) + val requestPart = jsonString.toRequestBody("application/json".toMediaType()) + + val response = + authService.signup( + accessToken = kakaoAccessToken ?: "", + profileImg = imagePart, + request = requestPart, + ) + + return response.data ?: throw Exception("Data is null") + } catch (e: HttpException) { + if (e.code() == HttpURLConnection.HTTP_CONFLICT) { + val errorString = e.response()?.errorBody()?.string() + Timber.d("errorString : $errorString") + + if (errorString != null) { + try { + val errorResponse = json.decodeFromString>(errorString) + Timber.d("errorResponse : $errorResponse") + if (errorResponse.code == ErrorCode.DUPLICATED_NICKNAME) { + throw NetworkException(ErrorCode.DUPLICATED_NICKNAME, errorResponse.message) + } + } catch (e: SerializationException) { + // JSON 형식이 잘못됨 (괄호 누락 등) + } catch (e: IllegalArgumentException) { + // 데이터 타입 불일치 + } + } + } + throw e + } + } + + suspend fun logout() { + try { + authService.logout() + } catch (e: Exception) { + throw e + } + } + + suspend fun withdraw() { + try { + authService.withdraw() + } catch (e: Exception) { + throw e + } + } + + suspend fun reissue(refreshToken: String): TokenResponse { + try { + val response = authService.reissue(refreshToken = "Bearer $refreshToken") + return response.data ?: throw Exception("Data is null") + } catch (e: Exception) { + throw e + } + } + } diff --git a/core/data/src/main/java/com/example/data/datasource/remote/DummyRemoteDataSource.kt b/core/data/src/main/java/com/example/data/datasource/remote/DummyRemoteDataSource.kt new file mode 100644 index 00000000..d2bb33ad --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/remote/DummyRemoteDataSource.kt @@ -0,0 +1,14 @@ +package com.example.data.datasource.remote + +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.DummyResponse +import com.example.data.service.DummyService +import javax.inject.Inject + +class DummyRemoteDataSource + @Inject + constructor( + private val dummyService: DummyService, + ) { + suspend fun getDummy(dummyId: Long): BaseResponse = dummyService.getDummy(dummyId = dummyId) + } diff --git a/core/data/src/main/java/com/example/data/datasource/remote/KakaoLoginDataSource.kt b/core/data/src/main/java/com/example/data/datasource/remote/KakaoLoginDataSource.kt new file mode 100644 index 00000000..f6186cc2 --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/remote/KakaoLoginDataSource.kt @@ -0,0 +1,47 @@ +package com.example.data.datasource.remote + +import android.content.Context +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause +import com.kakao.sdk.user.UserApiClient +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class KakaoLoginDataSource + @Inject + constructor( + @ApplicationContext private val context: Context, + ) { + suspend fun getKakaoAccessToken(): String = + suspendCancellableCoroutine { continuation -> + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> + if (error != null) { + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { + continuation.resumeWithException(error) + return@loginWithKakaoTalk + } + loginWithWebView(continuation) + } else if (token != null) { + continuation.resume(token.accessToken) + } + } + } else { + loginWithWebView(continuation) + } + } + + private fun loginWithWebView(continuation: CancellableContinuation) { + UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> + if (error != null) { + continuation.resumeWithException(error) + } else if (token != null) { + continuation.resume(token.accessToken) + } + } + } + } diff --git a/core/data/src/main/java/com/example/data/datasource/remote/PostRemoteDataSource.kt b/core/data/src/main/java/com/example/data/datasource/remote/PostRemoteDataSource.kt new file mode 100644 index 00000000..3eea6e99 --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/remote/PostRemoteDataSource.kt @@ -0,0 +1,54 @@ +package com.example.data.datasource.remote + +import com.example.data.model.request.RegisterPostRequest +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.PostDetailResponse +import com.example.data.model.response.PostLikeResponse +import com.example.data.model.response.PostResponse +import com.example.data.model.response.TodayPostsResponse +import com.example.data.service.PostService +import javax.inject.Inject + +class PostRemoteDataSource + @Inject + constructor( + private val postService: PostService, + ) { + suspend fun getPostDetail(postId: Long): BaseResponse = postService.getPostDetail(postId = postId) + + suspend fun postPostLike( + postId: Long, + ): BaseResponse = postService.postPostLike(postId = postId) + + suspend fun deletePostLike( + postId: Long, + ): BaseResponse = postService.deletePostLike(postId = postId) + + suspend fun postPostScrap( + postId: Long, + ): BaseResponse = postService.postPostScrap(postId = postId) + + suspend fun deletePostScrap( + postId: Long, + ): BaseResponse = postService.deletePostScrap(postId = postId) + + suspend fun deletePost( + postId: Long, + ): BaseResponse = postService.deletePost(postId = postId) + + suspend fun registerPost( + request: RegisterPostRequest, + ): PostResponse { + try { + val response = + postService.registerPost( + request = request, + ) + return response.data ?: throw Exception("Data is null") + } catch (e: Exception) { + throw e + } + } + + suspend fun getTodayPosts(): BaseResponse = postService.getTodayPosts() + } diff --git a/core/data/src/main/java/com/example/data/datasource/remote/QuestionPostsPagingSource.kt b/core/data/src/main/java/com/example/data/datasource/remote/QuestionPostsPagingSource.kt new file mode 100644 index 00000000..69b95655 --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/remote/QuestionPostsPagingSource.kt @@ -0,0 +1,43 @@ +package com.example.data.datasource.remote + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.example.data.model.response.QuestionPostItemResponse +import com.example.data.service.PostService + +class QuestionPostsPagingSource( + private val postService: PostService, + private val questionId: Long, + private val onTotalCountFetched: (Int) -> Unit, + private val onLockedFetched: (Boolean) -> Unit, +) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult = + try { + val currentCursor = params.key + + val response = + postService.getPostsByQuestionId( + questionId = questionId, + cursor = currentCursor, + limit = params.loadSize, + ) + + val data = response.data ?: throw Exception("data is null") + if (params.key == null) { + onTotalCountFetched(data.totalCount) + onLockedFetched(data.locked) + } + val posts = data.items + val nextCursor = data.nextCursor + + LoadResult.Page( + data = posts, + prevKey = null, + nextKey = if (posts.isEmpty()) null else nextCursor, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } +} diff --git a/core/data/src/main/java/com/example/data/datasource/remote/QuestionRemoteDataSource.kt b/core/data/src/main/java/com/example/data/datasource/remote/QuestionRemoteDataSource.kt new file mode 100644 index 00000000..e9a56f75 --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/remote/QuestionRemoteDataSource.kt @@ -0,0 +1,24 @@ +package com.example.data.datasource.remote + +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.QuestionRecordResponse +import com.example.data.model.response.TodayQuestionResponse +import com.example.data.service.QuestionService +import javax.inject.Inject + +class QuestionRemoteDataSource + @Inject + constructor( + private val questionService: QuestionService, + ) { + suspend fun getQuestionRecord( + year: Int, + month: Int, + ): BaseResponse = + questionService.getQuestionRecord( + year = year, + month = month, + ) + + suspend fun getTodayQuestion(): BaseResponse = questionService.getTodayQuestion() + } diff --git a/core/data/src/main/java/com/example/data/datasource/remote/RegisteredTracksPagingSource.kt b/core/data/src/main/java/com/example/data/datasource/remote/RegisteredTracksPagingSource.kt new file mode 100644 index 00000000..4ed7a42c --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/remote/RegisteredTracksPagingSource.kt @@ -0,0 +1,43 @@ +package com.example.data.datasource.remote + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.example.data.model.response.RegisteredTrackResponse +import com.example.data.service.UserService +import kotlinx.serialization.InternalSerializationApi + +@OptIn(InternalSerializationApi::class) +class RegisteredTracksPagingSource( + private val userService: UserService, + private val userId: Long, + private val onTotalCountFetched: (Int) -> Unit, +) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult = + try { + val currentCursor = params.key + + val response = + userService.getRegisteredTracks( + userId = userId, + page = currentCursor, + size = params.loadSize, + ) + + val data = response.data ?: throw Exception("data is null") + if (params.key == null) { + onTotalCountFetched(data.totalCount) + } + val tracks = data.items + val nextCursor = data.nextCursor + + LoadResult.Page( + data = tracks, + prevKey = null, + nextKey = if (tracks.isEmpty()) null else nextCursor, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } +} diff --git a/core/data/src/main/java/com/example/data/datasource/remote/ScrappedTracksPagingSource.kt b/core/data/src/main/java/com/example/data/datasource/remote/ScrappedTracksPagingSource.kt new file mode 100644 index 00000000..6728d09b --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/remote/ScrappedTracksPagingSource.kt @@ -0,0 +1,41 @@ +package com.example.data.datasource.remote + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.example.data.model.response.ScrappedTrackResponse +import com.example.data.service.UserService +import kotlinx.serialization.InternalSerializationApi +import timber.log.Timber + +@OptIn(InternalSerializationApi::class) +class ScrappedTracksPagingSource( + private val userService: UserService, + private val userId: Long, +) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult = + try { + val currentCursor = params.key + + val response = + userService.getScrappedTracks( + userId = userId, + page = currentCursor, + size = params.loadSize, + ) + + val data = response.data ?: throw Exception("data is null") + val tracks = data.items + val nextCursor = data.nextCursor + + LoadResult.Page( + data = tracks, + prevKey = null, + nextKey = if (tracks.isEmpty()) null else nextCursor, + ) + } catch (e: Exception) { + Timber.e(e) + LoadResult.Error(e) + } +} diff --git a/core/data/src/main/java/com/example/data/datasource/remote/SearchedTracksPagingSource.kt b/core/data/src/main/java/com/example/data/datasource/remote/SearchedTracksPagingSource.kt new file mode 100644 index 00000000..ca4d446b --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/remote/SearchedTracksPagingSource.kt @@ -0,0 +1,40 @@ +package com.example.data.datasource.remote + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.example.data.model.response.TrackResponse +import com.example.data.service.TrackService +import kotlinx.serialization.InternalSerializationApi + +@OptIn(InternalSerializationApi::class) +class SearchedTracksPagingSource( + private val trackService: TrackService, + private val query: String, +) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult = + try { + val currentCursor = params.key + + val response = + trackService.searchTracks( + query = query, + limit = params.loadSize, + storefront = null, + cursor = currentCursor, + ) + + val data = response.data ?: throw Exception("data is null") + val tracks = data.items + val nextCursor = data.nextCursor + + LoadResult.Page( + data = tracks, + prevKey = null, + nextKey = if (tracks.isEmpty()) null else nextCursor, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } +} diff --git a/core/data/src/main/java/com/example/data/datasource/remote/TrackRemoteDataSource.kt b/core/data/src/main/java/com/example/data/datasource/remote/TrackRemoteDataSource.kt new file mode 100644 index 00000000..f7f0ff3e --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/remote/TrackRemoteDataSource.kt @@ -0,0 +1,36 @@ +package com.example.data.datasource.remote + +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.TrackPreviewResponse +import com.example.data.model.response.TrackResponse +import com.example.data.service.TrackService +import timber.log.Timber +import javax.inject.Inject + +class TrackRemoteDataSource + @Inject + constructor( + private val trackService: TrackService, + ) { + suspend fun getTrack( + trackId: String, + ): TrackResponse { + try { + val response = + trackService.getTrack( + trackId = trackId, + storefront = null, + ) + + return response.data ?: throw Exception("Data is null") + } catch (e: Exception) { + Timber.e(e, "getTrack 실패") + throw e + } + } + + suspend fun getTrackPreview( + trackId: String, + storefront: String? = null, + ): BaseResponse = trackService.getTrackPreview(trackId = trackId, storefront = storefront) + } diff --git a/core/data/src/main/java/com/example/data/datasource/remote/UserRemoteDataSource.kt b/core/data/src/main/java/com/example/data/datasource/remote/UserRemoteDataSource.kt new file mode 100644 index 00000000..bb9c45e5 --- /dev/null +++ b/core/data/src/main/java/com/example/data/datasource/remote/UserRemoteDataSource.kt @@ -0,0 +1,112 @@ +package com.example.data.datasource.remote + +import com.example.data.constant.ErrorCode +import com.example.data.model.request.NotificationRequest +import com.example.data.model.request.PatchProfileRequest +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.UserInfoResponse +import com.example.data.service.UserService +import com.example.network.NetworkException +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import retrofit2.HttpException +import timber.log.Timber +import java.io.File +import java.net.HttpURLConnection +import javax.inject.Inject + +@OptIn(InternalSerializationApi::class) +class UserRemoteDataSource + @Inject + constructor( + private val userService: UserService, + private val json: Json, + ) { + suspend fun patchProfile( + nickname: String?, + imageFile: File?, + ) { + try { + val imagePart = + if (imageFile != null) { + val requestFile = imageFile.asRequestBody("image/*".toMediaTypeOrNull()) + MultipartBody.Part.createFormData("profileImg", imageFile.name, requestFile) + } else { + null + } + + userService.patchProfile( + profileImg = imagePart, + request = + PatchProfileRequest( + nickname, + ), + ) + } catch (e: HttpException) { + if (e.code() == HttpURLConnection.HTTP_CONFLICT) { + val errorString = e.response()?.errorBody()?.string() + Timber.d("errorString : $errorString") + + if (errorString != null) { + try { + val errorResponse = json.decodeFromString>(errorString) + Timber.d("errorResponse : $errorResponse") + if (errorResponse.code == ErrorCode.DUPLICATED_NICKNAME) { + throw NetworkException(ErrorCode.DUPLICATED_NICKNAME, errorResponse.message) + } + } catch (e: SerializationException) { + // JSON 형식이 잘못됨 (괄호 누락 등) + } catch (e: IllegalArgumentException) { + // 데이터 타입 불일치 + } + } + } + throw e + } + } + + suspend fun getUser( + userId: Long, + ): UserInfoResponse { + try { + val response = + userService.getUser( + userId = userId, + ) + return response.data ?: throw Exception("Data is null") + } catch (e: Exception) { + Timber.e(e) + throw e + } + } + + suspend fun getNotificationEnabled(): Boolean { + try { + val response = userService.getNotificationEnabled() + return response.data?.pushOn ?: throw Exception("Data is null") + } catch (e: Exception) { + Timber.e(e) + throw e + } + } + + suspend fun postNotificationEnabled( + enabled: Boolean, + ) { + try { + userService.postNotificationEnabled( + request = + NotificationRequest( + pushOn = enabled, + ), + ) + } catch (e: Exception) { + Timber.e(e) + throw e + } + } + } diff --git a/core/data/src/main/java/com/example/data/di/DataStoreModule.kt b/core/data/src/main/java/com/example/data/di/DataStoreModule.kt new file mode 100644 index 00000000..a26fd054 --- /dev/null +++ b/core/data/src/main/java/com/example/data/di/DataStoreModule.kt @@ -0,0 +1,28 @@ +package com.example.data.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +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 DataStoreModule { + private const val USER_PREFERENCES = "user_preferences" + + @Provides + @Singleton + fun providePreferencesDataStore( + @ApplicationContext context: Context, + ): DataStore = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile(USER_PREFERENCES) }, + ) +} diff --git a/core/data/src/main/java/com/example/data/di/NetworkModule.kt b/core/data/src/main/java/com/example/data/di/NetworkModule.kt new file mode 100644 index 00000000..4c5b206b --- /dev/null +++ b/core/data/src/main/java/com/example/data/di/NetworkModule.kt @@ -0,0 +1,27 @@ +package com.example.data.di + +import com.example.data.networkimpl.AuthenticatorProviderImpl +import com.example.data.networkimpl.TokenManagerImpl +import com.example.network.AuthenticatorProvider +import com.example.network.TokenManager +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class NetworkModule { + @Binds + @Singleton + abstract fun bindTokenManager( + tokenManagerImpl: TokenManagerImpl, + ): TokenManager + + @Binds + @Singleton + abstract fun bindAuthenticatorProvider( + provider: AuthenticatorProviderImpl, + ): AuthenticatorProvider +} diff --git a/core/data/src/main/java/com/example/data/di/RepositoryModule.kt b/core/data/src/main/java/com/example/data/di/RepositoryModule.kt new file mode 100644 index 00000000..785f4b41 --- /dev/null +++ b/core/data/src/main/java/com/example/data/di/RepositoryModule.kt @@ -0,0 +1,47 @@ +package com.example.data.di + +import com.example.data.repository.AuthRepositoryImpl +import com.example.data.repository.DummyRepositoryImpl +import com.example.data.repository.PostRepositoryImpl +import com.example.data.repository.QuestionRepositoryImpl +import com.example.data.repository.TrackRepositoryImpl +import com.example.data.repository.UserRepositoryImpl +import com.example.domain.repository.AuthRepository +import com.example.domain.repository.DummyRepository +import com.example.domain.repository.PostRepository +import com.example.domain.repository.QuestionRepository +import com.example.domain.repository.TrackRepository +import com.example.domain.repository.UserRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + @Binds + @Singleton + abstract fun bindsDummyRepository(repositoryImpl: DummyRepositoryImpl): DummyRepository + + @Binds + @Singleton + abstract fun bindsAuthRepository(repositoryImpl: AuthRepositoryImpl): AuthRepository + + @Binds + @Singleton + abstract fun bindsUserRepository(repositoryImpl: UserRepositoryImpl): UserRepository + + @Binds + @Singleton + abstract fun bindsPostRepository(repositoryImpl: PostRepositoryImpl): PostRepository + + @Binds + @Singleton + abstract fun bindsQuestionRepository(repositoryImpl: QuestionRepositoryImpl): QuestionRepository + + @Binds + @Singleton + abstract fun bindsTrackRepository(repositoryImpl: TrackRepositoryImpl): TrackRepository +} diff --git a/core/data/src/main/java/com/example/data/di/ServiceModule.kt b/core/data/src/main/java/com/example/data/di/ServiceModule.kt new file mode 100644 index 00000000..1cd81718 --- /dev/null +++ b/core/data/src/main/java/com/example/data/di/ServiceModule.kt @@ -0,0 +1,54 @@ +package com.example.data.di + +import com.example.data.service.AuthService +import com.example.data.service.DummyService +import com.example.data.service.PostService +import com.example.data.service.QuestionService +import com.example.data.service.TrackService +import com.example.data.service.UserService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ServiceModule { + @Provides + @Singleton + fun provideDummyService( + retrofit: Retrofit, + ): DummyService = retrofit.create(DummyService::class.java) + + @Provides + @Singleton + fun provideAuthService( + retrofit: Retrofit, + ): AuthService = retrofit.create(AuthService::class.java) + + @Provides + @Singleton + fun provideUserService( + retrofit: Retrofit, + ): UserService = retrofit.create(UserService::class.java) + + @Provides + @Singleton + fun providePostService( + retrofit: Retrofit, + ): PostService = retrofit.create(PostService::class.java) + + @Provides + @Singleton + fun provideQuestionService( + retrofit: Retrofit, + ): QuestionService = retrofit.create(QuestionService::class.java) + + @Provides + @Singleton + fun provideTrackService( + retrofit: Retrofit, + ): TrackService = retrofit.create(TrackService::class.java) +} diff --git a/core/data/src/main/java/com/example/data/mapper/todomain/LikeResponseMapper.kt b/core/data/src/main/java/com/example/data/mapper/todomain/LikeResponseMapper.kt new file mode 100644 index 00000000..e1fbe8ce --- /dev/null +++ b/core/data/src/main/java/com/example/data/mapper/todomain/LikeResponseMapper.kt @@ -0,0 +1,10 @@ +package com.example.data.mapper.todomain + +import com.example.data.model.response.LikeResponse +import com.example.domain.model.Like + +fun LikeResponse.toDomain(): Like = + Like( + isLiked = this.isLiked, + count = this.count, + ) diff --git a/core/data/src/main/java/com/example/data/mapper/todomain/PostDetailResponseMapper.kt b/core/data/src/main/java/com/example/data/mapper/todomain/PostDetailResponseMapper.kt new file mode 100644 index 00000000..8291ed3e --- /dev/null +++ b/core/data/src/main/java/com/example/data/mapper/todomain/PostDetailResponseMapper.kt @@ -0,0 +1,16 @@ +package com.example.data.mapper.todomain + +import com.example.data.model.response.PostDetailResponse +import com.example.domain.model.PostDetail + +fun PostDetailResponse.toDomain(): PostDetail = + PostDetail( + postId = this.postId, + isHost = this.isHost, + isScrapped = this.isScrapped, + content = this.content, + date = this.displayDate, + track = this.track.toDomain(), + writer = this.user.toDomain(), + like = this.like.toDomain(), + ) diff --git a/core/data/src/main/java/com/example/data/mapper/todomain/QuestionRecordResponseMapper.kt b/core/data/src/main/java/com/example/data/mapper/todomain/QuestionRecordResponseMapper.kt new file mode 100644 index 00000000..f51f7bcf --- /dev/null +++ b/core/data/src/main/java/com/example/data/mapper/todomain/QuestionRecordResponseMapper.kt @@ -0,0 +1,16 @@ +package com.example.data.mapper.todomain + +import com.example.data.model.response.QuestionItemResponse +import com.example.domain.model.DailyQuestion + +fun QuestionItemResponse.toDomain( + year: Int, + month: Int, +): DailyQuestion = + DailyQuestion( + questionId = questionId, + title = title, + date = this.day, + year = year, + month = month, + ) diff --git a/core/data/src/main/java/com/example/data/mapper/todomain/TodayPosteResponseMapper.kt b/core/data/src/main/java/com/example/data/mapper/todomain/TodayPosteResponseMapper.kt new file mode 100644 index 00000000..2d196ffc --- /dev/null +++ b/core/data/src/main/java/com/example/data/mapper/todomain/TodayPosteResponseMapper.kt @@ -0,0 +1,44 @@ +package com.example.data.mapper.todomain + +import com.example.data.model.response.TodayPostItemResponse +import com.example.data.model.response.TodayPostTrackResponse +import com.example.data.model.response.TodayPostsResponse +import com.example.domain.model.Badge +import com.example.domain.model.DailyQuestion +import com.example.domain.model.FeedItem +import com.example.domain.model.HomeScreenData +import com.example.domain.model.Track + +fun TodayPostsResponse.toDomain(): HomeScreenData = + HomeScreenData( + todayQuestion = + DailyQuestion( + questionId = questionId, + title = title, + date = date, + ), + hasPosted = hasPosted, + locked = locked, + totalCount = totalCount, + todayPosts = items.map { it.toDomain() }, + ) + +fun TodayPostItemResponse.toDomain(): FeedItem = + FeedItem( + postId = postId, + isScrapped = isScrapped, + content = content, + badge = badge?.let { Badge.valueOf(it) }, + track = track.toDomain(), + writer = user.toDomain(), + like = like.toDomain(), + ) + +private fun TodayPostTrackResponse.toDomain(): Track = + Track( + trackId = trackId, + songTitle = songTitle, + coverImg = coverImg, + artistName = artistName, + isrc = "", + ) diff --git a/core/data/src/main/java/com/example/data/mapper/todomain/TrackPreviewResponseMapper.kt b/core/data/src/main/java/com/example/data/mapper/todomain/TrackPreviewResponseMapper.kt new file mode 100644 index 00000000..57e9f234 --- /dev/null +++ b/core/data/src/main/java/com/example/data/mapper/todomain/TrackPreviewResponseMapper.kt @@ -0,0 +1,12 @@ +package com.example.data.mapper.todomain + +import com.example.data.model.response.TrackPreviewResponse +import com.example.domain.model.TrackPreview + +fun TrackPreviewResponse.toDomain(): TrackPreview = + TrackPreview( + sessionId = this.sessionId, + trackId = this.trackId, + streamUrl = this.streamUrl, + expiresAt = this.expiresAt, + ) diff --git a/core/data/src/main/java/com/example/data/mapper/todomain/UserResponseMapper.kt b/core/data/src/main/java/com/example/data/mapper/todomain/UserResponseMapper.kt new file mode 100644 index 00000000..00eba51a --- /dev/null +++ b/core/data/src/main/java/com/example/data/mapper/todomain/UserResponseMapper.kt @@ -0,0 +1,12 @@ +package com.example.data.mapper.todomain + +import com.example.data.model.response.UserResponse +import com.example.domain.model.Writer + +fun UserResponse.toDomain(): Writer = + Writer( + userId = this.userId, + nickname = this.nickname, + profileImg = this.profileImg, + isAdmin = this.isAdmin, + ) diff --git a/core/data/src/main/java/com/example/data/model/request/LoginRequest.kt b/core/data/src/main/java/com/example/data/model/request/LoginRequest.kt new file mode 100644 index 00000000..5cb0a200 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/request/LoginRequest.kt @@ -0,0 +1,9 @@ +package com.example.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + @SerialName("platform") val platform: String, +) diff --git a/core/data/src/main/java/com/example/data/model/request/NotificationRequest.kt b/core/data/src/main/java/com/example/data/model/request/NotificationRequest.kt new file mode 100644 index 00000000..379e8869 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/request/NotificationRequest.kt @@ -0,0 +1,10 @@ +package com.example.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NotificationRequest( + @SerialName("pushOn") + val pushOn: Boolean, +) diff --git a/core/data/src/main/java/com/example/data/model/request/PatchProfileRequest.kt b/core/data/src/main/java/com/example/data/model/request/PatchProfileRequest.kt new file mode 100644 index 00000000..f8c25058 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/request/PatchProfileRequest.kt @@ -0,0 +1,8 @@ +package com.example.data.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class PatchProfileRequest( + val nickname: String?, +) diff --git a/core/data/src/main/java/com/example/data/model/request/RegisterPostRequest.kt b/core/data/src/main/java/com/example/data/model/request/RegisterPostRequest.kt new file mode 100644 index 00000000..f14e0451 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/request/RegisterPostRequest.kt @@ -0,0 +1,22 @@ +package com.example.data.model.request + +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@InternalSerializationApi +@Serializable +data class RegisterPostRequest( + @SerialName("trackId") + val trackId: String, + @SerialName("songTitle") + val songTitle: String, + @SerialName("artistName") + val artistName: String, + @SerialName("coverImg") + val coverImg: String, + @SerialName("isrc") + val isrc: String, + @SerialName("content") + val content: String, +) diff --git a/core/data/src/main/java/com/example/data/model/request/SignupRequest.kt b/core/data/src/main/java/com/example/data/model/request/SignupRequest.kt new file mode 100644 index 00000000..47f59b16 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/request/SignupRequest.kt @@ -0,0 +1,12 @@ +package com.example.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SignupRequest( + @SerialName("platform") + val platform: String, + @SerialName("nickname") + val nickname: String, +) diff --git a/core/data/src/main/java/com/example/data/model/response/BaseResponse.kt b/core/data/src/main/java/com/example/data/model/response/BaseResponse.kt new file mode 100644 index 00000000..bb8e3faf --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/BaseResponse.kt @@ -0,0 +1,16 @@ +package com.example.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BaseResponse( + @SerialName("status") + val status: Boolean, + @SerialName("code") + val code: Int, + @SerialName("message") + val message: String, + @SerialName("data") + val data: T? = null, +) diff --git a/core/data/src/main/java/com/example/data/model/response/DummyResponse.kt b/core/data/src/main/java/com/example/data/model/response/DummyResponse.kt new file mode 100644 index 00000000..243d45b6 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/DummyResponse.kt @@ -0,0 +1,9 @@ +package com.example.data.model.response + +import com.example.domain.model.Dummy + +data class DummyResponse( + val dummyName: String, +) { + fun toDummyEntity(): Dummy = Dummy(dummyName = dummyName) +} diff --git a/core/data/src/main/java/com/example/data/model/response/LikeResponse.kt b/core/data/src/main/java/com/example/data/model/response/LikeResponse.kt new file mode 100644 index 00000000..bd116524 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/LikeResponse.kt @@ -0,0 +1,12 @@ +package com.example.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LikeResponse( + @SerialName("isLiked") + val isLiked: Boolean, + @SerialName("count") + val count: Int, +) diff --git a/core/data/src/main/java/com/example/data/model/response/NotificationResponse.kt b/core/data/src/main/java/com/example/data/model/response/NotificationResponse.kt new file mode 100644 index 00000000..6be8cd5e --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/NotificationResponse.kt @@ -0,0 +1,10 @@ +package com.example.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NotificationResponse( + @SerialName("pushOn") + val pushOn: Boolean, +) diff --git a/core/data/src/main/java/com/example/data/model/response/PostDetailResponse.kt b/core/data/src/main/java/com/example/data/model/response/PostDetailResponse.kt new file mode 100644 index 00000000..24a0caed --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/PostDetailResponse.kt @@ -0,0 +1,24 @@ +package com.example.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PostDetailResponse( + @SerialName("postId") + val postId: Long, + @SerialName("isHost") + val isHost: Boolean, + @SerialName("isScrapped") + val isScrapped: Boolean, + @SerialName("content") + val content: String, + @SerialName("displayDate") + val displayDate: String, + @SerialName("track") + val track: TrackResponse, + @SerialName("user") + val user: UserResponse, + @SerialName("like") + val like: LikeResponse, +) diff --git a/core/data/src/main/java/com/example/data/model/response/PostLikeResponse.kt b/core/data/src/main/java/com/example/data/model/response/PostLikeResponse.kt new file mode 100644 index 00000000..644d94aa --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/PostLikeResponse.kt @@ -0,0 +1,10 @@ +package com.example.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PostLikeResponse( + @SerialName("likeCount") + val likeCount: Int, +) diff --git a/core/data/src/main/java/com/example/data/model/response/PostResponse.kt b/core/data/src/main/java/com/example/data/model/response/PostResponse.kt new file mode 100644 index 00000000..36f73674 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/PostResponse.kt @@ -0,0 +1,12 @@ +package com.example.data.model.response + +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@InternalSerializationApi +@Serializable +data class PostResponse( + @SerialName("postId") + val postId: Long, +) diff --git a/core/data/src/main/java/com/example/data/model/response/QuestionPostsResponse.kt b/core/data/src/main/java/com/example/data/model/response/QuestionPostsResponse.kt new file mode 100644 index 00000000..8049f968 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/QuestionPostsResponse.kt @@ -0,0 +1,77 @@ +package com.example.data.model.response + +import com.example.domain.model.Badge +import com.example.domain.model.FeedItem +import com.example.domain.model.Like +import com.example.domain.model.Track +import com.example.domain.model.Writer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class QuestionPostsResponse( + @SerialName("questionId") + val questionId: Long, + @SerialName("date") + val date: String, + @SerialName("title") + val title: String, + @SerialName("hasPosted") + val hasPosted: Boolean, + @SerialName("locked") + val locked: Boolean, + @SerialName("visibleLimit") + val visibleLimit: Int, + @SerialName("totalCount") + val totalCount: Int, + @SerialName("nextCursor") + val nextCursor: String?, + @SerialName("items") + val items: List, +) + +@Serializable +data class QuestionPostItemResponse( + @SerialName("postId") + val postId: Long, + @SerialName("isEditorPick") + val isEditorPick: Boolean, + @SerialName("isScrapped") + val isScrapped: Boolean, + @SerialName("content") + val content: String, + @SerialName("track") + val track: TodayPostTrackResponse, + @SerialName("user") + val user: UserResponse, + @SerialName("like") + val like: LikeResponse, +) { + fun toDomain(): FeedItem = + FeedItem( + postId = postId, + isScrapped = isScrapped, + content = content, + badge = if (isEditorPick) Badge.EDITOR else null, + track = + Track( + trackId = track.trackId, + songTitle = track.songTitle, + coverImg = track.coverImg, + artistName = track.artistName, + isrc = "", + ), + writer = + Writer( + userId = user.userId, + nickname = user.nickname, + profileImg = user.profileImg.orEmpty(), + isAdmin = user.isAdmin, + ), + like = + Like( + isLiked = like.isLiked, + count = like.count, + ), + ) +} diff --git a/core/data/src/main/java/com/example/data/model/response/QustionRecordResponse.kt b/core/data/src/main/java/com/example/data/model/response/QustionRecordResponse.kt new file mode 100644 index 00000000..c56ad8b4 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/QustionRecordResponse.kt @@ -0,0 +1,20 @@ +package com.example.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class QuestionRecordResponse( + @SerialName("questions") + val questions: List, +) + +@Serializable +data class QuestionItemResponse( + @SerialName("day") + val day: String, + @SerialName("questionId") + val questionId: Long, + @SerialName("title") + val title: String, +) diff --git a/core/data/src/main/java/com/example/data/model/response/RegisteredTracksResponse.kt b/core/data/src/main/java/com/example/data/model/response/RegisteredTracksResponse.kt new file mode 100644 index 00000000..4e7b7ef0 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/RegisteredTracksResponse.kt @@ -0,0 +1,36 @@ +package com.example.data.model.response + +import com.example.domain.model.RegisteredTrack +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RegisteredTracksResponse( + @SerialName("visibleLimit") + val visibleLimit: Int, + @SerialName("totalCount") + val totalCount: Int, + @SerialName("nextCursor") + val nextCursor: String?, + @SerialName("items") + val items: List, + @SerialName("isHost") + val isHost: Boolean, +) + +@Serializable +data class RegisteredTrackResponse( + @SerialName("postId") + val postId: Long, + @SerialName("track") + val track: TrackResponse, + @SerialName("content") + val content: String, +) { + fun toDomain() = + RegisteredTrack( + postId = postId, + track = track.toDomain(), + comment = content, + ) +} diff --git a/core/data/src/main/java/com/example/data/model/response/ScrappedTracksResponse.kt b/core/data/src/main/java/com/example/data/model/response/ScrappedTracksResponse.kt new file mode 100644 index 00000000..a1364eaf --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/ScrappedTracksResponse.kt @@ -0,0 +1,31 @@ +package com.example.data.model.response + +import com.example.domain.model.ScrappedTrack +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ScrappedTracksResponse( + @SerialName("visibleLimit") + val visibleLimit: Int, + @SerialName("totalCount") + val totalCount: Int, + @SerialName("nextCursor") + val nextCursor: String?, + @SerialName("items") + val items: List, +) + +@Serializable +data class ScrappedTrackResponse( + @SerialName("postId") + val postId: Long, + @SerialName("track") + val track: TrackResponse, +) { + fun toDomain() = + ScrappedTrack( + postId = postId, + track = track.toDomain(), + ) +} diff --git a/core/data/src/main/java/com/example/data/model/response/SearchTrackResponse.kt b/core/data/src/main/java/com/example/data/model/response/SearchTrackResponse.kt new file mode 100644 index 00000000..18cd6841 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/SearchTrackResponse.kt @@ -0,0 +1,20 @@ +package com.example.data.model.response + +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@OptIn(InternalSerializationApi::class) +@Serializable +data class SearchTrackResponse( + @SerialName("query") + val query: String, + @SerialName("storefront") + val storefront: String, + @SerialName("limit") + val limit: Int, + @SerialName("nextCursor") + val nextCursor: String?, + @SerialName("items") + val items: List, +) diff --git a/core/data/src/main/java/com/example/data/model/response/TodayPostsResponse.kt b/core/data/src/main/java/com/example/data/model/response/TodayPostsResponse.kt new file mode 100644 index 00000000..f64812a9 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/TodayPostsResponse.kt @@ -0,0 +1,34 @@ +package com.example.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TodayPostsResponse( + @SerialName("questionId") val questionId: Long, + @SerialName("date") val date: String, + @SerialName("title") val title: String, + @SerialName("hasPosted") val hasPosted: Boolean, + @SerialName("locked") val locked: Boolean, + @SerialName("totalCount") val totalCount: Int, + @SerialName("items") val items: List, +) + +@Serializable +data class TodayPostItemResponse( + @SerialName("postId") val postId: Long, + @SerialName("isScrapped") val isScrapped: Boolean, + @SerialName("content") val content: String, + @SerialName("badge") val badge: String?, + @SerialName("track") val track: TodayPostTrackResponse, + @SerialName("user") val user: UserResponse, + @SerialName("like") val like: LikeResponse, +) + +@Serializable +data class TodayPostTrackResponse( + @SerialName("trackId") val trackId: String, + @SerialName("songTitle") val songTitle: String, + @SerialName("coverImg") val coverImg: String, + @SerialName("artistName") val artistName: String, +) diff --git a/core/data/src/main/java/com/example/data/model/response/TodayQuestionResponse.kt b/core/data/src/main/java/com/example/data/model/response/TodayQuestionResponse.kt new file mode 100644 index 00000000..aa5eef4c --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/TodayQuestionResponse.kt @@ -0,0 +1,19 @@ +package com.example.data.model.response + +import com.example.domain.model.DailyQuestion +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TodayQuestionResponse( + @SerialName("questionId") val questionId: Long, + @SerialName("date") val date: String, + @SerialName("title") val title: String, +) { + fun toEntity(): DailyQuestion = + DailyQuestion( + questionId = questionId, + title = title, + date = date, + ) +} diff --git a/core/data/src/main/java/com/example/data/model/response/TokenResponse.kt b/core/data/src/main/java/com/example/data/model/response/TokenResponse.kt new file mode 100644 index 00000000..63ddd150 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/TokenResponse.kt @@ -0,0 +1,11 @@ +package com.example.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TokenResponse( + @SerialName("accessToken") val accessToken: String, + @SerialName("refreshToken") val refreshToken: String, + @SerialName("userId") val userId: Long, +) diff --git a/core/data/src/main/java/com/example/data/model/response/TrackPreviewResponse.kt b/core/data/src/main/java/com/example/data/model/response/TrackPreviewResponse.kt new file mode 100644 index 00000000..b7635cba --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/TrackPreviewResponse.kt @@ -0,0 +1,16 @@ +package com.example.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TrackPreviewResponse( + @SerialName("sessionId") + val sessionId: String, + @SerialName("trackId") + val trackId: String, + @SerialName("streamUrl") + val streamUrl: String, + @SerialName("expiresAt") + val expiresAt: String? = null, +) diff --git a/core/data/src/main/java/com/example/data/model/response/TrackResponse.kt b/core/data/src/main/java/com/example/data/model/response/TrackResponse.kt new file mode 100644 index 00000000..7e74ccb5 --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/TrackResponse.kt @@ -0,0 +1,28 @@ +package com.example.data.model.response + +import com.example.domain.model.Track +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TrackResponse( + @SerialName("trackId") + val trackId: String, + @SerialName("songTitle") + val songTitle: String, + @SerialName("artistName") + val artistName: String, + @SerialName("coverImg") + val coverImg: String, + @SerialName("isrc") + val isrc: String? = null, +) { + fun toDomain() = + Track( + trackId = trackId, + songTitle = songTitle, + artistName = artistName, + coverImg = coverImg, + isrc = isrc ?: "", + ) +} diff --git a/core/data/src/main/java/com/example/data/model/response/UserResponse.kt b/core/data/src/main/java/com/example/data/model/response/UserResponse.kt new file mode 100644 index 00000000..9ba74fbf --- /dev/null +++ b/core/data/src/main/java/com/example/data/model/response/UserResponse.kt @@ -0,0 +1,28 @@ +package com.example.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserInfoResponse( + @SerialName("user") + val user: UserResponse, + @SerialName("isHost") + val isHost: Boolean, + @SerialName("pushOn") + val pushOn: Boolean, + @SerialName("postTotalCount") + val postTotalCount: Long, +) + +@Serializable +data class UserResponse( + @SerialName("userId") + val userId: Long, + @SerialName("nickname") + val nickname: String, + @SerialName("profileImg") + val profileImg: String?, + @SerialName("isAdmin") + val isAdmin: Boolean, +) diff --git a/core/data/src/main/java/com/example/data/networkimpl/AuthenticatorProviderImpl.kt b/core/data/src/main/java/com/example/data/networkimpl/AuthenticatorProviderImpl.kt new file mode 100644 index 00000000..dc2bce03 --- /dev/null +++ b/core/data/src/main/java/com/example/data/networkimpl/AuthenticatorProviderImpl.kt @@ -0,0 +1,15 @@ +package com.example.data.networkimpl + +import com.example.data.TokenAuthenticator +import com.example.network.AuthenticatorProvider +import okhttp3.Authenticator +import javax.inject.Inject +import javax.inject.Provider + +class AuthenticatorProviderImpl + @Inject + constructor( + private val tokenAuthenticator: Provider, + ) : AuthenticatorProvider { + override fun get(): Authenticator = tokenAuthenticator.get() + } diff --git a/core/data/src/main/java/com/example/data/networkimpl/TokenManagerImpl.kt b/core/data/src/main/java/com/example/data/networkimpl/TokenManagerImpl.kt new file mode 100644 index 00000000..38c9dcd6 --- /dev/null +++ b/core/data/src/main/java/com/example/data/networkimpl/TokenManagerImpl.kt @@ -0,0 +1,31 @@ +package com.example.data.networkimpl + +import com.example.data.datasource.local.TokenLocalDataSource +import com.example.network.TokenManager +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class TokenManagerImpl + @Inject + constructor( + private val tokenLocalDataSource: TokenLocalDataSource, + ) : TokenManager { + override suspend fun getAccessToken(): String? = tokenLocalDataSource.accessToken.first() + + override suspend fun getRefreshToken(): String? = tokenLocalDataSource.refreshToken.first() + + override suspend fun updateAccessToken(newAccessToken: String) { + tokenLocalDataSource.updateAccessToken(newAccessToken) + } + + override suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) { + tokenLocalDataSource.saveTokens(accessToken, refreshToken) + } + + override suspend fun clearAllTokens() { + tokenLocalDataSource.clearTokens() + } + } diff --git a/core/data/src/main/java/com/example/data/repository/AuthRepositoryImpl.kt b/core/data/src/main/java/com/example/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 00000000..aa0f253f --- /dev/null +++ b/core/data/src/main/java/com/example/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,118 @@ +package com.example.data.repository + +import com.example.data.constant.ApiConstants.KAKAO_PLATFORM +import com.example.data.constant.ErrorCode +import com.example.data.datasource.local.FileLocalDataSource +import com.example.data.datasource.local.TokenLocalDataSource +import com.example.data.datasource.local.UserLocalDataSource +import com.example.data.datasource.remote.AuthRemoteDataSource +import com.example.data.datasource.remote.KakaoLoginDataSource +import com.example.data.datasource.remote.UserRemoteDataSource +import com.example.data.model.request.LoginRequest +import com.example.data.model.request.SignupRequest +import com.example.domain.model.NicknameValidationResult +import com.example.domain.model.User +import com.example.domain.repository.AuthRepository +import com.example.network.NetworkException +import javax.inject.Inject + +class AuthRepositoryImpl + @Inject + constructor( + private val kakaoDataSource: KakaoLoginDataSource, + private val authRemoteDataSource: AuthRemoteDataSource, + private val tokenLocalDataSource: TokenLocalDataSource, + private val fileLocalDataSource: FileLocalDataSource, + private val userLocalDataSource: UserLocalDataSource, + private val userRemoteDataSource: UserRemoteDataSource, + ) : AuthRepository { + override suspend fun kakaoLogin(): Result = + runCatching { + val kakaoToken = kakaoDataSource.getKakaoAccessToken() + + val tokenData = + try { + authRemoteDataSource.login(kakaoToken, LoginRequest(KAKAO_PLATFORM)) + } catch (e: NetworkException) { + if (e.code == ErrorCode.USER_NOT_FOUND) { + return Result.success(kakaoToken) + } + throw e + } + + tokenLocalDataSource.saveTokens( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken, + ) + + val userInfo = + userRemoteDataSource.getUser( + userId = tokenData.userId, + ) + + userLocalDataSource.saveUser( + User( + id = tokenData.userId, + nickname = userInfo.user.nickname, + profileImagePath = userInfo.user.profileImg, + ), + ) + + return Result.success("") + } + + override suspend fun signupWithKakao( + kakaoAccessToken: String?, + profileImage: String?, + nickname: String, + ): Result = + runCatching { + val profileFile = fileLocalDataSource.createAndGetFile(profileImage) + + val response = + try { + authRemoteDataSource.signup( + kakaoAccessToken = kakaoAccessToken, + imageFile = profileFile, + signupRequest = + SignupRequest( + platform = KAKAO_PLATFORM, + nickname = nickname, + ), + ) + } catch (e: NetworkException) { + if (e.code == ErrorCode.DUPLICATED_NICKNAME) { + return Result.success(NicknameValidationResult.Error.Duplicated) + } + throw e + } + tokenLocalDataSource.saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + ) + + userLocalDataSource.saveUser( + User( + id = response.userId, + nickname = nickname, + profileImagePath = profileFile?.absolutePath, + ), + ) + + return Result.success(NicknameValidationResult.Success) + } + + override suspend fun withdraw(): Result = + runCatching { + authRemoteDataSource.withdraw() + tokenLocalDataSource.clearTokens() + userLocalDataSource.clearUser() + } + + override suspend fun logout(): Result = + runCatching { + authRemoteDataSource.logout() + tokenLocalDataSource.clearTokens() + userLocalDataSource.clearUser() + } + } diff --git a/core/data/src/main/java/com/example/data/repository/DummyRepositoryImpl.kt b/core/data/src/main/java/com/example/data/repository/DummyRepositoryImpl.kt new file mode 100644 index 00000000..a0c7bdfa --- /dev/null +++ b/core/data/src/main/java/com/example/data/repository/DummyRepositoryImpl.kt @@ -0,0 +1,17 @@ +package com.example.data.repository + +import com.example.data.datasource.remote.DummyRemoteDataSource +import com.example.domain.model.Dummy +import com.example.domain.repository.DummyRepository +import javax.inject.Inject + +class DummyRepositoryImpl + @Inject + constructor( + private val dummyRemoteDataSource: DummyRemoteDataSource, + ) : DummyRepository { + override suspend fun getDummy(dummyId: Long): Result = + runCatching { + dummyRemoteDataSource.getDummy(dummyId = dummyId).data?.toDummyEntity() ?: throw Exception() + } + } diff --git a/core/data/src/main/java/com/example/data/repository/PostRepositoryImpl.kt b/core/data/src/main/java/com/example/data/repository/PostRepositoryImpl.kt new file mode 100644 index 00000000..ec56595c --- /dev/null +++ b/core/data/src/main/java/com/example/data/repository/PostRepositoryImpl.kt @@ -0,0 +1,117 @@ +package com.example.data.repository + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +import com.example.data.datasource.remote.PostRemoteDataSource +import com.example.data.datasource.remote.QuestionPostsPagingSource +import com.example.data.mapper.todomain.toDomain +import com.example.data.model.request.RegisterPostRequest +import com.example.data.service.PostService +import com.example.domain.model.FeedItem +import com.example.domain.model.HomeScreenData +import com.example.domain.model.PostDetail +import com.example.domain.model.Track +import com.example.domain.repository.PostRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class PostRepositoryImpl + @Inject + constructor( + private val postRemoteDataSource: PostRemoteDataSource, + private val postService: PostService, + ) : PostRepository { + override suspend fun getPostDetail(postId: Long): Result = + runCatching { + postRemoteDataSource.getPostDetail(postId = postId).data?.toDomain() ?: throw Exception() + } + + override suspend fun registerPost( + track: Track, + comment: String, + ): Result = + runCatching { + postRemoteDataSource.registerPost( + request = + RegisterPostRequest( + trackId = track.trackId, + songTitle = track.songTitle, + artistName = track.artistName, + coverImg = track.coverImg, + isrc = track.isrc, + content = comment, + ), + ) + } + + override suspend fun postPostLike(postId: Long): Result = + runCatching { + postRemoteDataSource + .postPostLike(postId = postId) + .data + ?.likeCount + ?: error("likeCount is null") + } + + override suspend fun deletePostLike(postId: Long): Result = + runCatching { + postRemoteDataSource + .deletePostLike(postId = postId) + .data + ?.likeCount + ?: error("likeCount is null") + } + + override suspend fun postPostScrap(postId: Long): Result = + runCatching { + postRemoteDataSource.postPostScrap(postId = postId) + } + + override suspend fun deletePostScrap(postId: Long): Result = + runCatching { + postRemoteDataSource.deletePostScrap(postId = postId) + } + + override suspend fun deletePost(postId: Long): Result = + runCatching { + postRemoteDataSource.deletePost(postId = postId) + } + + override suspend fun getTodayPosts(): Result = + runCatching { + postRemoteDataSource.getTodayPosts().data?.toDomain() ?: throw Exception() + } + + override fun getPostsByQuestionId( + questionId: Long, + onTotalCountFetched: (Int) -> Unit, + onLockedFetched: (Boolean) -> Unit, + ): Flow> = + Pager( + config = + PagingConfig( + pageSize = QUESTION_POSTS_LOAD_SIZE, + initialLoadSize = QUESTION_POSTS_LOAD_SIZE, + enablePlaceholders = false, + ), + pagingSourceFactory = { + QuestionPostsPagingSource( + postService = postService, + questionId = questionId, + onTotalCountFetched = onTotalCountFetched, + onLockedFetched = onLockedFetched, + ) + }, + ).flow.map { pagingData -> + pagingData.map { post -> + post.toDomain() + } + } + + companion object { + const val QUESTION_POSTS_LOAD_SIZE = 20 + } + } diff --git a/core/data/src/main/java/com/example/data/repository/QuestionRepositoryImpl.kt b/core/data/src/main/java/com/example/data/repository/QuestionRepositoryImpl.kt new file mode 100644 index 00000000..f32febcb --- /dev/null +++ b/core/data/src/main/java/com/example/data/repository/QuestionRepositoryImpl.kt @@ -0,0 +1,48 @@ +package com.example.data.repository + +import com.example.data.datasource.remote.QuestionRemoteDataSource +import com.example.data.mapper.todomain.toDomain +import com.example.domain.model.DailyQuestion +import com.example.domain.model.QuestionError +import com.example.domain.repository.QuestionRepository +import javax.inject.Inject + +class QuestionRepositoryImpl + @Inject + constructor( + private val questionRemoteDataSource: QuestionRemoteDataSource, + ) : QuestionRepository { + override suspend fun getQuestionRecord( + year: Int, + month: Int, + ): Result> = + runCatching { + val response = + questionRemoteDataSource + .getQuestionRecord(year, month) + .data + ?: throw QuestionError.Unknown + + response.questions.map { it.toDomain(year, month) } + }.recoverCatching { e -> + if (e is retrofit2.HttpException) { + throw when (e.code()) { + 404 -> QuestionError.NotFound + else -> QuestionError.Unknown + } + } else { + throw e + } + } + + override suspend fun getTodayQuestion(): Result = + runCatching { + val data = + questionRemoteDataSource + .getTodayQuestion() + .data + ?: throw IllegalStateException("TodayQuestion data is null") + + data.toEntity() + } + } diff --git a/core/data/src/main/java/com/example/data/repository/TrackRepositoryImpl.kt b/core/data/src/main/java/com/example/data/repository/TrackRepositoryImpl.kt new file mode 100644 index 00000000..32811f45 --- /dev/null +++ b/core/data/src/main/java/com/example/data/repository/TrackRepositoryImpl.kt @@ -0,0 +1,59 @@ +package com.example.data.repository + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +import com.example.data.datasource.remote.SearchedTracksPagingSource +import com.example.data.datasource.remote.TrackRemoteDataSource +import com.example.data.mapper.todomain.toDomain +import com.example.data.service.TrackService +import com.example.domain.model.Track +import com.example.domain.model.TrackPreview +import com.example.domain.repository.TrackRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class TrackRepositoryImpl + @Inject + constructor( + private val trackService: TrackService, + private val trackRemoteDataSource: TrackRemoteDataSource, + ) : TrackRepository { + override fun searchTracks(query: String): Flow> = + Pager( + config = + PagingConfig( + pageSize = LOAD_SIZE, + initialLoadSize = LOAD_SIZE, + enablePlaceholders = false, + ), + pagingSourceFactory = { SearchedTracksPagingSource(trackService, query) }, + ).flow.map { pagingData -> + pagingData.map { track -> + track.toDomain() + } + } + + override suspend fun getTrack(trackId: String): Result = + runCatching { + trackRemoteDataSource.getTrack(trackId = trackId).toDomain() + } + + override suspend fun getTrackPreview( + trackId: String, + storefront: String?, + ): Result = + runCatching { + trackRemoteDataSource + .getTrackPreview(trackId = trackId, storefront = storefront) + .data + ?.toDomain() + ?: error("TrackPreview data is null") + } + + companion object { + const val LOAD_SIZE = 20 + } + } diff --git a/core/data/src/main/java/com/example/data/repository/UserRepositoryImpl.kt b/core/data/src/main/java/com/example/data/repository/UserRepositoryImpl.kt new file mode 100644 index 00000000..361ad18d --- /dev/null +++ b/core/data/src/main/java/com/example/data/repository/UserRepositoryImpl.kt @@ -0,0 +1,164 @@ +package com.example.data.repository + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +import com.example.data.constant.ErrorCode +import com.example.data.datasource.local.FileLocalDataSource +import com.example.data.datasource.local.TokenLocalDataSource +import com.example.data.datasource.local.UserLocalDataSource +import com.example.data.datasource.remote.RegisteredTracksPagingSource +import com.example.data.datasource.remote.ScrappedTracksPagingSource +import com.example.data.datasource.remote.UserRemoteDataSource +import com.example.data.mapper.todomain.toDomain +import com.example.data.service.UserService +import com.example.domain.model.NicknameValidationResult +import com.example.domain.model.ProfileImageState +import com.example.domain.model.RegisteredTrack +import com.example.domain.model.ScrappedTrack +import com.example.domain.model.User +import com.example.domain.repository.UserRepository +import com.example.network.NetworkException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class UserRepositoryImpl + @Inject + constructor( + private val userLocalDataSource: UserLocalDataSource, + private val tokenLocalDataSource: TokenLocalDataSource, + private val userRemoteDataSource: UserRemoteDataSource, + private val fileLocalDataSource: FileLocalDataSource, + private val userService: UserService, + ) : UserRepository { + override fun getUser(): Flow = + userLocalDataSource.userFlow.map { user -> + user?.let { validUser -> + if (validUser.profileImagePath?.isEmpty() == true) { + validUser.copy(profileImagePath = null) + } else { + validUser + } + } + } + + override suspend fun getUser(userId: Long): Result = + runCatching { + val userData = userRemoteDataSource.getUser(userId = userId).user + + // 추후 Writer와 통합 + User( + id = userData.userId, + nickname = userData.nickname, + profileImagePath = userData.profileImg, + ) + } + + override fun getAccessToken(): Flow = tokenLocalDataSource.accessToken + + override fun getRefreshToken(): Flow = tokenLocalDataSource.refreshToken + + override suspend fun getNotificationEnabled(): Result = + runCatching { + userRemoteDataSource.getNotificationEnabled() + } + + override suspend fun updateNotificationEnabled(enabled: Boolean): Result = + runCatching { + userRemoteDataSource.postNotificationEnabled(enabled) + } + + override suspend fun updateProfile( + nickname: String?, + profileImageState: ProfileImageState, + ): Result = + runCatching { + val profileFile = + when (profileImageState) { + is ProfileImageState.Keep -> null + is ProfileImageState.Delete -> fileLocalDataSource.createEmptyFile() + is ProfileImageState.Update -> fileLocalDataSource.createAndGetFile(profileImageState.imagePath) + } + + try { + userRemoteDataSource.patchProfile( + imageFile = profileFile, + nickname = nickname, + ) + } catch (e: NetworkException) { + if (e.code == ErrorCode.DUPLICATED_NICKNAME) { + return Result.success(NicknameValidationResult.Error.Duplicated) + } + throw e + } + + userLocalDataSource.updateNickname(nickname.orEmpty()) + + when (profileImageState) { + ProfileImageState.Delete -> { + userLocalDataSource.removeProfileImage() + } + is ProfileImageState.Update -> { + userLocalDataSource.updateProfileImage( + profileImagePath = profileFile?.absolutePath ?: "", + ) + } + ProfileImageState.Keep -> {} + } + + return Result.success(NicknameValidationResult.Success) + } + + override fun getRegisteredTracks( + userId: Long, + onTotalCountFetched: (Int) -> Unit, + ): Flow> = + Pager( + config = + PagingConfig( + pageSize = REGISTERED_TRACK_LOAD_SIZE, + initialLoadSize = REGISTERED_TRACK_LOAD_SIZE, + enablePlaceholders = false, + ), + pagingSourceFactory = { + RegisteredTracksPagingSource( + userService = userService, + userId = userId, + onTotalCountFetched = onTotalCountFetched, + ) + }, + ).flow.map { pagingData -> + pagingData.map { track -> + track.toDomain() + } + } + + override fun getScrappedTracks( + userId: Long, + ): Flow> = + Pager( + config = + PagingConfig( + pageSize = SCRAPPED_TRACK_LOAD_SIZE, + initialLoadSize = SCRAPPED_TRACK_LOAD_SIZE, + enablePlaceholders = false, + ), + pagingSourceFactory = { + ScrappedTracksPagingSource( + userService = userService, + userId = userId, + ) + }, + ).flow.map { pagingData -> + pagingData.map { track -> + track.toDomain() + } + } + + companion object { + const val SCRAPPED_TRACK_LOAD_SIZE = 20 + const val REGISTERED_TRACK_LOAD_SIZE = 20 + } + } diff --git a/core/data/src/main/java/com/example/data/service/AuthService.kt b/core/data/src/main/java/com/example/data/service/AuthService.kt new file mode 100644 index 00000000..ecd1fc1f --- /dev/null +++ b/core/data/src/main/java/com/example/data/service/AuthService.kt @@ -0,0 +1,50 @@ +package com.example.data.service + +import com.example.data.constant.ApiConstants.API +import com.example.data.constant.ApiConstants.AUTH +import com.example.data.constant.ApiConstants.LOGIN +import com.example.data.constant.ApiConstants.LOGOUT +import com.example.data.constant.ApiConstants.REISSUE +import com.example.data.constant.ApiConstants.SIGNUP +import com.example.data.constant.ApiConstants.TOKEN +import com.example.data.constant.ApiConstants.VERSIONS +import com.example.data.constant.ApiConstants.WITHDRAW +import com.example.data.model.request.LoginRequest +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.TokenResponse +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Part + +interface AuthService { + @POST("$API/$VERSIONS/$AUTH/$LOGIN") + suspend fun login( + @Header("Authorization") accessToken: String, + @Body request: LoginRequest, + ): BaseResponse + + @Multipart + @POST("$API/$VERSIONS/$AUTH/$SIGNUP") + suspend fun signup( + @Header("Authorization") accessToken: String, + @Part profileImg: MultipartBody.Part?, + @Part("signupRequest") request: RequestBody, + ): BaseResponse + + @PATCH("$API/$VERSIONS/$AUTH/$TOKEN/$REISSUE") + suspend fun reissue( + @Header("Authorization") refreshToken: String, + ): BaseResponse + + @DELETE("$API/$VERSIONS/$AUTH/$LOGOUT") + suspend fun logout(): BaseResponse + + @DELETE("$API/$VERSIONS/$AUTH/$WITHDRAW") + suspend fun withdraw(): BaseResponse +} diff --git a/core/data/src/main/java/com/example/data/service/DummyService.kt b/core/data/src/main/java/com/example/data/service/DummyService.kt new file mode 100644 index 00000000..b4b0afc0 --- /dev/null +++ b/core/data/src/main/java/com/example/data/service/DummyService.kt @@ -0,0 +1,14 @@ +package com.example.data.service + +import com.example.data.constant.ApiConstants +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.DummyResponse +import retrofit2.http.GET +import retrofit2.http.Path + +interface DummyService { + @GET("${ApiConstants.API}/${ApiConstants.VERSIONS}/dummy/{dummyId}") + suspend fun getDummy( + @Path("dummyId") dummyId: Long, + ): BaseResponse +} diff --git a/core/data/src/main/java/com/example/data/service/PostService.kt b/core/data/src/main/java/com/example/data/service/PostService.kt new file mode 100644 index 00000000..2f30283e --- /dev/null +++ b/core/data/src/main/java/com/example/data/service/PostService.kt @@ -0,0 +1,65 @@ +package com.example.data.service + +import com.example.data.constant.ApiConstants.API +import com.example.data.constant.ApiConstants.POSTS +import com.example.data.constant.ApiConstants.VERSIONS +import com.example.data.model.request.RegisterPostRequest +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.PostDetailResponse +import com.example.data.model.response.PostLikeResponse +import com.example.data.model.response.PostResponse +import com.example.data.model.response.QuestionPostsResponse +import com.example.data.model.response.TodayPostsResponse +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface PostService { + @POST("$API/$VERSIONS/$POSTS") + suspend fun registerPost( + @Body request: RegisterPostRequest, + ): BaseResponse + + @GET("$API/$VERSIONS/$POSTS/detail/{postId}") + suspend fun getPostDetail( + @Path("postId") postId: Long, + ): BaseResponse + + @POST("$API/$VERSIONS/$POSTS/{postId}/likes") + suspend fun postPostLike( + @Path("postId") postId: Long, + ): BaseResponse + + @DELETE("$API/$VERSIONS/$POSTS/{postId}/likes") + suspend fun deletePostLike( + @Path("postId") postId: Long, + ): BaseResponse + + @POST("$API/$VERSIONS/$POSTS/{postId}/scraps") + suspend fun postPostScrap( + @Path("postId") postId: Long, + ): BaseResponse + + @DELETE("$API/$VERSIONS/$POSTS/{postId}/scraps") + suspend fun deletePostScrap( + @Path("postId") postId: Long, + ): BaseResponse + + @DELETE("$API/$VERSIONS/$POSTS/{postId}") + suspend fun deletePost( + @Path("postId") postId: Long, + ): BaseResponse + + @GET("$API/$VERSIONS/$POSTS/today") + suspend fun getTodayPosts(): BaseResponse + + @GET("$API/$VERSIONS/$POSTS/{questionId}") + suspend fun getPostsByQuestionId( + @Path("questionId") questionId: Long, + @Query("cursor") cursor: String?, + @Query("limit") limit: Int, + ): BaseResponse +} diff --git a/core/data/src/main/java/com/example/data/service/QuestionService.kt b/core/data/src/main/java/com/example/data/service/QuestionService.kt new file mode 100644 index 00000000..f9e631e9 --- /dev/null +++ b/core/data/src/main/java/com/example/data/service/QuestionService.kt @@ -0,0 +1,21 @@ +package com.example.data.service + +import com.example.data.constant.ApiConstants.API +import com.example.data.constant.ApiConstants.QUESTIONS +import com.example.data.constant.ApiConstants.VERSIONS +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.QuestionRecordResponse +import com.example.data.model.response.TodayQuestionResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface QuestionService { + @GET("$API/$VERSIONS/$QUESTIONS") + suspend fun getQuestionRecord( + @Query("year") year: Int, + @Query("month") month: Int, + ): BaseResponse + + @GET("$API/$VERSIONS/$QUESTIONS/today") + suspend fun getTodayQuestion(): BaseResponse +} diff --git a/core/data/src/main/java/com/example/data/service/TrackService.kt b/core/data/src/main/java/com/example/data/service/TrackService.kt new file mode 100644 index 00000000..3ec1483c --- /dev/null +++ b/core/data/src/main/java/com/example/data/service/TrackService.kt @@ -0,0 +1,34 @@ +package com.example.data.service + +import com.example.data.constant.ApiConstants.API +import com.example.data.constant.ApiConstants.TRACKS +import com.example.data.constant.ApiConstants.VERSIONS +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.SearchTrackResponse +import com.example.data.model.response.TrackPreviewResponse +import com.example.data.model.response.TrackResponse +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface TrackService { + @GET("$API/$VERSIONS/$TRACKS") + suspend fun searchTracks( + @Query("query") query: String, + @Query("limit") limit: Int?, + @Query("storefront") storefront: String?, + @Query("cursor") cursor: String?, + ): BaseResponse + + @GET("$API/$VERSIONS/$TRACKS/{trackId}") + suspend fun getTrack( + @Path("trackId") trackId: String, + @Query("storefront") storefront: String?, + ): BaseResponse + + @GET("$API/$VERSIONS/$TRACKS/preview/{trackId}") + suspend fun getTrackPreview( + @Path("trackId") trackId: String, + @Query("storefront") storefront: String? = null, + ): BaseResponse +} diff --git a/core/data/src/main/java/com/example/data/service/UserService.kt b/core/data/src/main/java/com/example/data/service/UserService.kt new file mode 100644 index 00000000..a6fe00a9 --- /dev/null +++ b/core/data/src/main/java/com/example/data/service/UserService.kt @@ -0,0 +1,63 @@ +package com.example.data.service + +import com.example.data.constant.ApiConstants.API +import com.example.data.constant.ApiConstants.ME +import com.example.data.constant.ApiConstants.NOTIFICATIONS +import com.example.data.constant.ApiConstants.POSTS +import com.example.data.constant.ApiConstants.SCRAPS +import com.example.data.constant.ApiConstants.USERS +import com.example.data.constant.ApiConstants.VERSIONS +import com.example.data.model.request.NotificationRequest +import com.example.data.model.request.PatchProfileRequest +import com.example.data.model.response.BaseResponse +import com.example.data.model.response.NotificationResponse +import com.example.data.model.response.RegisteredTracksResponse +import com.example.data.model.response.ScrappedTracksResponse +import com.example.data.model.response.UserInfoResponse +import kotlinx.serialization.InternalSerializationApi +import okhttp3.MultipartBody +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +@OptIn(InternalSerializationApi::class) +interface UserService { + @Multipart + @PATCH("$API/$VERSIONS/$USERS/$ME") + suspend fun patchProfile( + @Part profileImg: MultipartBody.Part?, + @Part("changeProfileRequest") request: PatchProfileRequest, + ): BaseResponse + + @GET("$API/$VERSIONS/$USERS/{userId}") + suspend fun getUser( + @Path("userId") userId: Long, + ): BaseResponse + + @GET("$API/$VERSIONS/$USERS/$ME/$NOTIFICATIONS") + suspend fun getNotificationEnabled(): BaseResponse + + @POST("$API/$VERSIONS/$USERS/$ME/$NOTIFICATIONS") + suspend fun postNotificationEnabled( + @Body request: NotificationRequest, + ): BaseResponse + + @GET("$API/$VERSIONS/$USERS/{userId}/$POSTS") + suspend fun getRegisteredTracks( + @Path("userId") userId: Long, + @Query("cursor") page: String?, + @Query("limit") size: Int?, + ): BaseResponse + + @GET("$API/$VERSIONS/$USERS/{userId}/$SCRAPS") + suspend fun getScrappedTracks( + @Path("userId") userId: Long, + @Query("cursor") page: String?, + @Query("limit") size: Int?, + ): BaseResponse +} diff --git a/core/designsystem/.gitignore b/core/designsystem/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/designsystem/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts new file mode 100644 index 00000000..5af8b4be --- /dev/null +++ b/core/designsystem/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.dplay.android.compose) +} + +android { + namespace = "com.dplay.designsystem" +} + +dependencies { + implementation(libs.coil.compose) +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayBottomSheet.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayBottomSheet.kt new file mode 100644 index 00000000..d9eaa74f --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayBottomSheet.kt @@ -0,0 +1,395 @@ +package com.example.designsystem.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +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.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.draw.dropShadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.layout.SubcomposeLayout +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.dplay.designsystem.R +import com.example.designsystem.component.button.DPlayLargeGrayButton +import com.example.designsystem.component.button.DPlayUnderlineTextButton +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +enum class DPlayReportReason( + @param:StringRes val stringResId: Int, +) { + INAPPROPRIATE_CONTENT(R.string.report_reason_inappropriate_content), + OFFENSIVE_EXPRESSION(R.string.report_reason_offensive_expression), + SUSPICIOUS_OR_SPAM(R.string.report_reason_suspicious_or_spam), + COPYRIGHT_VIOLATION(R.string.report_reason_copyright_violation), +} + +@Composable +fun DPlayButtonBottomSheet( + mainText: String, + subText: String, + mainOnClick: () -> Unit, + subOnClick: () -> Unit, + modifier: Modifier = Modifier, + mainButtonColor: Color = DPlayTheme.colors.dplayBlack, +) { + val bottomSheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + Column( + modifier = + modifier + .fillMaxWidth() + .dropShadow( + shape = bottomSheetShape, + shadow = + Shadow( + radius = 20.dp, + alpha = 0.15f, + ), + ).clip(bottomSheetShape) + .background( + color = DPlayTheme.colors.dplayWhite, + shape = bottomSheetShape, + ).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = mainText, + style = DPlayTheme.typography.bodySemi16, + color = mainButtonColor, + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp) + .noRippleClickable(onClick = mainOnClick), + textAlign = TextAlign.Center, + ) + DPlayUnderlineTextButton( + onClick = subOnClick, + text = subText, + ) + } +} + +@Composable +fun DPlayTitleButtonBottomSheet( + titleText: String, + buttonText: String, + onButtonClick: () -> Unit, + onCloseClick: () -> Unit, + modifier: Modifier = Modifier, + isButtonEnabled: Boolean = true, + content: @Composable ColumnScope.() -> Unit, +) { + val bottomSheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + + Column( + modifier = + modifier + .fillMaxWidth() + .dropShadow( + shape = bottomSheetShape, + shadow = + Shadow( + radius = 20.dp, + alpha = 0.15f, + ), + ).clip(bottomSheetShape) + .background( + color = DPlayTheme.colors.dplayWhite, + shape = bottomSheetShape, + ).padding(top = 24.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = titleText, + style = DPlayTheme.typography.titleBold18, + color = DPlayTheme.colors.dplayBlack, + ) + Spacer(modifier = Modifier.weight(1f)) + DplayClickableIcon( + iconRes = R.drawable.ic_close_24, + onClick = onCloseClick, + tint = DPlayTheme.colors.dplayBlack, + modifier = Modifier.size(32.dp), + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + content() + + Spacer(modifier = Modifier.height(12.dp)) + + DPlayLargeGrayButton( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.5.dp), + onClick = onButtonClick, + label = buttonText, + enabled = isButtonEnabled, + ) + } +} + +@Composable +fun DPlayReportBottomSheet( + onButtonClick: (selectedReason: DPlayReportReason) -> Unit, + onCloseClick: () -> Unit, + modifier: Modifier = Modifier, + onCheckClick: ((DPlayReportReason) -> Unit)? = null, + reasons: List = DPlayReportReason.entries, +) { + var selectedReason by remember { mutableStateOf(null) } + + DPlayTitleButtonBottomSheet( + titleText = stringResource(R.string.report_bottom_sheet_title), + buttonText = "신고하기", + onButtonClick = { selectedReason?.let { onButtonClick(it) } }, + onCloseClick = onCloseClick, + modifier = modifier, + isButtonEnabled = selectedReason != null, + ) { + reasons.forEach { reason -> + val isChecked = reason == selectedReason + DPlayCheck( + text = stringResource(reason.stringResId), + isChecked = isChecked, + onClick = { + selectedReason = if (isChecked) null else reason + onCheckClick?.invoke(reason) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +fun DPlayDatePickerBottomSheet( + initialYear: Int, + initialMonth: Int, + onApplyClick: (year: Int, month: Int) -> Unit, + onCloseClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var selectedYear by remember { mutableIntStateOf(initialYear) } + var selectedMonth by remember { mutableIntStateOf(initialMonth) } + + DPlayTitleButtonBottomSheet( + titleText = "날짜를 선택해주세요", + buttonText = "적용하기", + onButtonClick = { onApplyClick(selectedYear, selectedMonth) }, + onCloseClick = onCloseClick, + modifier = modifier, + ) { + Spacer(modifier = Modifier.height(24.dp)) + + YearMonthWheelPicker( + initialYear = selectedYear, + initialMonth = selectedMonth, + onYearSelected = { selectedYear = it }, + onMonthSelected = { selectedMonth = it }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +fun YearMonthWheelPicker( + initialYear: Int, + initialMonth: Int, + onYearSelected: (Int) -> Unit, + onMonthSelected: (Int) -> Unit, + modifier: Modifier = Modifier, + yearRange: IntRange = 2025..2026, +) { + val years = yearRange.toList() + val months = (1..12).toList() + + @Composable + fun dateText( + date: Int, + modifier: Modifier = Modifier, + isFocused: Boolean = false, + isYear: Boolean = true, + ) { + Text( + text = "$date${if (isYear) "년" else "월"}", + modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = DPlayTheme.typography.bodySemi20, + color = if (isFocused) DPlayTheme.colors.dplayBlack else DPlayTheme.colors.gray200, + ) + } + + @Composable + fun WheelColumn( + items: List, + initialIndex: Int, + isYear: Boolean, + onSelected: (Int) -> Unit, + ) { + SubcomposeLayout { constraints -> + val samplePlaceable = + subcompose("sample") { + dateText(date = if (isYear) 2024 else 10, isYear = isYear) + }.first().measure(constraints) + + val itemWidth = samplePlaceable.width + val itemHeight = samplePlaceable.height + + val listPlaceable = + subcompose("list") { + val listState = rememberLazyListState(initialFirstVisibleItemIndex = initialIndex) + val focusedIndex by remember { + derivedStateOf { + if (itemHeight == 0) return@derivedStateOf -1 + val offset = listState.firstVisibleItemScrollOffset + val index = listState.firstVisibleItemIndex + if (offset > itemHeight / 2) index + 1 else index + } + } + + LaunchedEffect(listState.isScrollInProgress) { + if (!listState.isScrollInProgress && itemHeight > 0) { + val offset = listState.firstVisibleItemScrollOffset + val targetIndex = + if (offset > itemHeight / 2) { + listState.firstVisibleItemIndex + 1 + } else { + listState.firstVisibleItemIndex + } + listState.animateScrollToItem(targetIndex.coerceIn(0, items.lastIndex)) + onSelected(items[targetIndex.coerceIn(0, items.lastIndex)]) + } + } + + Box( + modifier = Modifier.width(itemWidth.toDp()), + contentAlignment = Alignment.Center, + ) { + LazyColumn( + state = listState, + modifier = Modifier.height((itemHeight * 3).toDp()), + contentPadding = PaddingValues(vertical = itemHeight.toDp()), + flingBehavior = rememberSnapFlingBehavior(listState), + ) { + itemsIndexed(items) { index, item -> + dateText( + date = item, + isFocused = index == focusedIndex, + isYear = isYear, + ) + } + } + Column { + HorizontalDivider(thickness = 1.dp, color = DPlayTheme.colors.dplayBlack) + Spacer(modifier = Modifier.height(itemHeight.toDp())) + HorizontalDivider(thickness = 1.dp, color = DPlayTheme.colors.dplayBlack) + } + } + }.first().measure(constraints) + + layout(listPlaceable.width, listPlaceable.height) { + listPlaceable.placeRelative(0, 0) + } + } + } + + Row(modifier = modifier) { + WheelColumn( + items = years, + initialIndex = years.indexOf(initialYear).coerceAtLeast(0), + isYear = true, + onSelected = onYearSelected, + ) + + Spacer(modifier = Modifier.width(44.dp)) + + WheelColumn( + items = months, + initialIndex = (initialMonth - 1).coerceAtLeast(0), + isYear = false, + onSelected = onMonthSelected, + ) + } +} + +@Preview +@Composable +private fun DPlayBottomSheetPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayWhite), + ) { + DPlayButtonBottomSheet( + mainText = stringResource(R.string.launch_album_bottomsheet_main_text), + subText = stringResource(R.string.launch_album_bottomsheet_sub_text), + mainOnClick = {}, + subOnClick = {}, + ) + + DPlayButtonBottomSheet( + mainText = "삭제하기", + subText = "취소하기", + mainOnClick = {}, + subOnClick = {}, + mainButtonColor = DPlayTheme.colors.dplayPink, + ) + DPlayReportBottomSheet( + onCloseClick = {}, + onButtonClick = {}, + onCheckClick = {}, + ) + DPlayDatePickerBottomSheet( + onCloseClick = {}, + onApplyClick = { year, month -> + }, + initialYear = 2026, + initialMonth = 2, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayCheck.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayCheck.kt new file mode 100644 index 00000000..aa6179fe --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayCheck.kt @@ -0,0 +1,123 @@ +package com.example.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +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.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DPlayCheck( + text: String, + isChecked: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = if (isChecked) DPlayTheme.colors.gray100 else Color.Transparent + val density = LocalDensity.current + var textHeightPx by remember { mutableIntStateOf(0) } + + Row( + modifier = + modifier + .background(color = backgroundColor) + .noRippleClickable( + onClick = onClick, + ).padding( + horizontal = 16.dp, + vertical = 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isChecked) { + val iconSize = with(density) { textHeightPx.toDp() } + DplayBaseIcon( + iconRes = R.drawable.ic_check_circle_20, + modifier = Modifier.size(iconSize), + ) + + Spacer(modifier = Modifier.width(10.dp)) + } + + Text( + text = text, + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.gray500, + textAlign = TextAlign.Center, + modifier = Modifier.onSizeChanged { textHeightPx = it.height }, + ) + } +} + +@Composable +@Preview(showBackground = true) +fun DPlayCheckPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(color = Color.White) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayCheck( + text = stringResource(id = R.string.reason_inappropriate_content), + isChecked = true, + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + + DPlayCheck( + text = stringResource(id = R.string.reason_inappropriate_content), + isChecked = false, + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + + DPlayCheck( + text = stringResource(id = R.string.reason_offensive_expression), + isChecked = false, + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + + DPlayCheck( + text = stringResource(id = R.string.reason_suspicious_or_spam), + isChecked = false, + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + + DPlayCheck( + text = stringResource(id = R.string.reason_copyright_violation), + isChecked = false, + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayCheckArrow.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayCheckArrow.kt new file mode 100644 index 00000000..2b08e233 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayCheckArrow.kt @@ -0,0 +1,93 @@ +package com.example.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material3.Text +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun DPlayCheckArrow( + text: String, + isChecked: Boolean, + onCheckBoxClick: () -> Unit, + onArrowClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val checkBoxIconRes = if (isChecked) R.drawable.ic_check_circle_pink_24 else R.drawable.ic_check_circle_lightgray_24 + + Row( + modifier = + modifier + .padding( + horizontal = 12.dp, + vertical = 16.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + DplayClickableIcon( + iconRes = checkBoxIconRes, + onClick = onCheckBoxClick, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = text, + style = DPlayTheme.typography.bodySemi16, + color = DPlayTheme.colors.dplayBlack, + ) + + Spacer(modifier = Modifier.weight(1f)) + + DplayClickableIcon( + iconRes = R.drawable.ic_arrow_right_16, + onClick = onArrowClick, + ) + } +} + +@Composable +@Preview(showBackground = true) +fun DPlayCheckArrowPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(Color.White) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayCheckArrow( + text = stringResource(id = R.string.terms_service_required), + isChecked = true, + onArrowClick = {}, + onCheckBoxClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + + DPlayCheckArrow( + text = stringResource(id = R.string.privacy_policy_required), + isChecked = false, + onArrowClick = {}, + onCheckBoxClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayCheckBox.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayCheckBox.kt new file mode 100644 index 00000000..58690c77 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayCheckBox.kt @@ -0,0 +1,91 @@ +package com.example.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +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.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DPlayCheckBox( + text: String, + isChecked: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val (backgroundColor, checkBoxIconRes) = + if (isChecked) { + DPlayTheme.colors.dplayPink100 to R.drawable.ic_check_circle_pink_24 + } else { + DPlayTheme.colors.gray100 to R.drawable.ic_check_circle_lightgray_24 + } + Row( + modifier = + modifier + .background( + color = backgroundColor, + shape = RoundedCornerShape(12.dp), + ).noRippleClickable( + onClick = onClick, + ).padding( + horizontal = 12.dp, + vertical = 16.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + DplayBaseIcon( + iconRes = checkBoxIconRes, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = text, + style = DPlayTheme.typography.bodySemi16, + color = DPlayTheme.colors.dplayBlack, + ) + } +} + +@Composable +@Preview(showBackground = true) +fun DPlayCheckBoxPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayCheckBox( + text = stringResource(R.string.agree_all_terms), + isChecked = true, + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + + DPlayCheckBox( + text = stringResource(R.string.agree_all_terms), + isChecked = false, + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayDayTopicItem.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayDayTopicItem.kt new file mode 100644 index 00000000..4e69b3e2 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayDayTopicItem.kt @@ -0,0 +1,79 @@ +package com.example.designsystem.component + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +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.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable +import com.example.designsystem.util.roundedBackgroundWithPadding + +@Composable +fun DPlayDayTopicItem( + dayText: String, + topic: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .noRippleClickable(onClick = onClick), + ) { + Box( + modifier = + Modifier + .size(50.dp) + .roundedBackgroundWithPadding( + backgroundColor = DPlayTheme.colors.gray600, + cornerRadius = 8.dp, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = dayText, + color = DPlayTheme.colors.gray100, + style = DPlayTheme.typography.bodyBold14, + ) + } + Box( + modifier = + Modifier + .height(50.dp) + .weight(1f) + .roundedBackgroundWithPadding( + backgroundColor = DPlayTheme.colors.gray100, + cornerRadius = 8.dp, + padding = PaddingValues(start = 10.dp), + ), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = topic, + style = DPlayTheme.typography.bodySemi14, + ) + } + } +} + +@Preview +@Composable +private fun DPlayDayTopicItemPreview() { + DPlayTheme { + DPlayDayTopicItem( + dayText = "1일", + topic = "여행 갈 때 플레이리스트에 꼭 넣는 노래는?", + onClick = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayErrorScreen.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayErrorScreen.kt new file mode 100644 index 00000000..16704aec --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayErrorScreen.kt @@ -0,0 +1,65 @@ +package com.example.designsystem.component + +import androidx.compose.foundation.Image +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.size +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.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun DPlayErrorScreen( + modifier: Modifier = Modifier, + onBackIconClick: () -> Unit = {}, +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DplayLeftIconTopAppBar { + onBackIconClick() + } + + Spacer(modifier = Modifier.height(height = 132.dp)) + + Image( + painter = painterResource(id = R.drawable.img_warning), + contentDescription = null, + modifier = Modifier.size(size = 140.dp), + ) + + Spacer(modifier = Modifier.height(height = 12.dp)) + + Text( + text = stringResource(R.string.error_main_text), + style = DPlayTheme.typography.bodyBold16, + color = DPlayTheme.colors.dplayBlack, + ) + + Spacer(modifier = Modifier.height(height = 8.dp)) + + Text( + text = stringResource(R.string.error_sub_text), + style = DPlayTheme.typography.bodyMed14, + color = DPlayTheme.colors.gray400, + ) + } +} + +@Preview +@Composable +private fun DPlayErrorScreenPreview(modifier: Modifier = Modifier) { + DPlayTheme { + DPlayErrorScreen(modifier = modifier) + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayIcon.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayIcon.kt new file mode 100644 index 00000000..fe333a59 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayIcon.kt @@ -0,0 +1,47 @@ +package com.example.designsystem.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DplayBaseIcon( + @DrawableRes iconRes: Int, + modifier: Modifier = Modifier, + contentDescription: String? = null, + tint: Color = Color.Unspecified, +) { + Icon( + modifier = modifier, + imageVector = ImageVector.vectorResource(iconRes), + contentDescription = contentDescription, + tint = tint, + ) +} + +@Composable +fun DplayClickableIcon( + @DrawableRes iconRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enablePressedAnimation: Boolean = false, + tint: Color = Color.Unspecified, +) { + val clickableModifier = + if (enablePressedAnimation) { + modifier.clickable(onClick = onClick) + } else { + modifier.noRippleClickable(onClick = onClick) + } + DplayBaseIcon( + iconRes = iconRes, + modifier = clickableModifier, + tint = tint, + ) +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayImageCheck.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayImageCheck.kt new file mode 100644 index 00000000..c726f15a --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayImageCheck.kt @@ -0,0 +1,127 @@ +package com.example.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.RoundedCornerShape +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.layout.ContentScale +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 coil3.compose.AsyncImage +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DPlayImageCheck( + imageUrl: String, + musicName: String, + artistName: String, + isChecked: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = if (isChecked) DPlayTheme.colors.gray100 else Color.Transparent + + Row( + modifier = + modifier + .background(color = backgroundColor) + .noRippleClickable( + onClick = onClick, + ).padding( + horizontal = 16.dp, + vertical = 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = imageUrl, + contentDescription = stringResource(R.string.image_check_image_description, musicName), + modifier = + Modifier + .size(52.dp) + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.FillBounds, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = musicName, + style = DPlayTheme.typography.bodySemi16, + color = DPlayTheme.colors.dplayBlack, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = artistName, + style = DPlayTheme.typography.bodyMed14, + color = DPlayTheme.colors.gray400, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (isChecked) { + DplayBaseIcon( + iconRes = R.drawable.ic_check_circle_darkgray_24, + ) + } + } +} + +@Composable +@Preview(showBackground = true) +fun DPlayImageCheckPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(Color.White) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayImageCheck( + imageUrl = "", + musicName = "내일에서 온 티켓", + artistName = "한로로", + isChecked = true, + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + + DPlayImageCheck( + imageUrl = "", + musicName = "내일에서 온 티켓", + artistName = "한로로", + isChecked = false, + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayLargeCover.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayLargeCover.kt new file mode 100644 index 00000000..dfd14ee9 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayLargeCover.kt @@ -0,0 +1,288 @@ +package com.example.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DPlayLargeCover( + isLikeChecked: Boolean, + isAdmin: Boolean, + likeCount: Int, + writerProfileImageUrl: String?, + writerNickname: String, + content: String, + musicImageUrl: String, + onCoverClick: () -> Unit, + onWriterProfileClick: () -> Unit, + onStreamClick: () -> Unit, + onLikeClick: () -> Unit, + modifier: Modifier = Modifier, + isLocked: Boolean = true, + isStreaming: Boolean = false, +) { + val color = DPlayTheme.colors + val typography = DPlayTheme.typography + + val likeCountString = likeCount.takeIf { it > 0 }?.toString().orEmpty() + val textCoverShape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp) + + var discHeightPx by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + + val textHeightDp = + remember(discHeightPx) { + if (discHeightPx == 0) { + 0.dp + } else { + with(density) { discHeightPx.toDp() * (204f / 255f) } + } + } + + Box( + modifier = + modifier + .fillMaxWidth() + .clip(textCoverShape), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .then(if (isLocked) Modifier.blur(20.dp) else Modifier) + .noRippleClickable(onClick = onCoverClick), + ) { + DPlayMusicDiscItem( + imageUrl = musicImageUrl, + isStreaming = isStreaming, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 44.dp, start = 12.dp, end = 12.dp) + .onSizeChanged { discHeightPx = it.height } + .align(Alignment.TopCenter), + ) + if (textHeightDp > 0.dp) { + Box( + modifier = + Modifier + .matchParentSize() + .drawWithContent { + val textHeightPx = textHeightDp.toPx() + val clipTop = size.height - textHeightPx + + clipRect( + top = clipTop, + bottom = size.height, + ) { + this@drawWithContent.drawContent() + } + }, + ) { + DPlayMusicDiscItem( + imageUrl = musicImageUrl, + isStreaming = isStreaming, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 44.dp, start = 12.dp, end = 12.dp) + .align(Alignment.TopCenter) + .blur( + radius = 60.dp, + edgeTreatment = BlurredEdgeTreatment.Unbounded, + ).clip(CircleShape), + ) + } + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .height(textHeightDp) + .background( + color = color.dplayPinkTrans, + shape = textCoverShape, + ).border( + width = 1.dp, + color = color.dplayPink, + shape = textCoverShape, + ).padding(12.dp) + .align(Alignment.BottomCenter), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.noRippleClickable(onClick = onWriterProfileClick), + ) { + AsyncImage( + model = + if (isAdmin) { + R.drawable.img_profile + } else { + writerProfileImageUrl ?: R.drawable.base_profile_image + }, + contentDescription = null, + modifier = + Modifier + .size(28.dp) + .clip(CircleShape) + .border(1.dp, color = color.gray200, shape = CircleShape), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = writerNickname, + style = typography.bodyBold16, + color = color.dplayWhite, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row { + DplayBaseIcon( + iconRes = R.drawable.ic_quote_up_16, + modifier = Modifier.align(Alignment.Top), + ) + Text( + text = content, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + style = typography.bodySemi14, + color = color.dplayWhite, + modifier = Modifier.weight(1f), + ) + DplayBaseIcon( + iconRes = R.drawable.ic_quote_down_16, + modifier = Modifier.align(Alignment.Bottom), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Row(verticalAlignment = Alignment.Bottom) { + Row(verticalAlignment = Alignment.CenterVertically) { + DplayClickableIcon( + iconRes = + if (isLikeChecked) { + R.drawable.ic_heart_white_filled_24 + } else { + R.drawable.ic_heart_white_unfilled_24 + }, + onClick = onLikeClick, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = likeCountString, + style = typography.bodySemi14, + color = color.dplayWhite, + ) + } + Spacer(modifier = Modifier.weight(1f)) + DplayClickableIcon( + iconRes = R.drawable.ic_stream_pink_32, + modifier = + Modifier + .background( + color = color.dplayWhite, + shape = RoundedCornerShape(16.dp), + ).padding(6.dp), + onClick = onStreamClick, + ) + } + } + } + + if (isLocked) { + Box( + modifier = + Modifier + .matchParentSize() + .noRippleClickable { onCoverClick() } + .background(color.dplayWhite.copy(alpha = 0.9f)) + .blur(20.dp), + ) + Column(modifier = Modifier.matchParentSize(), horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.weight(4f)) + DplayBaseIcon(iconRes = R.drawable.ic_lock_140) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "앗, 아직 열어 볼 수 없어요!", style = typography.bodyBold16, color = color.dplayBlack) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = "잠겨있는 곡이에요", style = typography.bodyMed14, color = color.gray400) + Spacer(modifier = Modifier.weight(6f)) + } + } + } +} + +@Preview +@Composable +private fun DPlayLockedLargeCoverPreview() { + DPlayTheme { + DPlayLargeCover( + isLikeChecked = false, + likeCount = 24, + writerProfileImageUrl = "", + writerNickname = "윤서얌", + content = "진짜 나오자마자 들었는데 이 노래가 최고! 출근곡, 퇴근곡, 노동곡 다 되는 짱제로! 일하는 매장에서도 수십 번씩 틀고 있어요. 모두가 알아야 돼..", + musicImageUrl = "", + onStreamClick = {}, + onLikeClick = {}, + onCoverClick = {}, + onWriterProfileClick = {}, + isAdmin = false, + ) + } +} + +@Preview +@Composable +private fun DPlayLargeCoverPreview() { + DPlayTheme { + DPlayLargeCover( + isLikeChecked = false, + likeCount = 24, + writerProfileImageUrl = "", + writerNickname = "윤서얌", + content = "진짜 나오자마자 들었는데 이 노래가 최고! 출근곡, 퇴근곡, 노동곡 다 되는 짱제로! 일하는 매장에서도 수십 번씩 틀고 있어요. 모두가 알아야 돼..", + musicImageUrl = "", + onStreamClick = {}, + onLikeClick = {}, + onCoverClick = {}, + onWriterProfileClick = {}, + isLocked = false, + isAdmin = false, + ) + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayLoadingScreen.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayLoadingScreen.kt new file mode 100644 index 00000000..ba062bb4 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayLoadingScreen.kt @@ -0,0 +1,30 @@ +package com.example.designsystem.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun DPlayLoadingScreen( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Preview +@Composable +private fun DPlayLoadingScreenPreview() { + DPlayTheme { + DPlayLoadingScreen() + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicDiscItem.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicDiscItem.kt new file mode 100644 index 00000000..58489dc0 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicDiscItem.kt @@ -0,0 +1,110 @@ +package com.example.designsystem.component + +import androidx.compose.animation.core.LinearEasing +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.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.example.designsystem.theme.DPlayTheme + +private const val DISC_ROTATION_DURATION_MILLIS = 10000 // 10초 + +@Composable +fun DPlayMusicDiscItem( + imageUrl: String, + modifier: Modifier = Modifier, + isStreaming: Boolean = false, +) { + val grayBorderColor = DPlayTheme.colors.gray200 + val infiniteTransition = rememberInfiniteTransition(label = "disc_rotation") + + val animatedRotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = DISC_ROTATION_DURATION_MILLIS, easing = LinearEasing), + ), + label = "rotation", + ) + + var startOffset by remember { mutableFloatStateOf(animatedRotation) } + var wasStreaming by remember { mutableStateOf(false) } + + if (isStreaming && !wasStreaming) { + startOffset = animatedRotation + } + wasStreaming = isStreaming + + val rotation = + if (isStreaming) { + (animatedRotation - startOffset + 360f) % 360f + } else { + 0f + } + + Box( + modifier = + modifier + .aspectRatio(1f) + .graphicsLayer { + rotationZ = rotation + compositingStrategy = CompositingStrategy.Offscreen + }.clip(CircleShape) + .border( + width = 2.dp, + color = grayBorderColor, + shape = CircleShape, + ).drawWithContent { + drawContent() + + val cx = size.width / 2 + val cy = size.height / 2 + val holeRadius = size.minDimension * 0.0625f + + drawCircle( + color = Color.Transparent, + radius = holeRadius, + center = Offset(cx, cy), + blendMode = BlendMode.Clear, + ) + + drawCircle( + color = grayBorderColor, + radius = holeRadius, + center = Offset(cx, cy), + style = Stroke(width = 2.dp.toPx()), + ) + }, + ) { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicGridItem.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicGridItem.kt new file mode 100644 index 00000000..b99cf203 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicGridItem.kt @@ -0,0 +1,83 @@ +package com.example.designsystem.component + +import androidx.compose.foundation.layout.Column +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +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 com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DPlayMusicGridItem( + musicImageUrl: String, + musicName: String, + musicArtistName: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + titleStyle: TextStyle = DPlayTheme.typography.bodySemi14, + artistStyle: TextStyle = DPlayTheme.typography.capMed12, + spaceBetweenCover: Dp = 5.dp, + spaceBetweenText: Dp = 1.dp, +) { + Column(modifier = modifier.noRippleClickable(onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally) { + DPlayMusicDiscItem( + imageUrl = musicImageUrl, + modifier = + Modifier + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(spaceBetweenCover)) + Text( + text = musicName, + style = titleStyle, + color = DPlayTheme.colors.dplayBlack, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(spaceBetweenText)) + Text( + text = musicArtistName, + style = artistStyle, + color = DPlayTheme.colors.gray400, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Preview +@Composable +private fun DPlayMusicGridItemPreview() { + DPlayTheme { + Row(modifier = Modifier.fillMaxWidth()) { + DPlayMusicGridItem( + musicImageUrl = "", + musicName = "Title", + musicArtistName = "artist", + modifier = Modifier.weight(1f), + ) + DPlayMusicGridItem( + musicImageUrl = "", + musicName = "Title", + musicArtistName = "artist", + modifier = Modifier.weight(1f), + ) + DPlayMusicGridItem( + musicImageUrl = "", + musicName = "Title", + musicArtistName = "artist", + modifier = Modifier.weight(1f), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicListItem.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicListItem.kt new file mode 100644 index 00000000..28fc158e --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayMusicListItem.kt @@ -0,0 +1,158 @@ +package com.example.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +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.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.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable +import com.example.designsystem.util.roundedBackgroundWithPadding + +@Composable +fun DPlayMusicListItem( + musicImageUrl: String, + musicName: String, + musicArtistName: String, + musicContent: String, + modifier: Modifier = Modifier, + onMoreClick: (() -> Unit)? = null, + isEditorPick: Boolean = false, + onClick: () -> Unit = {}, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .noRippleClickable(onClick = onClick), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .border(width = 1.dp, shape = RoundedCornerShape(20.dp), color = DPlayTheme.colors.gray200) + .roundedBackgroundWithPadding( + cornerRadius = 20.dp, + backgroundColor = DPlayTheme.colors.dplayWhite, + padding = PaddingValues(12.dp), + ), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = musicImageUrl, + contentDescription = null, + modifier = + Modifier + .size(68.dp) + .clip(shape = RoundedCornerShape(16.dp)), + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + onMoreClick?.let { + DplayClickableIcon( + iconRes = R.drawable.ic_more_gray_20, + onClick = it, + modifier = Modifier.align(Alignment.End), + ) + } + BoxWithConstraints { + val maxTitleWidth = maxWidth * 0.5f + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = musicName, + style = DPlayTheme.typography.bodyBold16, + color = DPlayTheme.colors.dplayBlack, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = maxTitleWidth), + ) + + Spacer(modifier = Modifier.width(6.dp)) + + Text( + text = musicArtistName, + style = DPlayTheme.typography.capMed12, + color = DPlayTheme.colors.gray400, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = musicContent, + style = DPlayTheme.typography.capMed12, + color = DPlayTheme.colors.gray500, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (isEditorPick) { + Box( + modifier = + Modifier + .padding(start = 64.dp, top = 8.dp) + .clip(shape = CircleShape) + .border( + width = 1.dp, + color = DPlayTheme.colors.dplayPink, + shape = CircleShape, + ).background(color = DPlayTheme.colors.dplayWhite) + .padding(4.dp), + ) { + DplayBaseIcon( + iconRes = R.drawable.ic_editor_20, + modifier = Modifier.size(12.dp), + ) + } + } + } +} + +@Preview +@Composable +private fun DPlayMusicListItemPreview() { + DPlayTheme { + Column { + DPlayMusicListItem( + musicImageUrl = "", + musicName = "Title", + musicArtistName = "artist", + musicContent = "contents", + onMoreClick = {}, + ) + DPlayMusicListItem( + musicImageUrl = "", + musicName = "Title", + musicArtistName = "artist", + musicContent = "contents", + onMoreClick = {}, + isEditorPick = true, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayProfileImageArea.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayProfileImageArea.kt new file mode 100644 index 00000000..e05e4d2f --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayProfileImageArea.kt @@ -0,0 +1,50 @@ +package com.example.designsystem.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.CircleShape +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.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DPlayProfileImageArea( + onProfileImageClick: () -> Unit, + profileImagePath: String?, + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {}, +) { + Box( + modifier = + modifier + .noRippleClickable( + onClick = { onProfileImageClick() }, + ), + contentAlignment = Alignment.BottomEnd, + ) { + AsyncImage( + model = profileImagePath ?: R.drawable.base_profile_image, + contentDescription = null, + modifier = + Modifier + .fillMaxSize() + .clip(CircleShape) + .border( + width = 1.dp, + color = DPlayTheme.colors.gray200, + shape = CircleShape, + ), + contentScale = ContentScale.Crop, + ) + + content() + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayScrim.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayScrim.kt new file mode 100644 index 00000000..27cc0665 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayScrim.kt @@ -0,0 +1,26 @@ +package com.example.designsystem.component + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DPlayScrim( + backgroundColor: Color, + onDismiss: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = backgroundColor) + .noRippleClickable { onDismiss() }, + ) { + BackHandler { onDismiss() } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlaySubjectItem.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlaySubjectItem.kt new file mode 100644 index 00000000..d6cc6206 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlaySubjectItem.kt @@ -0,0 +1,62 @@ +package com.example.designsystem.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.roundedBackgroundWithPadding + +@Composable +fun DPlaySubjectItem( + subject: String, + modifier: Modifier = Modifier, +) { + val color = DPlayTheme.colors + val typography = DPlayTheme.typography + Column( + modifier = + modifier + .fillMaxWidth() + .border(width = 1.dp, color = color.gray200, shape = RoundedCornerShape(12.dp)) + .roundedBackgroundWithPadding( + backgroundColor = color.gray100, + cornerRadius = 12.dp, + padding = PaddingValues(12.dp), + ), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + DplayBaseIcon( + iconRes = R.drawable.ic_symbol_20, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringResource(R.string.subject_title), style = typography.bodySemi14, color = color.dplayPink) + } + Spacer(modifier = Modifier.height(4.dp)) + + Text(text = subject, style = typography.bodySemi14, color = color.dplayBlack) + } +} + +@Preview +@Composable +private fun DPlaySubjectItemPreview() { + DPlayTheme { + DPlaySubjectItem( + subject = "여행 갈 때 플레이리스트에 꼭 넣는 노래는?", + ) + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DPlayTooltip.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayTooltip.kt new file mode 100644 index 00000000..2f0a82bf --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DPlayTooltip.kt @@ -0,0 +1,95 @@ +package com.example.designsystem.component + +import androidx.annotation.StringRes +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.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dplay.designsystem.R +import com.example.designsystem.component.button.DPlayUnderlineTextButton +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.theme.defaultDPlayColors +import com.example.designsystem.util.roundedBackgroundWithPadding + +@Composable +fun DplayTooltip( + onCloseButtonClicked: () -> Unit, + onTextButtonClicked: (() -> Unit)?, + modifier: Modifier = Modifier, + @StringRes textStringRes: Int = R.string.tooltip_default_description, +) { + Column(modifier = modifier) { + Box( + modifier = + Modifier + .padding(start = 54.dp) + .size(width = 16.dp, height = 10.dp) + .drawBehind { + val width = 16.dp.toPx() + val height = 10.dp.toPx() + val center = size.width / 2f + + val path = + Path().apply { + moveTo(center - width / 2f, height) + lineTo(center + width / 2f, height) + lineTo(center, 0f) + close() + } + drawPath(path, color = defaultDPlayColors.gray600) + }, + ) + + Column( + modifier = + Modifier + .roundedBackgroundWithPadding( + backgroundColor = DPlayTheme.colors.gray600, + cornerRadius = 4.dp, + padding = PaddingValues(vertical = 16.dp, horizontal = 12.dp), + ), + ) { + Row { + Text( + style = DPlayTheme.typography.bodyMed14, + color = DPlayTheme.colors.dplayWhite, + text = stringResource(textStringRes), + ) + DplayClickableIcon( + iconRes = R.drawable.ic_close_24, + onClick = onCloseButtonClicked, + ) + } + if (onTextButtonClicked != null) { + Spacer(modifier = Modifier.height(16.dp)) + DPlayUnderlineTextButton( + onClick = onTextButtonClicked, + text = stringResource(R.string.tooltip_learn_more), + ) + } + } + } +} + +@Preview +@Composable +private fun DplayTooltipPreview() { + DPlayTheme { + DplayTooltip( + onCloseButtonClicked = {}, + onTextButtonClicked = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/DplayTopAppBar.kt b/core/designsystem/src/main/java/com/example/designsystem/component/DplayTopAppBar.kt new file mode 100644 index 00000000..ae1ae5a0 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/DplayTopAppBar.kt @@ -0,0 +1,266 @@ +package com.example.designsystem.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DplayTopAppBar( + modifier: Modifier = Modifier, + containerColor: Color = Color.Transparent, + @DrawableRes leftIconRes: Int? = null, + @DrawableRes rightIconRes: Int? = null, + title: String? = null, + onLeftClick: (() -> Unit)? = null, + onRightClick: (() -> Unit)? = null, +) { + val iconPaddingModifier = Modifier.padding(12.dp) + + CenterAlignedTopAppBar( + modifier = modifier, + title = { + title?.let { + Text( + text = it, + style = DPlayTheme.typography.titleBold18, + color = DPlayTheme.colors.dplayBlack, + ) + } + }, + navigationIcon = { + leftIconRes?.let { + if (onLeftClick != null) { + DplayClickableIcon( + modifier = iconPaddingModifier, + iconRes = it, + onClick = onLeftClick, + ) + } else { + DplayBaseIcon(modifier = iconPaddingModifier, iconRes = it) + } + } + }, + actions = { + rightIconRes?.let { + if (onRightClick != null) { + DplayClickableIcon( + modifier = iconPaddingModifier, + iconRes = it, + onClick = onRightClick, + ) + } else { + DplayBaseIcon(modifier = iconPaddingModifier, iconRes = it) + } + } + }, + colors = + TopAppBarDefaults.topAppBarColors().copy( + containerColor = containerColor, + ), + ) +} + +@Composable +fun DplayLogoTopAppBar( + onListClick: (() -> Unit), + modifier: Modifier = Modifier, + containerColor: Color = Color.Transparent, +) { + val iconPaddingModifier = Modifier.padding(12.dp) + + CenterAlignedTopAppBar( + modifier = modifier, + navigationIcon = { + DplayBaseIcon( + modifier = iconPaddingModifier.padding(start = 4.dp), + iconRes = R.drawable.ic_wordmark_pink, + ) + }, + actions = { + DplayClickableIcon( + modifier = iconPaddingModifier, + iconRes = R.drawable.ic_list_24, + onClick = onListClick, + ) + }, + title = {}, + colors = + TopAppBarDefaults.topAppBarColors().copy( + containerColor = containerColor, + ), + ) +} + +@Composable +fun DplayLeftIconTopAppBar( + modifier: Modifier = Modifier, + @DrawableRes leftIconRes: Int = R.drawable.ic_arrow_left_16, + onLeftClick: (() -> Unit)? = null, +) { + DplayTopAppBar( + modifier = modifier, + leftIconRes = leftIconRes, + onLeftClick = onLeftClick, + ) +} + +@Composable +fun DplayDualIconTopAppBar( + modifier: Modifier = Modifier, + @DrawableRes leftIconRes: Int = R.drawable.ic_arrow_left_16, + @DrawableRes rightIconRes: Int = R.drawable.ic_more_24, + onLeftClick: (() -> Unit)? = null, + onRightClick: (() -> Unit)? = null, +) { + DplayTopAppBar( + modifier = modifier, + leftIconRes = leftIconRes, + rightIconRes = rightIconRes, + onLeftClick = onLeftClick, + onRightClick = onRightClick, + ) +} + +@Composable +fun DplayRightIconTitleTopAppBar( + modifier: Modifier = Modifier, + title: String = "", + @DrawableRes rightIconRes: Int = R.drawable.ic_setting_24, + onRightClick: (() -> Unit)? = null, +) { + DplayTopAppBar( + modifier = modifier, + title = title, + rightIconRes = rightIconRes, + onRightClick = onRightClick, + ) +} + +@Composable +fun DplayLeftIconTitleTopAppBar( + modifier: Modifier = Modifier, + title: String = "", + @DrawableRes leftIconRes: Int = R.drawable.ic_arrow_left_16, + onLeftClick: (() -> Unit)? = null, +) { + DplayTopAppBar( + modifier = modifier, + title = title, + leftIconRes = leftIconRes, + onLeftClick = onLeftClick, + ) +} + +@Composable +fun DplayDualIconTitleTopAppBar( + modifier: Modifier = Modifier, + title: String = "", + @DrawableRes leftIconRes: Int = R.drawable.ic_arrow_left_16, + @DrawableRes rightIconRes: Int = R.drawable.ic_more_24, + onLeftClick: (() -> Unit)? = null, + onRightClick: (() -> Unit)? = null, +) { + DplayTopAppBar( + modifier = modifier, + title = title, + leftIconRes = leftIconRes, + rightIconRes = rightIconRes, + onLeftClick = onLeftClick, + onRightClick = onRightClick, + ) +} + +@Composable +fun DplayTitleButtonTopAppBar( + modifier: Modifier = Modifier, + title: String = "", + containerColor: Color = Color.Transparent, + @DrawableRes leftIconRes: Int = R.drawable.ic_arrow_left_16, + @DrawableRes buttonIconRes: Int = R.drawable.ic_arrow_down, + onLeftClick: (() -> Unit)? = null, + onButtonClick: (() -> Unit)? = null, +) { + val clickableModifier = + Modifier.then( + if (onButtonClick != null) Modifier.noRippleClickable(onClick = onButtonClick) else Modifier, + ) + + val iconPaddingModifier = Modifier.padding(12.dp) + + CenterAlignedTopAppBar( + modifier = modifier, + title = { + Row(modifier = clickableModifier, verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = DPlayTheme.typography.titleBold18, + color = DPlayTheme.colors.dplayBlack, + ) + Spacer(modifier = Modifier.width(4.dp)) + DplayBaseIcon(iconRes = buttonIconRes) + } + }, + navigationIcon = { + if (onLeftClick != null) { + DplayClickableIcon( + iconRes = leftIconRes, + onClick = onLeftClick, + modifier = iconPaddingModifier, + ) + } else { + DplayBaseIcon( + iconRes = leftIconRes, + modifier = iconPaddingModifier, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors().copy( + containerColor = containerColor, + ), + ) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewDplayTopAppBars() { + DPlayTheme { + Column { + DplayLogoTopAppBar(onListClick = {}) + Spacer(modifier = Modifier.height(36.dp)) + + DplayLeftIconTopAppBar(onLeftClick = {}) + Spacer(modifier = Modifier.height(36.dp)) + + DplayDualIconTopAppBar(onLeftClick = {}, onRightClick = {}) + Spacer(modifier = Modifier.height(36.dp)) + + DplayRightIconTitleTopAppBar(title = "Title", onRightClick = {}) + Spacer(modifier = Modifier.height(36.dp)) + + DplayLeftIconTitleTopAppBar(title = "Title", onLeftClick = {}) + Spacer(modifier = Modifier.height(36.dp)) + + DplayDualIconTitleTopAppBar(title = "Title", onLeftClick = {}, onRightClick = {}) + Spacer(modifier = Modifier.height(36.dp)) + + DplayTitleButtonTopAppBar(title = "Title", onLeftClick = {}, onButtonClick = {}) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayBookmarkButton.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayBookmarkButton.kt new file mode 100644 index 00000000..1e59010f --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayBookmarkButton.kt @@ -0,0 +1,66 @@ +package com.example.designsystem.component.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dplay.designsystem.R +import com.example.designsystem.component.DplayBaseIcon +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun DPlayBookmarkButton( + isMarked: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val iconRes = if (isMarked) R.drawable.ic_bookmark_filled_24 else R.drawable.ic_bookmark_unfilled_24 + val iconContentDescription = + stringResource( + if (isMarked) R.string.bookmark_button_filled_bookmark_icon_description else R.string.bookmark_button_unfilled_bookmark_icon_description, + ) + + DPlayButtonSlot( + modifier = modifier, + onClick = onClick, + paddingValues = PaddingValues(10.dp), + containerColor = DPlayTheme.colors.gray600, + borderColor = DPlayTheme.colors.gray600, + ) { + DplayBaseIcon( + iconRes = iconRes, + contentDescription = iconContentDescription, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun DPlayBookmarkButtonPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayBookmarkButton( + isMarked = true, + onClick = {}, + ) + DPlayBookmarkButton( + isMarked = false, + onClick = {}, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayButtonSlot.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayButtonSlot.kt new file mode 100644 index 00000000..6b5d85ea --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayButtonSlot.kt @@ -0,0 +1,49 @@ +package com.example.designsystem.component.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.graphics.Shape +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DPlayButtonSlot( + paddingValues: PaddingValues, + containerColor: Color, + borderColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = RoundedCornerShape(12.dp), + content: @Composable () -> Unit, +) { + Box( + modifier = + modifier + .clip(shape = shape) + .border( + width = 1.dp, + color = borderColor, + shape = shape, + ).background( + color = containerColor, + ).noRippleClickable( + enabled = enabled, + onClick = onClick, + role = Role.Button, + ).padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + content() + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayCircleButton.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayCircleButton.kt new file mode 100644 index 00000000..e3dfedff --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayCircleButton.kt @@ -0,0 +1,95 @@ +package com.example.designsystem.component.button + +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.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.dplay.designsystem.R +import com.example.designsystem.component.DplayBaseIcon +import com.example.designsystem.component.button.type.CircleButtonType +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DPlayCircleButton( + circleButtonType: CircleButtonType, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .size(circleButtonType.containerSize) + .noRippleClickable(onClick = onClick) + .background( + color = circleButtonType.backgroundColor, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + DplayBaseIcon( + iconRes = circleButtonType.iconRes, + contentDescription = stringResource(circleButtonType.contentDescription), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun DPlayCircleButtonPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayCircleButton( + circleButtonType = + CircleButtonType.SmallPlus( + R.string.add_profile_image_button_icon_description, + ), + onClick = {}, + ) + DPlayCircleButton( + circleButtonType = + CircleButtonType.SmallClose( + R.string.clear_text_button_icon_description, + ), + onClick = {}, + ) + DPlayCircleButton( + circleButtonType = + CircleButtonType.SmallClose( + R.string.close_modal_button_icon_description, + ), + onClick = {}, + ) + DPlayCircleButton( + circleButtonType = + CircleButtonType.SmallEdit( + R.string.edit_profile_image_button_icon_description, + ), + onClick = {}, + ) + DPlayCircleButton( + circleButtonType = + CircleButtonType.LargePlus( + R.string.navigate_to_search_button_icon_description, + ), + onClick = {}, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayKakaoLoginButton.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayKakaoLoginButton.kt new file mode 100644 index 00000000..9aa14e74 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayKakaoLoginButton.kt @@ -0,0 +1,76 @@ +package com.example.designsystem.component.button + +import androidx.compose.foundation.background +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.Spacer +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.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.dplay.designsystem.R +import com.example.designsystem.component.DplayBaseIcon +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun DPlayKakaoLoginButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + DPlayButtonSlot( + modifier = modifier, + onClick = onClick, + paddingValues = PaddingValues(20.dp), + containerColor = DPlayTheme.colors.kakaoYellow, + borderColor = DPlayTheme.colors.kakaoYellow, + ) { + Row( + modifier = + Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + DplayBaseIcon( + iconRes = R.drawable.ic_kakao_24, + ) + + Text( + text = stringResource(R.string.kakao_login_button_label), + style = DPlayTheme.typography.bodyBold16, + color = DPlayTheme.colors.dplayBlack, + ) + + Spacer(modifier = Modifier.size(24.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DPlayKakaoLoginButtonPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayKakaoLoginButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayLargeGrayButton.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayLargeGrayButton.kt new file mode 100644 index 00000000..e8903341 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayLargeGrayButton.kt @@ -0,0 +1,77 @@ +package com.example.designsystem.component.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun DPlayLargeGrayButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + label: String = "", + enabled: Boolean = true, +) { + val containerColor = if (enabled) DPlayTheme.colors.gray600 else DPlayTheme.colors.gray200 + val textColor = if (enabled) DPlayTheme.colors.dplayWhite else DPlayTheme.colors.gray400 + + DPlayButtonSlot( + modifier = modifier, + onClick = onClick, + enabled = enabled, + paddingValues = PaddingValues(vertical = 16.dp), + containerColor = containerColor, + borderColor = containerColor, + ) { + Text( + text = label, + style = DPlayTheme.typography.bodyBold16, + color = textColor, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun DPlayLargeGrayButtonPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayLargeGrayButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.report_button_label), + ) + + DPlayLargeGrayButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.report_button_label), + enabled = false, + ) + + DPlayLargeGrayButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.apply_button_label), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayLargePinkButton.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayLargePinkButton.kt new file mode 100644 index 00000000..1fd2a32f --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayLargePinkButton.kt @@ -0,0 +1,101 @@ +package com.example.designsystem.component.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun DPlayLargePinkButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + label: String = "", + enabled: Boolean = true, +) { + val containerColor = if (enabled) DPlayTheme.colors.dplayPink else DPlayTheme.colors.gray200 + val textColor = if (enabled) DPlayTheme.colors.dplayWhite else DPlayTheme.colors.gray400 + + DPlayButtonSlot( + modifier = modifier, + onClick = onClick, + enabled = enabled, + paddingValues = PaddingValues(vertical = 20.dp), + containerColor = containerColor, + borderColor = containerColor, + ) { + Text( + text = label, + style = DPlayTheme.typography.bodyBold16, + color = textColor, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun DPlayLargePinkButtonPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayLargePinkButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.next_button_label), + ) + + DPlayLargePinkButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.next_button_label), + enabled = false, + ) + + DPlayLargePinkButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.register_button_label), + ) + + DPlayLargePinkButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.enroll_button_label), + ) + + DPlayLargePinkButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.start_button_label), + ) + + DPlayLargePinkButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.confirm_button_label), + ) + + DPlayLargePinkButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.modify_button_label), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayLikeButton.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayLikeButton.kt new file mode 100644 index 00000000..bc512c2d --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayLikeButton.kt @@ -0,0 +1,95 @@ +package com.example.designsystem.component.button + +import androidx.compose.foundation.background +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.Spacer +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.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.dplay.designsystem.R +import com.example.designsystem.component.DplayBaseIcon +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun DPlayLikeButton( + isLiked: Boolean, + likeCount: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val containerColor = if (isLiked) DPlayTheme.colors.dplayPink100 else DPlayTheme.colors.dplayWhite + val iconRes = if (isLiked) R.drawable.ic_heart_pink_filled_24 else R.drawable.ic_heart_pink_unfilled_24 + val stringRes = if (isLiked) R.string.like_button_filled_heart_description else R.string.like_button_unfilled_heart_description + + DPlayButtonSlot( + modifier = modifier, + onClick = onClick, + paddingValues = PaddingValues(vertical = 12.dp), + containerColor = containerColor, + borderColor = DPlayTheme.colors.dplayPink, + content = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + DplayBaseIcon( + iconRes = iconRes, + contentDescription = stringResource(stringRes), + ) + + Spacer( + modifier = Modifier.size(8.dp), + ) + + Text( + text = likeCount.toString(), + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.dplayPink, + ) + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +fun DPlayLikeButtonPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + DPlayLikeButton( + isLiked = false, + likeCount = 0, + onClick = {}, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.size(8.dp)) + + DPlayLikeButton( + isLiked = true, + likeCount = 1, + onClick = {}, + modifier = Modifier.weight(1f), + ) + } + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlaySmallGrayButton.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlaySmallGrayButton.kt new file mode 100644 index 00000000..d174eed5 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlaySmallGrayButton.kt @@ -0,0 +1,59 @@ +package com.example.designsystem.component.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun DPlaySmallGrayButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + label: String = "", +) { + DPlayButtonSlot( + modifier = modifier, + onClick = onClick, + paddingValues = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + containerColor = DPlayTheme.colors.gray600, + borderColor = DPlayTheme.colors.gray600, + shape = RoundedCornerShape(8.dp), + ) { + Text( + text = label, + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.dplayWhite, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun DPlaySmallGrayButtonPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlaySmallGrayButton( + onClick = {}, + label = stringResource(R.string.recommend_music_button_label), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayStreamingButton.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayStreamingButton.kt new file mode 100644 index 00000000..eb9dbb53 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayStreamingButton.kt @@ -0,0 +1,90 @@ +package com.example.designsystem.component.button + +import androidx.compose.foundation.background +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.Spacer +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.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.dplay.designsystem.R +import com.example.designsystem.component.DplayBaseIcon +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun DPlayStreamingButton( + onClick: () -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + val containerColor = if (enabled) DPlayTheme.colors.dplayPink else DPlayTheme.colors.dplayPink300 + DPlayButtonSlot( + modifier = modifier, + enabled = enabled, + onClick = onClick, + paddingValues = PaddingValues(vertical = 8.dp), + containerColor = containerColor, + borderColor = containerColor, + content = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + DplayBaseIcon( + iconRes = R.drawable.ic_stream_white_32, + contentDescription = stringResource(R.string.streaming_button_icon_description), + ) + + Spacer( + modifier = Modifier.size(8.dp), + ) + + Text( + text = stringResource(R.string.streaming_button_label), + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.dplayWhite, + ) + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +fun DPlayStreamingButtonPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + DPlayStreamingButton( + onClick = {}, + enabled = true, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.size(8.dp)) + + DPlayStreamingButton( + onClick = {}, + enabled = false, + modifier = Modifier.weight(1f), + ) + } + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayToggle.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayToggle.kt new file mode 100644 index 00000000..6ba6c3d2 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayToggle.kt @@ -0,0 +1,113 @@ +package com.example.designsystem.component.button + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.updateTransition +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.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +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.foundation.shape.RoundedCornerShape +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.example.designsystem.component.button.constant.DPlayToggleDefaults +import com.example.designsystem.component.button.constant.SwitchColors +import com.example.designsystem.component.button.constant.SwitchSizes +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DPlayToggle( + onClick: () -> Unit, + isChecked: Boolean, + modifier: Modifier = Modifier, + sizes: SwitchSizes = DPlayToggleDefaults.sizes(), + colors: SwitchColors = DPlayToggleDefaults.colors(), +) { + val transition = updateTransition(isChecked) + + val thumbOffset by transition.animateDp { isChecked -> + if (isChecked) sizes.thumbSize else 0.dp + } + + val containerColor by transition.animateColor { isChecked -> + if (isChecked) colors.checkedContainer else colors.uncheckedContainer + } + + Box( + modifier = + modifier + .width(sizes.containerWidth) + .height(sizes.containerHeight) + .background( + color = containerColor, + shape = RoundedCornerShape(sizes.cornerRadius), + ).noRippleClickable( + onClick = onClick, + role = Role.Switch, + ).padding(sizes.padding), + ) { + Thumb( + thumbOffset = thumbOffset, + thumbSize = sizes.thumbSize, + thumbColor = colors.thumb, + ) + } +} + +@Composable +private fun Thumb( + thumbOffset: Dp, + thumbSize: Dp, + thumbColor: Color, +) { + Box( + modifier = + Modifier + .size(thumbSize) + .offset { IntOffset(x = thumbOffset.roundToPx(), y = 0) } + .background( + color = thumbColor, + shape = CircleShape, + ), + ) +} + +@Preview(showBackground = true) +@Composable +fun DefaultPreview() { + var isChecked by remember { mutableStateOf(false) } + + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayToggle( + onClick = { isChecked = !isChecked }, + isChecked = isChecked, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayUnderlineTextButton.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayUnderlineTextButton.kt new file mode 100644 index 00000000..e8194a3e --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DPlayUnderlineTextButton.kt @@ -0,0 +1,61 @@ +package com.example.designsystem.component.button + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun DPlayUnderlineTextButton( + onClick: () -> Unit, + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = + modifier.noRippleClickable( + onClick = onClick, + ), + style = + DPlayTheme.typography.capMed12.copy( + textDecoration = TextDecoration.Underline, + ), + color = DPlayTheme.colors.gray400, + ) +} + +@Preview(showBackground = true) +@Composable +fun TextButtonPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + DPlayUnderlineTextButton( + onClick = {}, + text = "더 알아보기", + ) + DPlayUnderlineTextButton( + onClick = {}, + text = "기본 이미지로 변경하기", + ) + DPlayUnderlineTextButton( + onClick = {}, + text = "취소 하기", + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/DplayGuidelineButton.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/DplayGuidelineButton.kt new file mode 100644 index 00000000..f76b1360 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/DplayGuidelineButton.kt @@ -0,0 +1,78 @@ +package com.example.designsystem.component.button + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +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.Spacer +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.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.dplay.designsystem.R +import com.example.designsystem.component.DplayBaseIcon +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun DPlayGuidelineButton( + onClick: () -> Unit, + @StringRes textStringRes: Int, + modifier: Modifier = Modifier, +) { + DPlayButtonSlot( + modifier = modifier, + onClick = onClick, + paddingValues = PaddingValues(top = 8.dp, bottom = 8.dp, start = 8.dp, end = 12.dp), + containerColor = DPlayTheme.colors.dplayWhite, + borderColor = DPlayTheme.colors.gray200, + shape = RoundedCornerShape(20.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + DplayBaseIcon( + iconRes = R.drawable.ic_info_20, + contentDescription = stringResource(R.string.guideline_button_icon_description), + ) + + Spacer( + modifier = Modifier.size(4.dp), + ) + + Text( + text = stringResource(textStringRes), + style = DPlayTheme.typography.capMed12, + color = DPlayTheme.colors.gray400, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DPlayGuidelineButtonPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayGuidelineButton( + onClick = {}, + textStringRes = R.string.guideline_button_label, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/ModalButton.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/ModalButton.kt new file mode 100644 index 00000000..514a2414 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/ModalButton.kt @@ -0,0 +1,31 @@ +package com.example.designsystem.component.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.graphics.Color +import androidx.compose.ui.unit.dp +import com.example.designsystem.util.noRippleClickable + +@Composable +fun ModalButton( + backgroundColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + Box( + modifier = + modifier + .background( + color = backgroundColor, + ).noRippleClickable(onClick = onClick) + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + content() + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/constant/DPlayToggleDefaults.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/constant/DPlayToggleDefaults.kt new file mode 100644 index 00000000..531c8cae --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/constant/DPlayToggleDefaults.kt @@ -0,0 +1,50 @@ +package com.example.designsystem.component.button.constant + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.example.designsystem.theme.DPlayTheme + +@Immutable +data class SwitchSizes( + val padding: Dp = 2.dp, + val cornerRadius: Dp = 16.dp, + val thumbSize: Dp = 24.dp, +) { + val containerWidth: Dp get() = (thumbSize + padding) * 2 + val containerHeight: Dp get() = thumbSize + (padding * 2) +} + +@Immutable +data class SwitchColors( + val checkedContainer: Color, + val uncheckedContainer: Color, + val thumb: Color, +) + +object DPlayToggleDefaults { + @Composable + fun colors( + checkedContainer: Color = DPlayTheme.colors.dplayPink, + uncheckedContainer: Color = DPlayTheme.colors.gray300, + thumb: Color = DPlayTheme.colors.dplayWhite, + ): SwitchColors = + SwitchColors( + checkedContainer = checkedContainer, + uncheckedContainer = uncheckedContainer, + thumb = thumb, + ) + + fun sizes( + padding: Dp = 2.dp, + cornerRadius: Dp = 16.dp, + thumbSize: Dp = 24.dp, + ): SwitchSizes = + SwitchSizes( + padding = padding, + cornerRadius = cornerRadius, + thumbSize = thumbSize, + ) +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/button/type/CircleButtonType.kt b/core/designsystem/src/main/java/com/example/designsystem/component/button/type/CircleButtonType.kt new file mode 100644 index 00000000..d8e7ddd5 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/button/type/CircleButtonType.kt @@ -0,0 +1,58 @@ +package com.example.designsystem.component.button.type + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.dplay.designsystem.R +import com.example.designsystem.theme.defaultDPlayColors + +@Immutable +sealed class CircleButtonType { + abstract val containerSize: Dp + abstract val backgroundColor: Color + abstract val iconSize: Dp + abstract val iconTint: Color + abstract val iconRes: Int + abstract val contentDescription: Int + + data class SmallClose( + override val contentDescription: Int = R.string.circle_close_button_icon_default_description, + ) : CircleButtonType() { + override val containerSize = 20.dp + override val backgroundColor = defaultDPlayColors.gray200 + override val iconRes = R.drawable.ic_close_20 + override val iconSize = 20.dp + override val iconTint = defaultDPlayColors.gray400 + } + + data class SmallEdit( + override val contentDescription: Int = R.string.circle_edit_button_icon_default_description, + ) : CircleButtonType() { + override val containerSize = 24.dp + override val backgroundColor = defaultDPlayColors.gray200 + override val iconRes = R.drawable.ic_pen_24 + override val iconSize = 24.dp + override val iconTint = defaultDPlayColors.gray400 + } + + data class SmallPlus( + override val contentDescription: Int = R.string.circle_plus_button_icon_default_description, + ) : CircleButtonType() { + override val containerSize = 28.dp + override val backgroundColor = defaultDPlayColors.dplayBlack + override val iconRes = R.drawable.ic_plus_28 + override val iconSize = 28.dp + override val iconTint = defaultDPlayColors.dplayWhite + } + + data class LargePlus( + override val contentDescription: Int = R.string.circle_plus_button_icon_default_description, + ) : CircleButtonType() { + override val containerSize = 56.dp + override val backgroundColor = defaultDPlayColors.gray600 + override val iconRes = R.drawable.ic_plus_28 + override val iconSize = 28.dp + override val iconTint = defaultDPlayColors.dplayWhite + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/chip/type/DPlayChipType.kt b/core/designsystem/src/main/java/com/example/designsystem/component/chip/type/DPlayChipType.kt new file mode 100644 index 00000000..32df7b33 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/chip/type/DPlayChipType.kt @@ -0,0 +1,23 @@ +package com.example.designsystem.component.chip.type + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.dplay.designsystem.R + +enum class DPlayChipType( + @DrawableRes val drawableRes: Int, + @StringRes val stringRes: Int, +) { + EDITOR( + drawableRes = R.drawable.editor_chip, + stringRes = R.string.chip_editor, + ), + NEW( + drawableRes = R.drawable.new_chip, + stringRes = R.string.chip_new, + ), + BEST( + drawableRes = R.drawable.best_chip, + stringRes = R.string.chip_best, + ), +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/modal/GraphicModal.kt b/core/designsystem/src/main/java/com/example/designsystem/component/modal/GraphicModal.kt new file mode 100644 index 00000000..60bc2614 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/modal/GraphicModal.kt @@ -0,0 +1,157 @@ +package com.example.designsystem.component.modal + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.shape.RoundedCornerShape +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 com.dplay.designsystem.R +import com.example.designsystem.component.DplayBaseIcon +import com.example.designsystem.component.button.ModalButton +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun GraphicModal( + mainText: String, + buttonLabel: String, + onCloseIconClick: () -> Unit, + onButtonClick: () -> Unit, + modifier: Modifier = Modifier, + subText: String? = null, +) { + Box( + modifier = + modifier + .clip( + shape = RoundedCornerShape(12.dp), + ).background( + color = DPlayTheme.colors.dplayWhite, + ), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ModalContent( + mainText = mainText, + subText = subText, + onCloseIconClick = onCloseIconClick, + ) + + ModalButton( + modifier = Modifier.fillMaxWidth(), + backgroundColor = DPlayTheme.colors.gray600, + onClick = onButtonClick, + ) { + Text( + text = buttonLabel, + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.dplayWhite, + ) + } + } + } +} + +@Composable +private fun ModalContent( + mainText: String, + subText: String?, + onCloseIconClick: () -> Unit, +) { + Column( + modifier = + Modifier + .padding(horizontal = 12.dp) + .padding(top = 16.dp, bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Spacer(modifier = Modifier.size(32.dp)) + + Image( + painter = painterResource(R.drawable.img_key), + contentDescription = null, + ) + + Box( + modifier = + Modifier + .size(32.dp) + .noRippleClickable(onClick = onCloseIconClick), + contentAlignment = Alignment.Center, + ) { + DplayBaseIcon( + iconRes = R.drawable.ic_close_24, + contentDescription = stringResource(R.string.graphic_modal_close_icon_description), + tint = DPlayTheme.colors.dplayBlack, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = mainText, + style = DPlayTheme.typography.bodyBold16, + color = DPlayTheme.colors.dplayBlack, + textAlign = TextAlign.Center, + ) + + if (subText != null) { + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = subText, + style = DPlayTheme.typography.bodyMed14, + color = DPlayTheme.colors.gray400, + textAlign = TextAlign.Center, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun GraphicModalPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background( + color = DPlayTheme.colors.dplayBlack, + ).padding(horizontal = 40.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + GraphicModal( + mainText = stringResource(R.string.recommend_prompt_modal_main_text), + subText = stringResource(R.string.recommend_prompt_modal_sub_text), + buttonLabel = stringResource(R.string.recommend_prompt_modal_button_label), + onCloseIconClick = {}, + onButtonClick = {}, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/modal/WarningModal.kt b/core/designsystem/src/main/java/com/example/designsystem/component/modal/WarningModal.kt new file mode 100644 index 00000000..7db64e77 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/modal/WarningModal.kt @@ -0,0 +1,264 @@ +package com.example.designsystem.component.modal + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +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.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.shadow.Shadow +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.dplay.designsystem.R +import com.example.designsystem.component.DplayBaseIcon +import com.example.designsystem.component.button.ModalButton +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable + +@Composable +fun WarningModal( + mainText: String, + leftButtonLabel: String, + rightButtonLabel: String, + modifier: Modifier = Modifier, + subText: String? = null, + onLeftButtonClick: () -> Unit = {}, + onRightButtonClick: () -> Unit = {}, +) { + val warningModalShape = RoundedCornerShape(12.dp) + + Box( + modifier = + modifier + .dropShadow( + shape = warningModalShape, + shadow = + Shadow( + radius = 20.dp, + alpha = 0.15f, + ), + ).clip( + shape = warningModalShape, + ).background( + color = DPlayTheme.colors.dplayWhite, + ), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ModalContent( + mainText = mainText, + subText = subText, + ) + + ActionRow( + leftButtonLabel = leftButtonLabel, + rightButtonLabel = rightButtonLabel, + onLeftButtonClick = onLeftButtonClick, + onRightButtonClick = onRightButtonClick, + ) + } + } +} + +@Composable +private fun ModalContent( + mainText: String, + subText: String?, +) { + Column( + modifier = + Modifier + .padding(horizontal = 12.dp) + .padding(top = 16.dp, bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DplayBaseIcon( + iconRes = R.drawable.ic_warning_40, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = mainText, + style = DPlayTheme.typography.bodyBold16, + color = DPlayTheme.colors.dplayBlack, + textAlign = TextAlign.Center, + ) + + if (subText != null) { + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = subText, + style = DPlayTheme.typography.bodyMed14, + color = DPlayTheme.colors.gray400, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun ActionRow( + leftButtonLabel: String, + rightButtonLabel: String, + onLeftButtonClick: () -> Unit = {}, + onRightButtonClick: () -> Unit = {}, +) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + ModalButton( + modifier = Modifier.weight(1f), + backgroundColor = DPlayTheme.colors.gray100, + onClick = onLeftButtonClick, + ) { + Text( + text = leftButtonLabel, + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.gray400, + ) + } + + ModalButton( + modifier = Modifier.weight(1f), + backgroundColor = DPlayTheme.colors.gray600, + onClick = onRightButtonClick, + ) { + Text( + text = rightButtonLabel, + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.dplayWhite, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun WarningModalPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background( + color = DPlayTheme.colors.dplayWhite, + ).padding(horizontal = 40.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + WarningModal( + mainText = stringResource(R.string.delete_modal_main_text), + leftButtonLabel = stringResource(R.string.delete_modal_cancel_button_label), + rightButtonLabel = stringResource(R.string.delete_modal_delete_button_label), + ) + + WarningModal( + mainText = stringResource(R.string.logout_modal_main_text), + leftButtonLabel = stringResource(R.string.logout_modal_cancel_button_label), + rightButtonLabel = stringResource(R.string.logout_modal_logout_button_label), + ) + + WarningModal( + mainText = stringResource(R.string.withdraw_modal_main_text), + subText = stringResource(R.string.withdraw_modal_sub_text), + leftButtonLabel = stringResource(R.string.withdraw_modal_withdraw_button_label), + rightButtonLabel = stringResource(R.string.withdraw_modal_cancel_button_label), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ModalInteractionPreview() { + var isVisible by remember { mutableStateOf(false) } + + BackHandler(enabled = isVisible) { + isVisible = false + } + + DPlayTheme { + Box( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite), + ) { + Column( + modifier = Modifier.align(Alignment.TopCenter), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button(onClick = { isVisible = true }) { + Text("모달 열기") + } + Button(onClick = { isVisible = true }) { + Text("다른 모달 열기") + } + } + + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(animationSpec = tween(durationMillis = 1000)), + exit = fadeOut(animationSpec = tween(durationMillis = 1000)), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayBlack.copy(alpha = 0.6f)) + .noRippleClickable { isVisible = false }, + ) + } + + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(animationSpec = tween(200)) + scaleIn(initialScale = 0.5f, animationSpec = tween(200)), + exit = fadeOut(animationSpec = tween(150)) + scaleOut(targetScale = 0.97f, animationSpec = tween(150)), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 40.dp), + contentAlignment = Alignment.Center, + ) { + WarningModal( + mainText = stringResource(R.string.withdraw_modal_main_text), + subText = stringResource(R.string.withdraw_modal_sub_text), + leftButtonLabel = stringResource(R.string.withdraw_modal_withdraw_button_label), + rightButtonLabel = stringResource(R.string.withdraw_modal_cancel_button_label), + onLeftButtonClick = { isVisible = false }, + onRightButtonClick = { isVisible = false }, + ) + } + } + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/snackbar/DPlaySnackbar.kt b/core/designsystem/src/main/java/com/example/designsystem/component/snackbar/DPlaySnackbar.kt new file mode 100644 index 00000000..21a728b8 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/snackbar/DPlaySnackbar.kt @@ -0,0 +1,144 @@ +package com.example.designsystem.component.snackbar + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.material3.Icon +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.designsystem.component.snackbar.type.SnackBarType +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable +import kotlinx.coroutines.delay + +private const val SNACKBAR_DISPLAY_DURATION_MILLS = 2000L +private const val SNACKBAR_ANIMATION_DURATION_MILLS = 200 + +@Composable +fun DPlaySnackBar( + type: SnackBarType, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onActionClick: (() -> Unit)? = null, +) { + var isVisible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + isVisible = true + delay(SNACKBAR_DISPLAY_DURATION_MILLS) + isVisible = false + delay(SNACKBAR_ANIMATION_DURATION_MILLS.toLong()) + onDismiss() + } + + AnimatedVisibility( + visible = isVisible, + enter = + slideInVertically( + initialOffsetY = { it * 2 }, + animationSpec = tween(durationMillis = SNACKBAR_ANIMATION_DURATION_MILLS), + ), + exit = + slideOutVertically( + targetOffsetY = { it * 2 }, + animationSpec = tween(durationMillis = SNACKBAR_ANIMATION_DURATION_MILLS), + ), + modifier = modifier, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(shape = RoundedCornerShape(8.dp)) + .background(color = DPlayTheme.colors.gray500) + .padding(horizontal = 12.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(type.iconRes), + contentDescription = null, + tint = Color.Unspecified, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = stringResource(type.titleRes), + color = DPlayTheme.colors.dplayWhite, + style = DPlayTheme.typography.bodyMed14, + modifier = Modifier.weight(1f), + ) + + type.actionStringRes?.let { actionRes -> + Text( + text = stringResource(actionRes), + color = DPlayTheme.colors.dplayPink, + style = DPlayTheme.typography.bodyBold14, + modifier = Modifier.noRippleClickable { onActionClick?.invoke() }, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DPlaySnackBarAddPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + ) { + DPlaySnackBar( + type = SnackBarType.ADD, + onActionClick = {}, + onDismiss = {}, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DPlaySnackBarStreamingNotSupportPreview() { + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + ) { + DPlaySnackBar( + type = SnackBarType.STREAMING_NOT_SUPPORT, + onActionClick = null, + onDismiss = {}, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/snackbar/LocalShowSnackBar.kt b/core/designsystem/src/main/java/com/example/designsystem/component/snackbar/LocalShowSnackBar.kt new file mode 100644 index 00000000..ee4f3b62 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/snackbar/LocalShowSnackBar.kt @@ -0,0 +1,7 @@ +package com.example.designsystem.component.snackbar + +import androidx.compose.runtime.compositionLocalOf +import com.example.designsystem.component.snackbar.type.SnackBarType + +val LocalSnackBarState = compositionLocalOf { null } +val LocalShowSnackBar = compositionLocalOf<(SnackBarType, (() -> Unit)?) -> Unit> { { _, _ -> } } diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/snackbar/type/SnackBarType.kt b/core/designsystem/src/main/java/com/example/designsystem/component/snackbar/type/SnackBarType.kt new file mode 100644 index 00000000..786be93e --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/snackbar/type/SnackBarType.kt @@ -0,0 +1,22 @@ +package com.example.designsystem.component.snackbar.type + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.dplay.designsystem.R + +enum class SnackBarType( + @param:DrawableRes val iconRes: Int, + @param:StringRes val titleRes: Int, + @param:StringRes val actionStringRes: Int? = null, +) { + ADD( + iconRes = R.drawable.ic_check_circle_pink_24, + titleRes = R.string.snackbar_message_add_collection, + actionStringRes = R.string.snackbar_action_message_navigate_collection, + ), + STREAMING_NOT_SUPPORT( + iconRes = R.drawable.ic_warning_24, + titleRes = R.string.snackbar_message_streaming_not_support, + actionStringRes = null, + ), +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/textfield/CharacterCounterText.kt b/core/designsystem/src/main/java/com/example/designsystem/component/textfield/CharacterCounterText.kt new file mode 100644 index 00000000..ab17cc0d --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/textfield/CharacterCounterText.kt @@ -0,0 +1,22 @@ +package com.example.designsystem.component.textfield + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme + +@Composable +fun CharacterCounterText( + currentLength: Int, + maxLength: Int, + modifier: Modifier = Modifier, +) { + Text( + text = stringResource(R.string.text_field_character_counter, currentLength, maxLength), + modifier = modifier, + color = DPlayTheme.colors.gray400, + style = DPlayTheme.typography.capMed12, + ) +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/textfield/DPlayTextArea.kt b/core/designsystem/src/main/java/com/example/designsystem/component/textfield/DPlayTextArea.kt new file mode 100644 index 00000000..4fa72d73 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/textfield/DPlayTextArea.kt @@ -0,0 +1,128 @@ +package com.example.designsystem.component.textfield + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.TextFieldConstant + +private const val TEXT_AREA_ASPECT_RATIO = 343f / 180f + +@Composable +fun DPlayTextArea( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "", + maxLength: Int = TextFieldConstant.MAX_COMMENT_LENGTH, + onEnterClick: () -> Unit = {}, + onFocusChange: (Boolean) -> Unit = {}, +) { + val focusManager = LocalFocusManager.current + + BasicTextField( + value = value, + onValueChange = { + if (it.length <= maxLength) onValueChange(it) + }, + modifier = + modifier + .fillMaxWidth() + .onFocusChanged { + onFocusChange(it.isFocused) + }.background( + color = DPlayTheme.colors.gray100, + shape = RoundedCornerShape(16.dp), + ).aspectRatio(TEXT_AREA_ASPECT_RATIO) + .padding(vertical = 16.dp, horizontal = 12.dp), + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { + onEnterClick() + focusManager.clearFocus(force = true) + }, + ), + textStyle = DPlayTheme.typography.bodySemi14.copy(color = DPlayTheme.colors.dplayBlack), + cursorBrush = SolidColor(value = DPlayTheme.colors.dplayPink), + decorationBox = { innerTextField -> + Column( + modifier = + Modifier + .fillMaxWidth(), + ) { + Box( + modifier = Modifier.weight(1f), + ) { + if (value.isEmpty()) { + Text( + text = placeholder, + color = DPlayTheme.colors.gray400, + style = DPlayTheme.typography.bodySemi16, + ) + } + + innerTextField() + } + + Spacer(modifier = Modifier.size(4.dp)) + + CharacterCounterText( + currentLength = value.length, + maxLength = maxLength, + modifier = Modifier.align(alignment = Alignment.End), + ) + } + }, + ) +} + +@Preview +@Composable +fun DPlayTextAreaPreview() { + var text by remember { mutableStateOf("") } + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite) + .padding(16.dp), + ) { + DPlayTextArea( + value = text, + onValueChange = { text = it }, + placeholder = stringResource(id = R.string.placeholder_comment), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/textfield/DPlayTextInput.kt b/core/designsystem/src/main/java/com/example/designsystem/component/textfield/DPlayTextInput.kt new file mode 100644 index 00000000..d4bce8d7 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/textfield/DPlayTextInput.kt @@ -0,0 +1,278 @@ +package com.example.designsystem.component.textfield + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Spacer +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.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +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.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dplay.designsystem.R +import com.example.designsystem.component.textfield.type.InputState +import com.example.designsystem.component.textfield.type.NicknameInputState +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.TextFieldConstant + +@Composable +fun DPlayTextInput( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + inputState: InputState = InputState.Default, + placeholder: String = "", + maxLength: Int? = null, + onEnterClick: () -> Unit = {}, + onFocusChange: (Boolean) -> Unit = {}, +) { + val focusManager = LocalFocusManager.current + val borderModifier = + when (inputState) { + is InputState.Error -> + Modifier.border( + width = 1.dp, + color = DPlayTheme.colors.alertRed, + shape = RoundedCornerShape(16.dp), + ) + is InputState.Success -> + Modifier.border( + width = 1.dp, + color = DPlayTheme.colors.infoBlue, + shape = RoundedCornerShape(16.dp), + ) + is InputState.Default -> Modifier + } + + Column(modifier = modifier) { + BasicTextField( + value = value, + onValueChange = { + if (maxLength == null || it.length <= maxLength) onValueChange(it) + }, + modifier = + Modifier + .fillMaxWidth() + .onFocusChanged { + onFocusChange(it.isFocused) + }.background( + color = DPlayTheme.colors.gray100, + shape = RoundedCornerShape(16.dp), + ).then(borderModifier) + .padding(vertical = 16.dp, horizontal = 12.dp), + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { + onEnterClick() + focusManager.clearFocus(force = true) + }, + ), + singleLine = true, + textStyle = DPlayTheme.typography.bodySemi16.copy(color = DPlayTheme.colors.dplayBlack), + cursorBrush = SolidColor(value = DPlayTheme.colors.dplayPink), + decorationBox = { innerTextField -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .weight(1f), + ) { + if (value.isEmpty()) { + Text( + text = placeholder, + color = DPlayTheme.colors.gray400, + style = DPlayTheme.typography.bodySemi16, + ) + } + + innerTextField() + } + + if (value.isNotEmpty()) { + Spacer(modifier = Modifier.size(8.dp)) + + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_close_20), + contentDescription = stringResource(R.string.text_input_clear_description), + tint = DPlayTheme.colors.gray400, + modifier = + Modifier + .background( + color = DPlayTheme.colors.gray200, + shape = CircleShape, + ).clickable( + role = Role.Button, + ) { onValueChange("") }, + ) + } + } + }, + ) + + Spacer(modifier = Modifier.size(4.dp)) + + if (inputState !is InputState.Default || maxLength != null) { + GuidelineRow( + inputState = inputState, + maxLength = maxLength, + value = value, + ) + } + } +} + +@Composable +private fun GuidelineRow( + inputState: InputState, + maxLength: Int?, + value: String, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (inputState) { + is InputState.Error -> { + Text( + text = inputState.getMessage(), + color = DPlayTheme.colors.alertRed, + style = DPlayTheme.typography.capMed12, + ) + } + + is InputState.Success -> { + Text( + text = inputState.getMessage(), + color = DPlayTheme.colors.infoBlue, + style = DPlayTheme.typography.capMed12, + ) + } + + is InputState.Default -> Unit + } + + Spacer(modifier = Modifier.weight(1f)) + + if (maxLength != null) { + CharacterCounterText( + currentLength = value.length, + maxLength = maxLength, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DPlayTextInputPreview() { + var nickname by remember { mutableStateOf("") } + var music by remember { mutableStateOf("") } + + DPlayTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .background(color = Color.White) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DPlayTextInput( + value = nickname, + onValueChange = { nickname = it }, + onFocusChange = {}, + placeholder = stringResource(R.string.placeholder_nickname), + maxLength = TextFieldConstant.MAX_NICKNAME_LENGTH, + ) + + DPlayTextInput( + value = nickname, + onValueChange = { nickname = it }, + onFocusChange = {}, + inputState = NicknameInputState.Success, + placeholder = stringResource(R.string.placeholder_nickname), + maxLength = TextFieldConstant.MAX_NICKNAME_LENGTH, + ) + + DPlayTextInput( + value = nickname, + onValueChange = { nickname = it }, + onFocusChange = {}, + inputState = NicknameInputState.Error.AlreadyExists, + placeholder = stringResource(R.string.placeholder_nickname), + maxLength = TextFieldConstant.MAX_NICKNAME_LENGTH, + ) + + DPlayTextInput( + value = nickname, + onValueChange = { nickname = it }, + onFocusChange = {}, + inputState = NicknameInputState.Error.NotEnoughLength, + placeholder = stringResource(R.string.placeholder_nickname), + maxLength = TextFieldConstant.MAX_NICKNAME_LENGTH, + ) + + DPlayTextInput( + value = nickname, + onValueChange = { nickname = it }, + onFocusChange = {}, + inputState = NicknameInputState.Error.InvalidFormat, + placeholder = stringResource(R.string.placeholder_nickname), + maxLength = TextFieldConstant.MAX_NICKNAME_LENGTH, + ) + + DPlayTextInput( + value = nickname, + onValueChange = { nickname = it }, + onFocusChange = {}, + inputState = NicknameInputState.Error.ForbiddenWord, + placeholder = stringResource(R.string.placeholder_nickname), + maxLength = TextFieldConstant.MAX_NICKNAME_LENGTH, + ) + + DPlayTextInput( + value = music, + onValueChange = { music = it }, + placeholder = stringResource(R.string.placeholder_music_search), + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/component/textfield/type/InputState.kt b/core/designsystem/src/main/java/com/example/designsystem/component/textfield/type/InputState.kt new file mode 100644 index 00000000..61d60ddd --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/component/textfield/type/InputState.kt @@ -0,0 +1,51 @@ +package com.example.designsystem.component.textfield.type + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.stringResource +import com.dplay.designsystem.R +import com.example.designsystem.util.TextFieldConstant + +sealed class InputState { + data object Default : InputState() + + sealed class Error : InputState() { + @Composable + abstract fun getMessage(): String + } + + sealed class Success : InputState() { + @Composable + abstract fun getMessage(): String + } +} + +@Immutable +sealed class NicknameInputState : InputState() { + sealed class Error : InputState.Error() { + data object NotEnoughLength : Error() { + @Composable + override fun getMessage(): String = stringResource(R.string.nickname_error_not_enough_length, TextFieldConstant.MIN_NICKNAME_LENGTH) + } + + data object InvalidFormat : Error() { + @Composable + override fun getMessage(): String = stringResource(R.string.nickname_error_invalid_format) + } + + data object AlreadyExists : Error() { + @Composable + override fun getMessage(): String = stringResource(R.string.nickname_error_already_exists) + } + + data object ForbiddenWord : Error() { + @Composable + override fun getMessage(): String = stringResource(R.string.nickname_error_forbidden_word) + } + } + + data object Success : InputState.Success() { + @Composable + override fun getMessage(): String = stringResource(R.string.nickname_success_message) + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/example/designsystem/theme/Color.kt new file mode 100644 index 00000000..3aa017be --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/theme/Color.kt @@ -0,0 +1,78 @@ +package com.example.designsystem.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +// Primary +val Dplay_pink = Color(0xFFFF448B) +val Dplay_pink300 = Color(0xFFFF8FBA) +val Dplay_pink100 = Color(0xFFFFDAE8) + +// GrayScale +val Dplay_white = Color(0xFFFFFFFF) +val Gray100 = Color(0xFFF7F8FC) +val Gray200 = Color(0xFFE5E7F0) +val Gray300 = Color(0xFFC7CCD3) +val Gray400 = Color(0xFF7F8A96) +val Gray500 = Color(0xFF4A555E) +val Gray600 = Color(0xFF31393F) +val Dplay_black = Color(0xFF14181B) + +// Symentic +val Alert_red = Color(0xFFFC4649) +val Info_blue = Color(0xFF2C8BFF) +val Kakao_yellow = Color(0xFFFEE500) + +// Transparent +val Dplay_pink_trans = Color(0xCCFF8FBA) +val Dplay_gray_trans = Color(0xCCD9D9D9) + +val Dim_40 = Color(0x6614181B) +val Dim_80 = Color(0xCC14181B) + +@Immutable +data class DPlayColors( + val dplayPink: Color, + val dplayPink300: Color, + val dplayPink100: Color, + val dplayWhite: Color, + val gray100: Color, + val gray200: Color, + val gray300: Color, + val gray400: Color, + val gray500: Color, + val gray600: Color, + val dplayBlack: Color, + val alertRed: Color, + val infoBlue: Color, + val kakaoYellow: Color, + val dplayPinkTrans: Color, + val dplayGrayTrans: Color, + val dim40: Color, + val dim80: Color, +) + +val defaultDPlayColors = + DPlayColors( + dplayPink = Dplay_pink, + dplayPink300 = Dplay_pink300, + dplayPink100 = Dplay_pink100, + dplayWhite = Dplay_white, + gray100 = Gray100, + gray200 = Gray200, + gray300 = Gray300, + gray400 = Gray400, + gray500 = Gray500, + gray600 = Gray600, + dplayBlack = Dplay_black, + alertRed = Alert_red, + infoBlue = Info_blue, + kakaoYellow = Kakao_yellow, + dplayPinkTrans = Dplay_pink_trans, + dplayGrayTrans = Dplay_gray_trans, + dim40 = Dim_40, + dim80 = Dim_80, + ) + +val LocalDPlayColors = staticCompositionLocalOf { defaultDPlayColors } diff --git a/core/designsystem/src/main/java/com/example/designsystem/theme/Theme.kt b/core/designsystem/src/main/java/com/example/designsystem/theme/Theme.kt new file mode 100644 index 00000000..8179203c --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.example.designsystem.theme + +import android.app.Activity +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +object DPlayTheme { + val colors: DPlayColors + @Composable + @ReadOnlyComposable + get() = LocalDPlayColors.current + + val typography: DPlayTypography + @Composable + @ReadOnlyComposable + get() = LocalDPlayTypography.current +} + +@Composable +fun ProvideDPlayColorsAndTypography( + colors: DPlayColors, + typography: DPlayTypography, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalDPlayColors provides colors, + LocalDPlayTypography provides typography, + content = content, + ) +} + +@Composable +fun DPlayTheme( + content: @Composable () -> Unit, +) { + ProvideDPlayColorsAndTypography( + colors = defaultDPlayColors, + typography = defaultDPlayTypography, + ) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + (view.context as Activity).window.run { + WindowCompat.getInsetsController(this, view).isAppearanceLightStatusBars = true + } + } + } + + MaterialTheme( + content = content, + ) + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/theme/Type.kt b/core/designsystem/src/main/java/com/example/designsystem/theme/Type.kt new file mode 100644 index 00000000..8bfefd9f --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/theme/Type.kt @@ -0,0 +1,145 @@ +package com.example.designsystem.theme + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +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.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import com.dplay.designsystem.R + +val SuitBold = FontFamily(Font(R.font.suit_bold)) +val SuitSemiBold = FontFamily(Font(R.font.suit_semibold)) +val SuitMedium = FontFamily(Font(R.font.suit_medium)) + +@Immutable +data class DPlayTypography( + val titleBold24: TextStyle, + val bodyBold20: TextStyle, + val bodySemi20: TextStyle, + val titleBold18: TextStyle, + val bodyBold16: TextStyle, + val bodySemi16: TextStyle, + val bodyMed16: TextStyle, + val bodyBold14: TextStyle, + val bodySemi14: TextStyle, + val bodyMed14: TextStyle, + val capMed12: TextStyle, +) + +val defaultDPlayTypography = + DPlayTypography( + titleBold24 = + TextStyle( + fontFamily = SuitBold, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = (31.2).sp, + letterSpacing = 0.sp, + ), + bodyBold20 = + TextStyle( + fontFamily = SuitBold, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + lineHeight = 25.sp, + letterSpacing = (-0.03).sp, + ), + bodySemi20 = + TextStyle( + fontFamily = SuitSemiBold, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 25.sp, + letterSpacing = (-0.03).sp, + ), + titleBold18 = + TextStyle( + fontFamily = SuitBold, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + lineHeight = (23.4).sp, + letterSpacing = 0.sp, + ), + bodyBold16 = + TextStyle( + fontFamily = SuitBold, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + lineHeight = (20.8).sp, + letterSpacing = 0.sp, + ), + bodySemi16 = + TextStyle( + fontFamily = SuitSemiBold, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = (20.8).sp, + letterSpacing = 0.sp, + ), + bodyMed16 = + TextStyle( + fontFamily = SuitMedium, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = (20.8).sp, + letterSpacing = 0.sp, + ), + bodyBold14 = + TextStyle( + fontFamily = SuitBold, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = (19.6).sp, + letterSpacing = 0.sp, + ), + bodySemi14 = + TextStyle( + fontFamily = SuitSemiBold, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = (19.6).sp, + letterSpacing = 0.sp, + ), + bodyMed14 = + TextStyle( + fontFamily = SuitMedium, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = (19.6).sp, + letterSpacing = 0.sp, + ), + capMed12 = + TextStyle( + fontFamily = SuitMedium, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = (15.6).sp, + letterSpacing = 0.sp, + ), + ) + +val LocalDPlayTypography = staticCompositionLocalOf { defaultDPlayTypography } + +@Preview(showBackground = true) +@Composable +fun DPlayTypographyPreview() { + DPlayTheme { + Column { + Text(text = "titleBold24", style = DPlayTheme.typography.titleBold24) + Text(text = "titleBold18", style = DPlayTheme.typography.titleBold18) + Text(text = "bodyBold16", style = DPlayTheme.typography.bodyBold16) + Text(text = "bodySemi16", style = DPlayTheme.typography.bodySemi16) + Text(text = "bodyMed16", style = DPlayTheme.typography.bodyMed16) + Text(text = "bodyBold14", style = DPlayTheme.typography.bodyBold14) + Text(text = "bodySemi14", style = DPlayTheme.typography.bodySemi14) + Text(text = "bodyMed14", style = DPlayTheme.typography.bodyMed14) + Text(text = "capMed12", style = DPlayTheme.typography.capMed12) + } + } +} diff --git a/core/designsystem/src/main/java/com/example/designsystem/util/Constant.kt b/core/designsystem/src/main/java/com/example/designsystem/util/Constant.kt new file mode 100644 index 00000000..be6a9d32 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/util/Constant.kt @@ -0,0 +1,12 @@ +package com.example.designsystem.util + +// TextField +object TextFieldConstant { + const val MIN_NICKNAME_LENGTH = 2 + const val MAX_NICKNAME_LENGTH = 10 + + const val MAX_COMMENT_LENGTH = 150 +} + +// Dummy +object Dummy diff --git a/core/designsystem/src/main/java/com/example/designsystem/util/ModifierExt.kt b/core/designsystem/src/main/java/com/example/designsystem/util/ModifierExt.kt new file mode 100644 index 00000000..8a322dc5 --- /dev/null +++ b/core/designsystem/src/main/java/com/example/designsystem/util/ModifierExt.kt @@ -0,0 +1,97 @@ +package com.example.designsystem.util + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.FocusManager +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +inline fun Modifier.noRippleClickable( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + crossinline onClick: () -> Unit = {}, +): Modifier = + composed { + this.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + ) { + onClick() + } + } + +fun Modifier.roundedBackgroundWithPadding( + backgroundColor: Color = Color.Unspecified, + cornerRadius: Dp = 0.dp, + padding: PaddingValues = PaddingValues(0.dp), +): Modifier = + this + .background(color = backgroundColor, shape = RoundedCornerShape(cornerRadius)) + .padding(padding) + +fun Modifier.addFocusCleaner( + focusManager: FocusManager, + doOnClear: () -> Unit = {}, +): Modifier = + this.pointerInput(Unit) { + detectTapGestures(onTap = { + doOnClear() + focusManager.clearFocus() + }) + } + +// 추후 추가되는 디자인 값에 맞게 사용 +inline fun Modifier.pressedEffectClickable( + crossinline onClick: () -> Unit = {}, +): Modifier = + composed { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + this + .graphicsLayer { + alpha = if (isPressed) 0.6f else 1f + }.clickable( + interactionSource = interactionSource, + indication = null, + ) { + onClick() + } + } + +fun Modifier.advancedImePadding() = + composed { + var consumePadding by remember { mutableIntStateOf(0) } + onGloballyPositioned { coordinates -> + consumePadding = coordinates.findRootCoordinates().size.height - + (coordinates.positionInWindow().y + coordinates.size.height).toInt() + }.consumeWindowInsets( + PaddingValues(bottom = with(LocalDensity.current) { consumePadding.toDp() }), + ).imePadding() + } diff --git a/core/designsystem/src/main/res/drawable/base_profile_image.png b/core/designsystem/src/main/res/drawable/base_profile_image.png new file mode 100644 index 00000000..27f153b1 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/base_profile_image.png differ diff --git a/core/designsystem/src/main/res/drawable/best_chip.png b/core/designsystem/src/main/res/drawable/best_chip.png new file mode 100644 index 00000000..5cfb0dcb Binary files /dev/null and b/core/designsystem/src/main/res/drawable/best_chip.png differ diff --git a/core/designsystem/src/main/res/drawable/editor_chip.png b/core/designsystem/src/main/res/drawable/editor_chip.png new file mode 100644 index 00000000..cb18bc4a Binary files /dev/null and b/core/designsystem/src/main/res/drawable/editor_chip.png differ diff --git a/core/designsystem/src/main/res/drawable/ic_alert_24.xml b/core/designsystem/src/main/res/drawable/ic_alert_24.xml new file mode 100644 index 00000000..15db4fde --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_alert_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_arrow_down.xml b/core/designsystem/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 00000000..260c2432 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_arrow_left_16.xml b/core/designsystem/src/main/res/drawable/ic_arrow_left_16.xml new file mode 100644 index 00000000..e756a8a7 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_arrow_left_16.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_arrow_right_16.xml b/core/designsystem/src/main/res/drawable/ic_arrow_right_16.xml new file mode 100644 index 00000000..7156182d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_arrow_right_16.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_best_20.xml b/core/designsystem/src/main/res/drawable/ic_best_20.xml new file mode 100644 index 00000000..0cf04a62 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_best_20.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_bookmark_active_32.xml b/core/designsystem/src/main/res/drawable/ic_bookmark_active_32.xml new file mode 100644 index 00000000..f1c3d3d3 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_bookmark_active_32.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_bookmark_disabled_32.xml b/core/designsystem/src/main/res/drawable/ic_bookmark_disabled_32.xml new file mode 100644 index 00000000..ac50fc44 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_bookmark_disabled_32.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_bookmark_filled_24.xml b/core/designsystem/src/main/res/drawable/ic_bookmark_filled_24.xml new file mode 100644 index 00000000..dc8e07cb --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_bookmark_filled_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_bookmark_unfilled_24.xml b/core/designsystem/src/main/res/drawable/ic_bookmark_unfilled_24.xml new file mode 100644 index 00000000..e5a0974d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_bookmark_unfilled_24.xml @@ -0,0 +1,12 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_check_circle_20.xml b/core/designsystem/src/main/res/drawable/ic_check_circle_20.xml new file mode 100644 index 00000000..aeb4324e --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_check_circle_20.xml @@ -0,0 +1,12 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_check_circle_darkgray_24.xml b/core/designsystem/src/main/res/drawable/ic_check_circle_darkgray_24.xml new file mode 100644 index 00000000..5c59b348 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_check_circle_darkgray_24.xml @@ -0,0 +1,15 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_check_circle_lightgray_24.xml b/core/designsystem/src/main/res/drawable/ic_check_circle_lightgray_24.xml new file mode 100644 index 00000000..9198dc21 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_check_circle_lightgray_24.xml @@ -0,0 +1,15 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_check_circle_pink_24.xml b/core/designsystem/src/main/res/drawable/ic_check_circle_pink_24.xml new file mode 100644 index 00000000..0a66e339 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_check_circle_pink_24.xml @@ -0,0 +1,15 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_close_20.xml b/core/designsystem/src/main/res/drawable/ic_close_20.xml new file mode 100644 index 00000000..c67f0e1a --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_close_20.xml @@ -0,0 +1,18 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_close_24.xml b/core/designsystem/src/main/res/drawable/ic_close_24.xml new file mode 100644 index 00000000..1e4aa4ce --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_close_24.xml @@ -0,0 +1,18 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_editor_20.xml b/core/designsystem/src/main/res/drawable/ic_editor_20.xml new file mode 100644 index 00000000..a3f44bc0 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_editor_20.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_heart_pink_filled_24.xml b/core/designsystem/src/main/res/drawable/ic_heart_pink_filled_24.xml new file mode 100644 index 00000000..050158b7 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_heart_pink_filled_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_heart_pink_unfilled_24.xml b/core/designsystem/src/main/res/drawable/ic_heart_pink_unfilled_24.xml new file mode 100644 index 00000000..1afd2a0d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_heart_pink_unfilled_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_heart_white_filled_24.xml b/core/designsystem/src/main/res/drawable/ic_heart_white_filled_24.xml new file mode 100644 index 00000000..0eceda4b --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_heart_white_filled_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_heart_white_unfilled_24.xml b/core/designsystem/src/main/res/drawable/ic_heart_white_unfilled_24.xml new file mode 100644 index 00000000..bb8fbde7 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_heart_white_unfilled_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_home_active_32.xml b/core/designsystem/src/main/res/drawable/ic_home_active_32.xml new file mode 100644 index 00000000..58e1b66e --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_home_active_32.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_home_disabled_32.xml b/core/designsystem/src/main/res/drawable/ic_home_disabled_32.xml new file mode 100644 index 00000000..17197c69 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_home_disabled_32.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_info_20.xml b/core/designsystem/src/main/res/drawable/ic_info_20.xml new file mode 100644 index 00000000..9405cfdf --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_info_20.xml @@ -0,0 +1,20 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_kakao_24.xml b/core/designsystem/src/main/res/drawable/ic_kakao_24.xml new file mode 100644 index 00000000..cd1bbb02 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_kakao_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_list_24.xml b/core/designsystem/src/main/res/drawable/ic_list_24.xml new file mode 100644 index 00000000..224ec4d8 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_list_24.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_lock_140.xml b/core/designsystem/src/main/res/drawable/ic_lock_140.xml new file mode 100644 index 00000000..e678539c --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_lock_140.xml @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_lock_42.xml b/core/designsystem/src/main/res/drawable/ic_lock_42.xml new file mode 100644 index 00000000..78c5b397 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_lock_42.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_more_24.xml b/core/designsystem/src/main/res/drawable/ic_more_24.xml new file mode 100644 index 00000000..b353aaf3 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_more_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_more_gray_20.xml b/core/designsystem/src/main/res/drawable/ic_more_gray_20.xml new file mode 100644 index 00000000..a32ee3c9 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_more_gray_20.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_new_20.xml b/core/designsystem/src/main/res/drawable/ic_new_20.xml new file mode 100644 index 00000000..4fa33222 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_new_20.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_pen_24.xml b/core/designsystem/src/main/res/drawable/ic_pen_24.xml new file mode 100644 index 00000000..eb50d760 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_pen_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_plus_28.xml b/core/designsystem/src/main/res/drawable/ic_plus_28.xml new file mode 100644 index 00000000..955b89e4 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_plus_28.xml @@ -0,0 +1,18 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_quote_down_16.xml b/core/designsystem/src/main/res/drawable/ic_quote_down_16.xml new file mode 100644 index 00000000..791f6990 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_quote_down_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_quote_up_16.xml b/core/designsystem/src/main/res/drawable/ic_quote_up_16.xml new file mode 100644 index 00000000..d96aa3cf --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_quote_up_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_refresh_20.xml b/core/designsystem/src/main/res/drawable/ic_refresh_20.xml new file mode 100644 index 00000000..adbf749d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_refresh_20.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_setting_24.xml b/core/designsystem/src/main/res/drawable/ic_setting_24.xml new file mode 100644 index 00000000..5f9fc55e --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_setting_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_stream_pink_32.xml b/core/designsystem/src/main/res/drawable/ic_stream_pink_32.xml new file mode 100644 index 00000000..13951dbf --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_stream_pink_32.xml @@ -0,0 +1,20 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_stream_white_32.xml b/core/designsystem/src/main/res/drawable/ic_stream_white_32.xml new file mode 100644 index 00000000..3b0f7728 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_stream_white_32.xml @@ -0,0 +1,20 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_symbol_20.xml b/core/designsystem/src/main/res/drawable/ic_symbol_20.xml new file mode 100644 index 00000000..05fd9e32 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_symbol_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_warning_24.xml b/core/designsystem/src/main/res/drawable/ic_warning_24.xml new file mode 100644 index 00000000..fa0b0a92 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_warning_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_warning_40.xml b/core/designsystem/src/main/res/drawable/ic_warning_40.xml new file mode 100644 index 00000000..2f632fce --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_warning_40.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_wordmark_pink.xml b/core/designsystem/src/main/res/drawable/ic_wordmark_pink.xml new file mode 100644 index 00000000..e115733b --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_wordmark_pink.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/img_key.png b/core/designsystem/src/main/res/drawable/img_key.png new file mode 100644 index 00000000..91a9ca0b Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_key.png differ diff --git a/core/designsystem/src/main/res/drawable/img_profile.png b/core/designsystem/src/main/res/drawable/img_profile.png new file mode 100644 index 00000000..ac48d619 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_profile.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..e9d9c383 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_warning.png differ diff --git a/core/designsystem/src/main/res/drawable/img_wordmark_pink.png b/core/designsystem/src/main/res/drawable/img_wordmark_pink.png new file mode 100644 index 00000000..f089d6d9 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_wordmark_pink.png differ diff --git a/core/designsystem/src/main/res/drawable/img_wordmark_white.png b/core/designsystem/src/main/res/drawable/img_wordmark_white.png new file mode 100644 index 00000000..7d271bc9 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_wordmark_white.png differ diff --git a/core/designsystem/src/main/res/drawable/new_chip.png b/core/designsystem/src/main/res/drawable/new_chip.png new file mode 100644 index 00000000..571324ea Binary files /dev/null and b/core/designsystem/src/main/res/drawable/new_chip.png differ diff --git a/core/designsystem/src/main/res/font/suit_bold.otf b/core/designsystem/src/main/res/font/suit_bold.otf new file mode 100644 index 00000000..7c6bab92 Binary files /dev/null and b/core/designsystem/src/main/res/font/suit_bold.otf differ diff --git a/core/designsystem/src/main/res/font/suit_medium.otf b/core/designsystem/src/main/res/font/suit_medium.otf new file mode 100644 index 00000000..5d97ac55 Binary files /dev/null and b/core/designsystem/src/main/res/font/suit_medium.otf differ diff --git a/core/designsystem/src/main/res/font/suit_semibold.otf b/core/designsystem/src/main/res/font/suit_semibold.otf new file mode 100644 index 00000000..9769761d Binary files /dev/null and b/core/designsystem/src/main/res/font/suit_semibold.otf differ diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml new file mode 100644 index 00000000..5159eab1 --- /dev/null +++ b/core/designsystem/src/main/res/values/strings.xml @@ -0,0 +1,111 @@ + + + + EDITOR + NEW + BEST + + + 보관함에 추가했어요 + 보러가기 + + + 정말 삭제하시겠어요? + 취소 + 삭제하기 + 로그아웃하시겠어요? + 취소 + 로그아웃 + 정말 탈퇴하시겠어요? + 작성하신 글, 좋아요한 글, 저장한 글 등\n 모든 기록이 삭제되며 복구가 불가능해요. + 탈퇴하기 + 머무르기 + 더 많은 추천을 만나고 싶나요? + 오늘의 추천곡을 작성하면 볼 수 있어요! + 곡 추천하러가기 + 모달창 닫기 + + + 텍스트 지우기 + %d/%d + + %d자 이상 입력해주세요 + 특수문자, 띄어쓰기는 사용 불가능해요 + 이미 사용 중인 닉네임이에요 + 비속어, 금칙어가 포함되어 있습니다 + 사용 가능한 닉네임이에요 + + 닉네임을 입력해주세요 + 노래 제목이나 아티스트명을 검색해주세요 + 노래에 대한 이야기를 자유롭게 작성해주세요 + + + 재생하기 + 커뮤니티 가이드 + 곡 추천하러가기 + 다음으로 + 가입하기 + 시작하기 + 확인 + 등록하기 + 수정하기 + 신고하기 + 적용하기 + 카카오로 계속하기 + + + 북마크됨 + 북마크 안됨 + 음악 재생 + 좋아요 눌러짐 + 좋아요 안눌림 + 가이드 열기 + 닫기 + 수정 + 추가 + 프로필 사진 추가 + 텍스트 지우기 + 프로필 사진 수정 + 노래 검색으로 이동 + 모달창 닫기 + + + 존중하는 말과 따뜻한 표현을 사용해주세요.\n욕설, 비방, 혐오 발언은 삼가해주세요.\n저작권 문제로 가사 전체 업로드는 불가해요. + + 더 알아보기 + %1$d일 + + + 네, 모두 동의합니다 + 서비스 이용약관(필수) + 개인정보 처리방침(필수) + + + 부적절한 내용을 포함하고 있어요. + 불쾌한 표현이 포함되어 있어요. + 의심스럽거나 스팸이에요. + 저작권을 침해하고 있어요. + + + %s 앨범 커버 + 오늘의 질문 + + + + 신고 사유를 선택해주세요 + 부적절한 내용을 포함하고 있어요. + 불쾌한 표현이 포함되어 있어요. + 의심스럽거나 스팸이에요. + 저작권을 침해하고 있어요. + 앨범에서 선택하기 + 기본 이미지로 변경하기 + + + 보관함에 추가했어요 + 미리듣기가 제공되지 않는 곡이에요 + 보러가기 + 앗! 일시적인 오류가 발생했어요 + 잠시 후 다시 시도해주세요 + + + \ No newline at end of file diff --git a/core/domain/.gitignore b/core/domain/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts new file mode 100644 index 00000000..faf82322 --- /dev/null +++ b/core/domain/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + alias(libs.plugins.dplay.domain) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/DailyQuestion.kt b/core/domain/src/main/java/com/example/domain/model/DailyQuestion.kt new file mode 100644 index 00000000..db0c2ce4 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/DailyQuestion.kt @@ -0,0 +1,51 @@ +package com.example.domain.model + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +data class DailyQuestion( + val questionId: Long, + val title: String, + private val date: String, + val year: Int = 0, + val month: Int = 0, +) { + val homeTitleDateText: String = date.toDiscoveryTitleSafe() + + val recordDayText: String = date + + val recordMMDD: String = if (year > 0 && month > 0) { + val dayNum = date.filter { it.isDigit() }.toIntOrNull() + if (dayNum != null) { + "${month}월 ${dayNum}일" + } else { + "${month}월 ${date}" + } + } else { + date.toMMDDText() + } +} + +private fun String.toDiscoveryTitleSafe(): String = + runCatching { + val date = LocalDate.parse(this, DateTimeFormatter.ISO_DATE) + "${date.monthValue}월 ${date.dayOfMonth}일의 발견" + }.getOrElse { + "알 수 없는 날짜" + } + +private fun String.toRecordListDayTextSafe(): Int = + runCatching { + val day = LocalDate.parse(this, DateTimeFormatter.ISO_DATE) + day.dayOfMonth + }.getOrElse { + -1 + } + +private fun String.toMMDDText(): String = + runCatching { + val date = LocalDate.parse(this, DateTimeFormatter.ISO_DATE) + "${date.monthValue}월 ${date.dayOfMonth}일" + }.getOrElse { + "알 수 없는 날짜" + } diff --git a/core/domain/src/main/java/com/example/domain/model/Dummy.kt b/core/domain/src/main/java/com/example/domain/model/Dummy.kt new file mode 100644 index 00000000..748ce450 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/Dummy.kt @@ -0,0 +1,5 @@ +package com.example.domain.model + +data class Dummy( + val dummyName: String +) diff --git a/core/domain/src/main/java/com/example/domain/model/FeedItem.kt b/core/domain/src/main/java/com/example/domain/model/FeedItem.kt new file mode 100644 index 00000000..f702dfbf --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/FeedItem.kt @@ -0,0 +1,18 @@ +package com.example.domain.model + + +data class FeedItem( + val postId: Long, + val isScrapped: Boolean, + val content: String, + val badge: Badge?, + val track: Track, + val writer: Writer, + val like: Like, +) + +enum class Badge { + EDITOR, + BEST, + NEW, +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/HomeScreenData.kt b/core/domain/src/main/java/com/example/domain/model/HomeScreenData.kt new file mode 100644 index 00000000..58aa5203 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/HomeScreenData.kt @@ -0,0 +1,9 @@ +package com.example.domain.model + +data class HomeScreenData( + val todayQuestion: DailyQuestion, + val hasPosted: Boolean, + val locked: Boolean, + val totalCount: Int, + val todayPosts: List, +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/Like.kt b/core/domain/src/main/java/com/example/domain/model/Like.kt new file mode 100644 index 00000000..9fd6dd7f --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/Like.kt @@ -0,0 +1,6 @@ +package com.example.domain.model + +data class Like( + val isLiked: Boolean, + val count: Int, +) diff --git a/core/domain/src/main/java/com/example/domain/model/LoadingState.kt b/core/domain/src/main/java/com/example/domain/model/LoadingState.kt new file mode 100644 index 00000000..14c74066 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/LoadingState.kt @@ -0,0 +1,7 @@ +package com.example.domain.model + +enum class LoadingState { + LOADING, + SUCCESS, + FAILURE +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/NicknameValidationResult.kt b/core/domain/src/main/java/com/example/domain/model/NicknameValidationResult.kt new file mode 100644 index 00000000..a8106cd0 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/NicknameValidationResult.kt @@ -0,0 +1,11 @@ +package com.example.domain.model + +sealed interface NicknameValidationResult { + data object Success : NicknameValidationResult + + sealed interface Error : NicknameValidationResult { + data object TooShort : Error + data object InvalidFormat : Error + data object Duplicated : Error + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/PostDetail.kt b/core/domain/src/main/java/com/example/domain/model/PostDetail.kt new file mode 100644 index 00000000..dba320be --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/PostDetail.kt @@ -0,0 +1,21 @@ +package com.example.domain.model + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +data class PostDetail( + val postId: Long, + val isHost: Boolean, + val isScrapped: Boolean, + val content: String, + private val date: String, + val track: Track, + val writer: Writer, + val like: Like, +) { + val displayDate: String + get() = runCatching { + val parsedDate = LocalDate.parse(date, DateTimeFormatter.ISO_DATE) + "${parsedDate.monthValue}월 ${parsedDate.dayOfMonth}일" + }.getOrElse { "알 수 없는 날짜" } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/ProfileImageState.kt b/core/domain/src/main/java/com/example/domain/model/ProfileImageState.kt new file mode 100644 index 00000000..e3c04f30 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/ProfileImageState.kt @@ -0,0 +1,9 @@ +package com.example.domain.model + +sealed interface ProfileImageState { + data object Keep : ProfileImageState + + data object Delete : ProfileImageState + + data class Update(val imagePath: String) : ProfileImageState +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/QuestionError.kt b/core/domain/src/main/java/com/example/domain/model/QuestionError.kt new file mode 100644 index 00000000..2b9e80e4 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/QuestionError.kt @@ -0,0 +1,6 @@ +package com.example.domain.model + +sealed class QuestionError : Throwable() { + object NotFound : QuestionError() + object Unknown : QuestionError() +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/RegisteredTrack.kt b/core/domain/src/main/java/com/example/domain/model/RegisteredTrack.kt new file mode 100644 index 00000000..e8d5e55e --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/RegisteredTrack.kt @@ -0,0 +1,7 @@ +package com.example.domain.model + +data class RegisteredTrack( + val postId: Long, + val track: Track, + val comment: String, +) diff --git a/core/domain/src/main/java/com/example/domain/model/ScrappedTrack.kt b/core/domain/src/main/java/com/example/domain/model/ScrappedTrack.kt new file mode 100644 index 00000000..7d865a69 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/ScrappedTrack.kt @@ -0,0 +1,6 @@ +package com.example.domain.model + +data class ScrappedTrack( + val postId: Long, + val track: Track, +) diff --git a/core/domain/src/main/java/com/example/domain/model/Track.kt b/core/domain/src/main/java/com/example/domain/model/Track.kt new file mode 100644 index 00000000..32e06c12 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/Track.kt @@ -0,0 +1,9 @@ +package com.example.domain.model + +data class Track( + val trackId: String, + val songTitle: String, + val artistName: String, + val coverImg: String, + val isrc: String, +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/TrackPreview.kt b/core/domain/src/main/java/com/example/domain/model/TrackPreview.kt new file mode 100644 index 00000000..a5d67229 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/TrackPreview.kt @@ -0,0 +1,8 @@ +package com.example.domain.model + +data class TrackPreview( + val sessionId: String, + val trackId: String, + val streamUrl: String, + val expiresAt: String? = null, +) diff --git a/core/domain/src/main/java/com/example/domain/model/User.kt b/core/domain/src/main/java/com/example/domain/model/User.kt new file mode 100644 index 00000000..b3bdce7f --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/User.kt @@ -0,0 +1,7 @@ +package com.example.domain.model + +data class User( + val id: Long, + val nickname: String, + val profileImagePath: String?, +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/UserRelation.kt b/core/domain/src/main/java/com/example/domain/model/UserRelation.kt new file mode 100644 index 00000000..53e050ce --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/UserRelation.kt @@ -0,0 +1,6 @@ +package com.example.domain.model + +enum class UserRelation { + ME, + OTHER; +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/model/Writer.kt b/core/domain/src/main/java/com/example/domain/model/Writer.kt new file mode 100644 index 00000000..054ac0a7 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/model/Writer.kt @@ -0,0 +1,8 @@ +package com.example.domain.model + +data class Writer( + val userId: Long, + val nickname: String, + val profileImg: String?, + val isAdmin: Boolean, +) diff --git a/core/domain/src/main/java/com/example/domain/repository/AuthRepository.kt b/core/domain/src/main/java/com/example/domain/repository/AuthRepository.kt new file mode 100644 index 00000000..b5ad3284 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/repository/AuthRepository.kt @@ -0,0 +1,17 @@ +package com.example.domain.repository + +import com.example.domain.model.NicknameValidationResult + +interface AuthRepository { + suspend fun kakaoLogin(): Result + + suspend fun signupWithKakao( + kakaoAccessToken: String?, + profileImage: String?, + nickname: String, + ): Result + + suspend fun logout(): Result + + suspend fun withdraw(): Result +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/repository/DummyRepository.kt b/core/domain/src/main/java/com/example/domain/repository/DummyRepository.kt new file mode 100644 index 00000000..06bc5a90 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/repository/DummyRepository.kt @@ -0,0 +1,7 @@ +package com.example.domain.repository + +import com.example.domain.model.Dummy + +interface DummyRepository { + suspend fun getDummy(dummyId: Long): Result +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/repository/PostRepository.kt b/core/domain/src/main/java/com/example/domain/repository/PostRepository.kt new file mode 100644 index 00000000..e5d1d7d7 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/repository/PostRepository.kt @@ -0,0 +1,46 @@ +package com.example.domain.repository + +import androidx.paging.PagingData +import com.example.domain.model.FeedItem +import com.example.domain.model.HomeScreenData +import com.example.domain.model.Track +import com.example.domain.model.PostDetail +import kotlinx.coroutines.flow.Flow + +interface PostRepository { + suspend fun registerPost( + track: Track, + comment: String, + ): Result + + suspend fun getPostDetail(postId: Long): Result + + suspend fun postPostLike( + postId: Long, + ): Result + + suspend fun deletePostLike( + postId: Long, + ): Result + + suspend fun postPostScrap( + postId: Long, + ): Result + + suspend fun deletePostScrap( + postId: Long, + ): Result + + suspend fun deletePost( + postId: Long, + ): Result + + suspend fun getTodayPosts( + ): Result + + fun getPostsByQuestionId( + questionId: Long, + onTotalCountFetched: (Int) -> Unit, + onLockedFetched: (Boolean) -> Unit, + ): Flow> +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/repository/QuestionRepository.kt b/core/domain/src/main/java/com/example/domain/repository/QuestionRepository.kt new file mode 100644 index 00000000..5b2e5942 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/repository/QuestionRepository.kt @@ -0,0 +1,12 @@ +package com.example.domain.repository + +import com.example.domain.model.DailyQuestion + +interface QuestionRepository { + suspend fun getQuestionRecord( + year: Int, + month: Int, + ): Result> + + suspend fun getTodayQuestion( ): Result +} diff --git a/core/domain/src/main/java/com/example/domain/repository/TrackRepository.kt b/core/domain/src/main/java/com/example/domain/repository/TrackRepository.kt new file mode 100644 index 00000000..11c6cf15 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/repository/TrackRepository.kt @@ -0,0 +1,17 @@ +package com.example.domain.repository + +import com.example.domain.model.TrackPreview +import androidx.paging.PagingData +import com.example.domain.model.Track +import kotlinx.coroutines.flow.Flow + +interface TrackRepository { + fun searchTracks(query: String): Flow> + + suspend fun getTrack(trackId: String): Result + + suspend fun getTrackPreview( + trackId: String, + storefront: String? = null, + ): Result +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/repository/UserRepository.kt b/core/domain/src/main/java/com/example/domain/repository/UserRepository.kt new file mode 100644 index 00000000..a768c033 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/repository/UserRepository.kt @@ -0,0 +1,37 @@ +package com.example.domain.repository + +import androidx.paging.PagingData +import com.example.domain.model.NicknameValidationResult +import com.example.domain.model.ProfileImageState +import com.example.domain.model.RegisteredTrack +import com.example.domain.model.ScrappedTrack +import com.example.domain.model.User +import kotlinx.coroutines.flow.Flow + +interface UserRepository { + fun getUser(): Flow + + suspend fun getUser(userId: Long): Result + + fun getAccessToken(): Flow + + fun getRefreshToken(): Flow + + suspend fun getNotificationEnabled(): Result + + suspend fun updateNotificationEnabled(enabled: Boolean): Result + + suspend fun updateProfile( + nickname: String?, + profileImageState: ProfileImageState, + ): Result + + fun getRegisteredTracks( + userId: Long, + onTotalCountFetched: (Int) -> Unit + ): Flow> + + fun getScrappedTracks( + userId: Long, + ): Flow> +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/usecase/CheckUserRelationUseCase.kt b/core/domain/src/main/java/com/example/domain/usecase/CheckUserRelationUseCase.kt new file mode 100644 index 00000000..e629bf89 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/usecase/CheckUserRelationUseCase.kt @@ -0,0 +1,18 @@ +package com.example.domain.usecase + +import com.example.domain.model.UserRelation +import com.example.domain.repository.UserRepository +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class CheckUserRelationUseCase @Inject constructor( + private val userRepository: UserRepository +) { + suspend operator fun invoke(userId: Long): UserRelation { + val myId = userRepository.getUser().first()?.id + return when { + myId == userId -> UserRelation.ME + else -> UserRelation.OTHER + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/usecase/GetDummyUseCase.kt b/core/domain/src/main/java/com/example/domain/usecase/GetDummyUseCase.kt new file mode 100644 index 00000000..9801282c --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/usecase/GetDummyUseCase.kt @@ -0,0 +1,13 @@ +package com.example.domain.usecase + +import com.example.domain.model.Dummy +import com.example.domain.repository.DummyRepository +import javax.inject.Inject + +class GetDummyUseCase +@Inject +constructor( + private val dummyRepository: DummyRepository, +) { + suspend operator fun invoke(id: Long): Result = dummyRepository.getDummy(dummyId = id) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/usecase/GetRegisteredTrackUseCase.kt b/core/domain/src/main/java/com/example/domain/usecase/GetRegisteredTrackUseCase.kt new file mode 100644 index 00000000..266952c8 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/usecase/GetRegisteredTrackUseCase.kt @@ -0,0 +1,31 @@ +package com.example.domain.usecase + +import androidx.paging.PagingData +import com.example.domain.model.RegisteredTrack +import com.example.domain.repository.UserRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class GetRegisteredTracksUseCase @Inject constructor( + private val userRepository: UserRepository +) { + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke( + userId: Long? = null, + onTotalCountFetched: (Int) -> Unit + ): Flow> { + return userId?.let { + userRepository.getRegisteredTracks(userId = it, onTotalCountFetched = onTotalCountFetched) + } ?: userRepository.getUser() + .flatMapLatest { user -> + if (user == null) { + flowOf(PagingData.empty()) + } else { + userRepository.getRegisteredTracks(userId = user.id, onTotalCountFetched = onTotalCountFetched) + } + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/usecase/GetScrappedTracksUseCase.kt b/core/domain/src/main/java/com/example/domain/usecase/GetScrappedTracksUseCase.kt new file mode 100644 index 00000000..54753a63 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/usecase/GetScrappedTracksUseCase.kt @@ -0,0 +1,30 @@ +package com.example.domain.usecase + +import androidx.paging.PagingData +import com.example.domain.model.ScrappedTrack +import com.example.domain.repository.UserRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class GetScrappedTracksUseCase @Inject constructor( + private val userRepository: UserRepository +) { + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke( + userId: Long? = null + ): Flow> { + return userId?.let{ + userRepository.getScrappedTracks(userId = userId) + } ?: userRepository.getUser() + .flatMapLatest { user -> + if (user == null) { + flowOf(PagingData.empty()) + } else { + userRepository.getScrappedTracks(userId = user.id) + } + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/example/domain/usecase/ValidateNicknameUseCase.kt b/core/domain/src/main/java/com/example/domain/usecase/ValidateNicknameUseCase.kt new file mode 100644 index 00000000..06975426 --- /dev/null +++ b/core/domain/src/main/java/com/example/domain/usecase/ValidateNicknameUseCase.kt @@ -0,0 +1,20 @@ +package com.example.domain.usecase + +import com.example.domain.model.NicknameValidationResult +import javax.inject.Inject + +class ValidateNicknameUseCase @Inject constructor() { + + companion object { + // 문자 허용 범위: 한글 완성형(가–힣), 영문 A–Z/a–z, 숫자 0–9 (특수문자/이모지/초성·자모 단독 불가) + private val NICKNAME_REGEX = "^[가-힣a-zA-Z0-9]+\$".toRegex() + } + + operator fun invoke(nickname: String): NicknameValidationResult { + return when { + nickname.length < 2 -> NicknameValidationResult.Error.TooShort + !nickname.matches(NICKNAME_REGEX) -> NicknameValidationResult.Error.InvalidFormat + else -> NicknameValidationResult.Success + } + } +} \ No newline at end of file diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts new file mode 100644 index 00000000..4af6de7a --- /dev/null +++ b/core/navigation/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.dplay.android.compose) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.navigation" +} + +dependencies { + implementation(libs.kotlinx.serialization.json) + implementation(projects.core.designsystem) + implementation(projects.core.common) + implementation(projects.core.domain) + implementation(projects.core.ui) +} diff --git a/core/navigation/src/main/java/com/example/navigation/Navigator.kt b/core/navigation/src/main/java/com/example/navigation/Navigator.kt new file mode 100644 index 00000000..7ba535b9 --- /dev/null +++ b/core/navigation/src/main/java/com/example/navigation/Navigator.kt @@ -0,0 +1,45 @@ +package com.example.navigation + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.navigation3.runtime.NavKey +import dagger.hilt.android.scopes.ActivityRetainedScoped +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import timber.log.Timber + +@ActivityRetainedScoped +class Navigator( + startDestination: NavKey, +) { + val backStack: SnapshotStateList = mutableStateListOf(startDestination) + + val currentScreen: NavKey? + get() = backStack.lastOrNull() + + val shouldShowBottomSheet: Boolean + get() = backStack.lastOrNull() is TopLevelRoute + + val topLevelRoutes: ImmutableList = persistentListOf(Home, MyPage()) + + fun navigateToTopLevelRoute(destination: TopLevelRoute) { + clearAndNavigateTo(destination as NavKey) + Timber.d("backStack: $backStack") + } + + fun navigateTo(destination: NavKey) { + backStack.add(destination) + Timber.d("backStack: $backStack") + } + + fun navigateToBack() { + backStack.removeLastOrNull() + Timber.d("backStack: $backStack") + } + + fun clearAndNavigateTo(destination: NavKey) { + backStack.clear() + backStack.add(destination) + Timber.d("backStack: $backStack") + } +} diff --git a/core/navigation/src/main/java/com/example/navigation/Route.kt b/core/navigation/src/main/java/com/example/navigation/Route.kt new file mode 100644 index 00000000..3bc9ead6 --- /dev/null +++ b/core/navigation/src/main/java/com/example/navigation/Route.kt @@ -0,0 +1,78 @@ +package com.example.navigation + +import androidx.annotation.DrawableRes +import androidx.navigation3.runtime.NavKey +import com.dplay.designsystem.R +import com.example.domain.model.Badge +import com.example.ui.model.TrackState +import kotlinx.serialization.Serializable + +sealed interface TopLevelRoute { + @get:DrawableRes + val selectedIconRes: Int + + @get:DrawableRes + val unselectedIconRes: Int +} + +data object Home : TopLevelRoute, NavKey { + override val selectedIconRes: Int + get() = R.drawable.ic_home_active_32 + override val unselectedIconRes: Int + get() = R.drawable.ic_home_disabled_32 +} + +enum class MyPageTab { + REGISTERED, + BOOKMARKED, +} + +data class MyPage( + val initialTab: MyPageTab = MyPageTab.REGISTERED, +) : TopLevelRoute, + NavKey { + override val selectedIconRes: Int + get() = R.drawable.ic_bookmark_active_32 + override val unselectedIconRes: Int + get() = R.drawable.ic_bookmark_disabled_32 +} + +data object Splash : NavKey + +data object Login : NavKey + +data class OnboardingGraph( + val kakaoAccessToken: String, +) : NavKey { + data object Terms : NavKey + + data object Profile : NavKey + + data object Onboarding : NavKey + + data object Permission : NavKey +} + +data object Search : NavKey + +data class Comment( + val track: TrackState, +) : NavKey + +data object Setting : NavKey + +data object EditProfile : NavKey + +@Serializable +data object Record : NavKey + +@Serializable +data class Detail( + val postId: Long, + val badge: Badge? = null, +) : NavKey + +@Serializable +data class OtherProfile( + val userId: Long, +) : NavKey diff --git a/core/network/.gitignore b/core/network/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/network/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts new file mode 100644 index 00000000..c47a9c29 --- /dev/null +++ b/core/network/build.gradle.kts @@ -0,0 +1,40 @@ +import com.android.build.api.variant.BuildConfigField +import java.io.StringReader +import java.util.Properties + +plugins { + alias(libs.plugins.dplay.data) + alias(libs.plugins.dplay.hilt) +} + +android { + buildFeatures { + buildConfig = true + } + + namespace = "com.dplay.network" +} + +val baseUrl = + providers.environmentVariable("BASE_URL").orElse( + providers + .fileContents( + isolated.rootProject.projectDirectory.file("local.properties"), + ).asText + .map { text -> + val properties = Properties() + properties.load(StringReader(text)) + properties.getProperty("base.url") + }.orElse("http://example.com"), + ) + +androidComponents { + onVariants { + it.buildConfigFields!!.put( + "BASE_URL", + baseUrl.map { value -> + BuildConfigField(type = "String", value = "\"$value\"", comment = null) + }, + ) + } +} diff --git a/core/network/src/main/java/com/example/network/AuthInterceptor.kt b/core/network/src/main/java/com/example/network/AuthInterceptor.kt new file mode 100644 index 00000000..52158e4e --- /dev/null +++ b/core/network/src/main/java/com/example/network/AuthInterceptor.kt @@ -0,0 +1,30 @@ +package com.example.network + +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +class AuthInterceptor + @Inject + constructor( + private val tokenManager: TokenManager, + ) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (originalRequest.header("Authorization") != null) { + return chain.proceed(originalRequest) + } + + val accessToken = runBlocking { tokenManager.getAccessToken() } + + val newRequest = + originalRequest + .newBuilder() + .addHeader("Authorization", "Bearer $accessToken") + .build() + + return chain.proceed(newRequest) + } + } diff --git a/core/network/src/main/java/com/example/network/AuthenticatorProvider.kt b/core/network/src/main/java/com/example/network/AuthenticatorProvider.kt new file mode 100644 index 00000000..82ac06ee --- /dev/null +++ b/core/network/src/main/java/com/example/network/AuthenticatorProvider.kt @@ -0,0 +1,7 @@ +package com.example.network + +import okhttp3.Authenticator + +interface AuthenticatorProvider { + fun get(): Authenticator +} diff --git a/core/network/src/main/java/com/example/network/NetworkException.kt b/core/network/src/main/java/com/example/network/NetworkException.kt new file mode 100644 index 00000000..7255f59f --- /dev/null +++ b/core/network/src/main/java/com/example/network/NetworkException.kt @@ -0,0 +1,6 @@ +package com.example.network + +class NetworkException( + val code: Int, + override val message: String, +) : Exception(message) diff --git a/core/network/src/main/java/com/example/network/RetrofitModule.kt b/core/network/src/main/java/com/example/network/RetrofitModule.kt new file mode 100644 index 00000000..ebf13422 --- /dev/null +++ b/core/network/src/main/java/com/example/network/RetrofitModule.kt @@ -0,0 +1,69 @@ +package com.example.network + +import com.dplay.network.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RetrofitModule { + @Provides + @Singleton + fun providesJson(): Json = + Json { + ignoreUnknownKeys = true + prettyPrint = BuildConfig.DEBUG + } + + @Provides + @Singleton + fun providesAuthInterceptor(interceptor: AuthInterceptor): Interceptor = interceptor + + @Provides + @Singleton + fun providesOkHttpClient( + authInterceptor: Interceptor, + authenticatorProvider: AuthenticatorProvider, + ): OkHttpClient = + OkHttpClient + .Builder() + .apply { + connectTimeout(10, TimeUnit.SECONDS) + writeTimeout(10, TimeUnit.SECONDS) + readTimeout(10, TimeUnit.SECONDS) + addInterceptor(authInterceptor) + authenticator(authenticatorProvider.get()) + if (BuildConfig.DEBUG) { + addInterceptor( + HttpLoggingInterceptor().apply { + setLevel(HttpLoggingInterceptor.Level.BODY) + }, + ) + } + }.build() + + @Provides + @Singleton + fun providesRetrofit( + okHttpClient: OkHttpClient, + json: Json, + ): Retrofit = + Retrofit + .Builder() + .baseUrl(BuildConfig.BASE_URL) + .client(okHttpClient) + .addConverterFactory( + json.asConverterFactory("application/json".toMediaType()), + ).build() +} diff --git a/core/network/src/main/java/com/example/network/TokenManager.kt b/core/network/src/main/java/com/example/network/TokenManager.kt new file mode 100644 index 00000000..e4814bde --- /dev/null +++ b/core/network/src/main/java/com/example/network/TokenManager.kt @@ -0,0 +1,16 @@ +package com.example.network + +interface TokenManager { + suspend fun getAccessToken(): String? + + suspend fun getRefreshToken(): String? + + suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) + + suspend fun updateAccessToken(newAccessToken: String) + + suspend fun clearAllTokens() +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 00000000..b9b025c1 --- /dev/null +++ b/core/ui/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.dplay.android.compose) +} + +android { + namespace = "com.dplay.ui" +} + +dependencies { + implementation(projects.core.designsystem) + implementation(projects.core.domain) +} diff --git a/core/ui/src/main/java/com/example/ui/Util.kt b/core/ui/src/main/java/com/example/ui/Util.kt new file mode 100644 index 00000000..332d1360 --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/Util.kt @@ -0,0 +1,10 @@ +package com.example.ui + +import androidx.compose.runtime.Composable +import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.flow.flowOf + +@Composable +fun emptyLazyPagingItems(): LazyPagingItems = flowOf(PagingData.empty()).collectAsLazyPagingItems() diff --git a/core/ui/src/main/java/com/example/ui/base/BaseContract.kt b/core/ui/src/main/java/com/example/ui/base/BaseContract.kt new file mode 100644 index 00000000..c320d24e --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/base/BaseContract.kt @@ -0,0 +1,9 @@ +package com.example.ui.base + +sealed interface BaseContract { + interface State : BaseContract + + interface Intent : BaseContract + + interface SideEffect : BaseContract +} diff --git a/core/ui/src/main/java/com/example/ui/base/BaseViewModel.kt b/core/ui/src/main/java/com/example/ui/base/BaseViewModel.kt new file mode 100644 index 00000000..83d6b417 --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/base/BaseViewModel.kt @@ -0,0 +1,35 @@ +package com.example.ui.base + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +abstract class BaseViewModel( + initialState: STATE, +) : ViewModel() { + private val _uiState = MutableStateFlow(initialState) + val uiState = _uiState.asStateFlow() + + private val _sideEffect: Channel = Channel(Channel.BUFFERED) + val sideEffect = _sideEffect.receiveAsFlow() + + protected val currentState: STATE + get() = _uiState.value + + abstract fun handleIntent(intent: INTENT) + + protected fun updateState(reducer: STATE.() -> STATE) { + _uiState.update { currentState.reducer() } + } + + protected fun setSideEffect(effect: SIDE_EFFECT) { + viewModelScope.launch { + _sideEffect.send(effect) + } + } +} diff --git a/core/ui/src/main/java/com/example/ui/controller/BottomNavigationController.kt b/core/ui/src/main/java/com/example/ui/controller/BottomNavigationController.kt new file mode 100644 index 00000000..51a65648 --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/controller/BottomNavigationController.kt @@ -0,0 +1,24 @@ +package com.example.ui.controller + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +class BottomNavigationController { + var bottomNavigationVisible by mutableStateOf(true) + private set + + fun show() { + bottomNavigationVisible = true + } + + fun hide() { + bottomNavigationVisible = false + } +} + +val LocalBottomNavigationController = + compositionLocalOf { + error("BottomNavigationController not provided") + } diff --git a/core/ui/src/main/java/com/example/ui/controller/ModalController.kt b/core/ui/src/main/java/com/example/ui/controller/ModalController.kt new file mode 100644 index 00000000..263c1699 --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/controller/ModalController.kt @@ -0,0 +1,79 @@ +package com.example.ui.controller + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateOf + +@Stable +class ModalController { + private val _modalState = mutableStateOf(null) + val modalState: State = _modalState + + fun showGraphicModal( + mainText: String, + subText: String?, + buttonLabel: String, + onButtonClick: () -> Unit = {}, + onDismiss: () -> Unit = {}, + ) { + _modalState.value = + ModalState.GraphicModal( + mainText = mainText, + subText = subText, + onButtonClick = onButtonClick, + onDismiss = onDismiss, + buttonLabel = buttonLabel, + ) + } + + fun showWarningModal( + mainText: String, + subText: String?, + onLeftButtonClick: () -> Unit, + onRightButtonClick: () -> Unit, + onDismiss: () -> Unit, + leftButtonLabel: String, + rightButtonLabel: String, + ) { + _modalState.value = + ModalState.WarningModal( + mainText = mainText, + subText = subText, + onLeftButtonClick = onLeftButtonClick, + onRightButtonClick = onRightButtonClick, + onDismiss = onDismiss, + leftButtonLabel = leftButtonLabel, + rightButtonLabel = rightButtonLabel, + ) + } + + fun hideModal() { + _modalState.value = null + } +} + +sealed class ModalState { + data class GraphicModal( + val mainText: String, + val subText: String?, + val onButtonClick: () -> Unit, + val onDismiss: () -> Unit, + val buttonLabel: String, + ) : ModalState() + + data class WarningModal( + val mainText: String, + val subText: String?, + val onLeftButtonClick: () -> Unit, + val onRightButtonClick: () -> Unit, + val onDismiss: () -> Unit, + val leftButtonLabel: String, + val rightButtonLabel: String, + ) : ModalState() +} + +val LocalModalController = + compositionLocalOf { + error("DialogController not provided") + } diff --git a/core/ui/src/main/java/com/example/ui/handler/AppTerminationHandler.kt b/core/ui/src/main/java/com/example/ui/handler/AppTerminationHandler.kt new file mode 100644 index 00000000..09048a6d --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/handler/AppTerminationHandler.kt @@ -0,0 +1,28 @@ +package com.example.ui.handler + +import android.app.Activity +import android.content.Context +import android.widget.Toast +import timber.log.Timber + +class AppTerminationHandler( + private val context: Context, +) { + private var backPressedTime: Long = 0 + private val finishIntervalTime: Long = 2000 // 2초 + + fun onBackPress() { + val currentTime = System.currentTimeMillis() + val intervalTime = currentTime - backPressedTime + Timber.d("intervalTime: $intervalTime") + + if (intervalTime in 0..finishIntervalTime) { + // 2초 내에 다시 눌렀으면 앱 종료 + (context as? Activity)?.finish() + } else { + // 처음 눌렀거나 시간이 지났으면 Toast 표시 + backPressedTime = currentTime + Toast.makeText(context, "'뒤로' 버튼을 한번 더 누르면 종료됩니다.", Toast.LENGTH_SHORT).show() + } + } +} diff --git a/core/ui/src/main/java/com/example/ui/handler/GlobalModalHandler.kt b/core/ui/src/main/java/com/example/ui/handler/GlobalModalHandler.kt new file mode 100644 index 00000000..645d1991 --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/handler/GlobalModalHandler.kt @@ -0,0 +1,61 @@ +package com.example.ui.handler + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.designsystem.component.DPlayScrim +import com.example.designsystem.component.modal.GraphicModal +import com.example.designsystem.component.modal.WarningModal +import com.example.designsystem.theme.DPlayTheme +import com.example.ui.controller.LocalModalController +import com.example.ui.controller.ModalController +import com.example.ui.controller.ModalState + +@Composable +fun GlobalModalHandler( + modifier: Modifier = Modifier, + controller: ModalController = LocalModalController.current, +) { + val modalState by controller.modalState + + when (val state = modalState) { + is ModalState.WarningModal -> { + DPlayScrim( + backgroundColor = DPlayTheme.colors.dim80, + onDismiss = state.onDismiss, + ) + + WarningModal( + mainText = state.mainText, + subText = state.subText, + leftButtonLabel = state.leftButtonLabel, + rightButtonLabel = state.rightButtonLabel, + onLeftButtonClick = state.onLeftButtonClick, + onRightButtonClick = state.onRightButtonClick, + modifier = + modifier + .padding(horizontal = 40.dp), + ) + } + is ModalState.GraphicModal -> { + DPlayScrim( + backgroundColor = DPlayTheme.colors.dim80, + onDismiss = state.onDismiss, + ) + + GraphicModal( + mainText = state.mainText, + subText = state.subText, + buttonLabel = state.buttonLabel, + onCloseIconClick = state.onDismiss, + onButtonClick = state.onButtonClick, + modifier = + modifier + .padding(horizontal = 40.dp), + ) + } + null -> { /* 다이얼로그 없음 */ } + } +} diff --git a/core/ui/src/main/java/com/example/ui/handler/PermissionHandler.kt b/core/ui/src/main/java/com/example/ui/handler/PermissionHandler.kt new file mode 100644 index 00000000..b12deeed --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/handler/PermissionHandler.kt @@ -0,0 +1,59 @@ +package com.example.ui.handler + +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat + +class PermissionHandler( + private val context: Context, + private val launcher: ManagedActivityResultLauncher, + private val onResult: (Boolean) -> Unit, +) { + fun request(permission: String) { + val isGranted = + ContextCompat.checkSelfPermission( + context, + permission, + ) == PackageManager.PERMISSION_GRANTED + + if (isGranted) { + onResult(true) + } else { + launcher.launch(permission) + } + } + + fun requestIf( + permission: String, + condition: Boolean, + ) { + if (condition) { + request(permission) + } else { + onResult(true) + } + } +} + +@Composable +fun rememberPermissionHandler( + onResult: (Boolean) -> Unit, +): PermissionHandler { + val context = LocalContext.current + + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = onResult, + ) + + return remember(context, launcher, onResult) { + PermissionHandler(context, launcher, onResult) + } +} diff --git a/core/ui/src/main/java/com/example/ui/mapper/NicknameValidationMapper.kt b/core/ui/src/main/java/com/example/ui/mapper/NicknameValidationMapper.kt new file mode 100644 index 00000000..38e61d63 --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/mapper/NicknameValidationMapper.kt @@ -0,0 +1,13 @@ +package com.example.ui.mapper + +import com.example.designsystem.component.textfield.type.InputState +import com.example.designsystem.component.textfield.type.NicknameInputState +import com.example.domain.model.NicknameValidationResult + +fun NicknameValidationResult.toUiState(): InputState = + when (this) { + NicknameValidationResult.Success -> NicknameInputState.Success + NicknameValidationResult.Error.TooShort -> NicknameInputState.Error.NotEnoughLength + NicknameValidationResult.Error.InvalidFormat -> NicknameInputState.Error.InvalidFormat + NicknameValidationResult.Error.Duplicated -> NicknameInputState.Error.AlreadyExists + } diff --git a/core/ui/src/main/java/com/example/ui/model/RegisteredTrackState.kt b/core/ui/src/main/java/com/example/ui/model/RegisteredTrackState.kt new file mode 100644 index 00000000..211767a1 --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/model/RegisteredTrackState.kt @@ -0,0 +1,16 @@ +package com.example.ui.model + +import com.example.domain.model.RegisteredTrack + +data class RegisteredTrackState( + val postId: Long, + val comment: String, + val track: TrackState, +) + +fun RegisteredTrack.toUiState() = + RegisteredTrackState( + postId = postId, + comment = comment, + track = track.toUiState(), + ) diff --git a/core/ui/src/main/java/com/example/ui/model/ScrappedTrackState.kt b/core/ui/src/main/java/com/example/ui/model/ScrappedTrackState.kt new file mode 100644 index 00000000..f265dae9 --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/model/ScrappedTrackState.kt @@ -0,0 +1,14 @@ +package com.example.ui.model + +import com.example.domain.model.ScrappedTrack + +data class ScrappedTrackState( + val postId: Long, + val track: TrackState, +) + +fun ScrappedTrack.toUiState() = + ScrappedTrackState( + postId = postId, + track = track.toUiState(), + ) diff --git a/core/ui/src/main/java/com/example/ui/model/TrackState.kt b/core/ui/src/main/java/com/example/ui/model/TrackState.kt new file mode 100644 index 00000000..2af54996 --- /dev/null +++ b/core/ui/src/main/java/com/example/ui/model/TrackState.kt @@ -0,0 +1,29 @@ +package com.example.ui.model + +import com.example.domain.model.Track + +data class TrackState( + val trackId: String, + val musicTitle: String, + val artistName: String, + val thumbnailUrl: String, + val isrc: String, +) + +fun Track.toUiState(): TrackState = + TrackState( + trackId = trackId, + musicTitle = songTitle, + artistName = artistName, + thumbnailUrl = coverImg, + isrc = isrc, + ) + +fun TrackState.toDomain(): Track = + Track( + trackId = trackId, + songTitle = musicTitle, + artistName = artistName, + coverImg = thumbnailUrl, + isrc = isrc, + ) diff --git a/feature/comment/build.gradle.kts b/feature/comment/build.gradle.kts new file mode 100644 index 00000000..ba86a278 --- /dev/null +++ b/feature/comment/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.comment" +} diff --git a/feature/comment/src/main/java/com/example/comment/CommentContract.kt b/feature/comment/src/main/java/com/example/comment/CommentContract.kt new file mode 100644 index 00000000..3fbd3a98 --- /dev/null +++ b/feature/comment/src/main/java/com/example/comment/CommentContract.kt @@ -0,0 +1,45 @@ +package com.example.comment + +import com.example.ui.base.BaseContract +import com.example.ui.model.TrackState + +class CommentContract { + data class CommentState( + val track: TrackState? = null, + val commentInput: String = "", + val isGuideVisible: Boolean = false, + ) : BaseContract.State { + val isRegisterButtonEnabled: Boolean + get() = commentInput.isNotBlank() && track != null + } + + sealed interface CommentIntent : BaseContract.Intent { + data class Initialize( + val track: TrackState, + ) : CommentIntent + + data object OnBackIconClick : CommentIntent + + data class OnCommentInputChanged( + val commentInput: String, + ) : CommentIntent + + data object OnRegisterButtonClick : CommentIntent + + data object OnGuideButtonClick : CommentIntent + + data object OnMoreGuideClick : CommentIntent + + data object OnGuideXIconClick : CommentIntent + } + + sealed interface CommentSideEffect : BaseContract.SideEffect { + data object NavigateToBack : CommentSideEffect + + data object NavigateToHome : CommentSideEffect + + data class OpenWebView( + val url: String, + ) : CommentSideEffect + } +} diff --git a/feature/comment/src/main/java/com/example/comment/CommentNavigationModule.kt b/feature/comment/src/main/java/com/example/comment/CommentNavigationModule.kt new file mode 100644 index 00000000..2a6de792 --- /dev/null +++ b/feature/comment/src/main/java/com/example/comment/CommentNavigationModule.kt @@ -0,0 +1,29 @@ +package com.example.comment + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.Comment +import com.example.navigation.Navigator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object CommentNavigationModule { + @Provides + @IntoSet + fun provideCommentEntry( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { key -> + CommentRoute( + track = key.track, + navigator = navigator, + ) + } + } +} diff --git a/feature/comment/src/main/java/com/example/comment/CommentScreen.kt b/feature/comment/src/main/java/com/example/comment/CommentScreen.kt new file mode 100644 index 00000000..ab484697 --- /dev/null +++ b/feature/comment/src/main/java/com/example/comment/CommentScreen.kt @@ -0,0 +1,205 @@ +package com.example.comment + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dplay.designsystem.R +import com.example.designsystem.component.DPlayMusicGridItem +import com.example.designsystem.component.DplayLeftIconTitleTopAppBar +import com.example.designsystem.component.DplayTooltip +import com.example.designsystem.component.button.DPlayGuidelineButton +import com.example.designsystem.component.button.DPlayLargePinkButton +import com.example.designsystem.component.textfield.DPlayTextArea +import com.example.designsystem.theme.DPlayTheme +import com.example.navigation.Home +import com.example.navigation.Navigator +import com.example.ui.model.TrackState + +@Composable +fun CommentRoute( + track: TrackState, + navigator: Navigator, + modifier: Modifier = Modifier, + viewModel: CommentViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + val uriHandler = LocalUriHandler.current + + LaunchedEffect(track) { + viewModel.handleIntent(CommentContract.CommentIntent.Initialize(track)) + } + + LaunchedEffect(Unit) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + CommentContract.CommentSideEffect.NavigateToBack -> { + navigator.navigateToBack() + } + CommentContract.CommentSideEffect.NavigateToHome -> { + navigator.clearAndNavigateTo(Home) + } + is CommentContract.CommentSideEffect.OpenWebView -> { + uriHandler.openUri(sideEffect.url) + } + } + } + } + + CommentScreen( + state = state, + modifier = modifier, + onBackIconClick = { viewModel.handleIntent(CommentContract.CommentIntent.OnBackIconClick) }, + onGuideButtonClick = { viewModel.handleIntent(CommentContract.CommentIntent.OnGuideButtonClick) }, + onMoreGuideClick = { viewModel.handleIntent(CommentContract.CommentIntent.OnMoreGuideClick) }, + onGuideXIconClick = { viewModel.handleIntent(CommentContract.CommentIntent.OnGuideXIconClick) }, + onCommentInputChanged = { viewModel.handleIntent(CommentContract.CommentIntent.OnCommentInputChanged(it)) }, + onRegisterButtonClick = { viewModel.handleIntent(CommentContract.CommentIntent.OnRegisterButtonClick) }, + ) +} + +@Composable +fun CommentScreen( + state: CommentContract.CommentState, + modifier: Modifier = Modifier, + onBackIconClick: () -> Unit = {}, + onGuideButtonClick: () -> Unit = {}, + onMoreGuideClick: () -> Unit = {}, + onGuideXIconClick: () -> Unit = {}, + onCommentInputChanged: (String) -> Unit = {}, + onRegisterButtonClick: () -> Unit = {}, +) { + var guideButtonHeightPx by remember { mutableIntStateOf(0) } + + Column( + modifier = + modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayWhite) + .padding(bottom = 16.dp), + ) { + DplayLeftIconTitleTopAppBar( + title = stringResource(com.dplay.comment.R.string.comment_top_bar_title), + ) { + onBackIconClick() + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = stringResource(com.dplay.comment.R.string.comment_title), + style = DPlayTheme.typography.titleBold24, + color = DPlayTheme.colors.dplayBlack, + modifier = Modifier.padding(start = 16.dp), + ) + + Spacer(modifier = Modifier.height(20.dp)) + + DPlayMusicGridItem( + musicImageUrl = state.track?.thumbnailUrl ?: "", + musicName = state.track?.musicTitle ?: "", + musicArtistName = state.track?.artistName ?: "", + modifier = + Modifier + .width(132.dp) + .align(Alignment.CenterHorizontally), + titleStyle = DPlayTheme.typography.titleBold18, + artistStyle = DPlayTheme.typography.bodySemi14, + spaceBetweenCover = 12.dp, + spaceBetweenText = 6.dp, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + DPlayTextArea( + value = state.commentInput, + onValueChange = { onCommentInputChanged(it) }, + placeholder = stringResource(id = R.string.placeholder_comment), + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Box( + modifier = + Modifier + .padding(start = 16.dp) + .zIndex(1f), + ) { + DPlayGuidelineButton( + onClick = { onGuideButtonClick() }, + modifier = + Modifier + .onGloballyPositioned { coordinates -> + guideButtonHeightPx = coordinates.size.height + }, + textStringRes = R.string.guideline_button_label, + ) + + if (state.isGuideVisible) { + DplayTooltip( + onTextButtonClicked = { onMoreGuideClick() }, + onCloseButtonClicked = { onGuideXIconClick() }, + modifier = + Modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(0, 0) { + val topMargin = 8.dp.roundToPx() + placeable.place(x = 0, y = guideButtonHeightPx + topMargin) + } + }, + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + DPlayLargePinkButton( + onClick = { onRegisterButtonClick() }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + label = stringResource(R.string.register_button_label), + enabled = state.isRegisterButtonEnabled, + ) + } +} + +@Preview +@Composable +fun CommentPreview(modifier: Modifier = Modifier) { + DPlayTheme { + CommentScreen( + state = + CommentContract.CommentState( + isGuideVisible = true, + ), + ) + } +} diff --git a/feature/comment/src/main/java/com/example/comment/CommentViewModel.kt b/feature/comment/src/main/java/com/example/comment/CommentViewModel.kt new file mode 100644 index 00000000..5aa68980 --- /dev/null +++ b/feature/comment/src/main/java/com/example/comment/CommentViewModel.kt @@ -0,0 +1,77 @@ +package com.example.comment + +import androidx.lifecycle.viewModelScope +import com.example.common.constant.Url +import com.example.common.event.HomeRefreshTrigger +import com.example.domain.repository.PostRepository +import com.example.ui.base.BaseViewModel +import com.example.ui.model.TrackState +import com.example.ui.model.toDomain +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CommentViewModel + @Inject + constructor( + private val postRepository: PostRepository, + private val homeRefreshTrigger: HomeRefreshTrigger, + ) : BaseViewModel( + CommentContract.CommentState(), + ) { + override fun handleIntent(intent: CommentContract.CommentIntent) { + when (intent) { + is CommentContract.CommentIntent.Initialize -> { + initializeMusicInfo(intent.track) + } + CommentContract.CommentIntent.OnBackIconClick -> { + setSideEffect(CommentContract.CommentSideEffect.NavigateToBack) + } + is CommentContract.CommentIntent.OnCommentInputChanged -> { + updateState { + copy(commentInput = intent.commentInput.trimStart()) + } + } + CommentContract.CommentIntent.OnGuideButtonClick -> { + updateState { + copy(isGuideVisible = true) + } + } + CommentContract.CommentIntent.OnGuideXIconClick -> { + // guide의 dismiss 동작은 x 버튼 밖에 없음 + updateState { + copy(isGuideVisible = false) + } + } + CommentContract.CommentIntent.OnMoreGuideClick -> { + setSideEffect(CommentContract.CommentSideEffect.OpenWebView(Url.COMMUNITY_GUIDE)) + } + CommentContract.CommentIntent.OnRegisterButtonClick -> { + registerPost() + } + } + } + + private fun registerPost() { + viewModelScope.launch { + val track = currentState.track?.toDomain() ?: return@launch + + postRepository + .registerPost( + track = track, + comment = currentState.commentInput, + ).onSuccess { + homeRefreshTrigger.refresh() + setSideEffect(CommentContract.CommentSideEffect.NavigateToHome) + }.onFailure { + } + } + } + + private fun initializeMusicInfo(trackState: TrackState) { + updateState { + copy(track = trackState) + } + } + } diff --git a/feature/comment/src/main/res/values/strings.xml b/feature/comment/src/main/res/values/strings.xml new file mode 100644 index 00000000..55635365 --- /dev/null +++ b/feature/comment/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + 노래 등록하기 + 노래에 대한\n이야기를 작성해보세요! + \ No newline at end of file diff --git a/feature/detail/.gitignore b/feature/detail/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/detail/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/detail/build.gradle.kts b/feature/detail/build.gradle.kts new file mode 100644 index 00000000..e80d549a --- /dev/null +++ b/feature/detail/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.detail" +} +dependencies { + implementation(projects.core.ui) + implementation(projects.core.common) +} diff --git a/feature/detail/consumer-rules.pro b/feature/detail/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/detail/proguard-rules.pro b/feature/detail/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/detail/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 \ No newline at end of file diff --git a/feature/detail/src/main/java/com/example/detail/DetailContract.kt b/feature/detail/src/main/java/com/example/detail/DetailContract.kt new file mode 100644 index 00000000..64fb6210 --- /dev/null +++ b/feature/detail/src/main/java/com/example/detail/DetailContract.kt @@ -0,0 +1,99 @@ +package com.example.detail + +import com.example.designsystem.component.snackbar.type.SnackBarType +import com.example.domain.model.Badge +import com.example.domain.model.Like +import com.example.domain.model.LoadingState +import com.example.domain.model.Track +import com.example.domain.model.Writer +import com.example.navigation.MyPageTab +import com.example.ui.base.BaseContract + +class DetailContract { + data class DetailState( + val loadingState: LoadingState = LoadingState.LOADING, + val postId: Long = 0L, + val isScrapped: Boolean = false, + val initialIsScrapped: Boolean = false, + val content: String = "", + val isHost: Boolean = false, + val date: String = "", + val track: Track = + Track( + trackId = "", + songTitle = "", + coverImg = "", + artistName = "", + isrc = "", + ), + val writer: Writer = + Writer( + userId = 0, + nickname = "", + profileImg = "", + isAdmin = false, + ), + val like: Like = + Like( + isLiked = false, + count = 0, + ), + val initialIsLiked: Boolean = false, + val badge: Badge? = null, + val bottomSheetVisible: Boolean = false, + val streamingTrackId: String? = null, + ) : BaseContract.State { + val homeRefreshRequired: Boolean + get() = isScrapped != initialIsScrapped || like.isLiked != initialIsLiked + } + + sealed interface DetailIntent : BaseContract.Intent { + data class LoadData( + val postId: Long, + val badge: Badge? = null, + ) : DetailIntent + + data object OnBookmarkClick : DetailIntent + + data object OnStreamClick : DetailIntent + + data object OnLikeClick : DetailIntent + + data object OnMeatBallsClick : DetailIntent + + data object OnWriterProfileClick : DetailIntent + + data object OnBackButtonClick : DetailIntent + + data class OnReportClick( + val reasons: List, + ) : DetailIntent + + data object OnDeleteClick : DetailIntent + + data object OnDeleteConfirmClick : DetailIntent + + data class ChangeBottomSheetVisible( + val visible: Boolean, + ) : DetailIntent + } + + sealed interface DetailSideEffect : BaseContract.SideEffect { + data object ShowDeleteConfirmModal : DetailSideEffect + + data object NavigateBackStack : DetailSideEffect + + data class NavigateToWriterProfile( + val writerUserId: Long, + ) : DetailSideEffect + + data class ShowSnackBar( + val snackBarType: SnackBarType, + val action: (() -> Unit)? = null, + ) : DetailSideEffect + + data class NavigateToMyPage( + val initialTab: MyPageTab = MyPageTab.REGISTERED, + ) : DetailSideEffect + } +} diff --git a/feature/detail/src/main/java/com/example/detail/DetailNavigationModule.kt b/feature/detail/src/main/java/com/example/detail/DetailNavigationModule.kt new file mode 100644 index 00000000..f2f3d886 --- /dev/null +++ b/feature/detail/src/main/java/com/example/detail/DetailNavigationModule.kt @@ -0,0 +1,26 @@ +package com.example.detail + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.Detail +import com.example.navigation.Navigator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object DetailNavigationModule { + @Provides + @IntoSet + fun provideDetailEntry( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { args -> + DetailRoute(postId = args.postId, navigator = navigator, badge = args.badge) + } + } +} diff --git a/feature/detail/src/main/java/com/example/detail/DetailScreen.kt b/feature/detail/src/main/java/com/example/detail/DetailScreen.kt new file mode 100644 index 00000000..96f76e48 --- /dev/null +++ b/feature/detail/src/main/java/com/example/detail/DetailScreen.kt @@ -0,0 +1,388 @@ +package com.example.detail + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +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.foundation.shape.RoundedCornerShape +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.graphics.Brush +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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.dplay.designsystem.R +import com.example.designsystem.component.DPlayButtonBottomSheet +import com.example.designsystem.component.DPlayErrorScreen +import com.example.designsystem.component.DPlayLoadingScreen +import com.example.designsystem.component.DPlayMusicDiscItem +import com.example.designsystem.component.DPlayReportBottomSheet +import com.example.designsystem.component.DplayDualIconTitleTopAppBar +import com.example.designsystem.component.button.DPlayBookmarkButton +import com.example.designsystem.component.button.DPlayLikeButton +import com.example.designsystem.component.button.DPlayStreamingButton +import com.example.designsystem.component.chip.type.DPlayChipType +import com.example.designsystem.component.snackbar.LocalShowSnackBar +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable +import com.example.designsystem.util.roundedBackgroundWithPadding +import com.example.domain.model.Badge +import com.example.domain.model.LoadingState +import com.example.navigation.MyPage +import com.example.navigation.Navigator +import com.example.navigation.OtherProfile +import com.example.ui.controller.LocalModalController +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun DetailRoute( + postId: Long, + navigator: Navigator, + viewModel: DetailViewModel = hiltViewModel(), + badge: Badge? = null, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val showSnackBar = LocalShowSnackBar.current + val modalController = LocalModalController.current + + BackHandler { + viewModel.handleIntent(DetailContract.DetailIntent.OnBackButtonClick) + } + + LaunchedEffect(Unit) { + viewModel.handleIntent(DetailContract.DetailIntent.LoadData(postId = postId, badge = badge)) + } + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collectLatest { + when (it) { + is DetailContract.DetailSideEffect.ShowSnackBar -> { + showSnackBar(it.snackBarType, it.action) + } + + is DetailContract.DetailSideEffect.NavigateBackStack -> { + navigator.navigateToBack() + } + + is DetailContract.DetailSideEffect.NavigateToWriterProfile -> { + navigator.navigateTo(destination = OtherProfile(userId = it.writerUserId)) + } + + is DetailContract.DetailSideEffect.NavigateToMyPage -> { + navigator.navigateTo(destination = MyPage(initialTab = it.initialTab)) + } + + is DetailContract.DetailSideEffect.ShowDeleteConfirmModal -> { + modalController.showWarningModal( + mainText = "정말 삭제하시겠어요?", + subText = "삭제된 글은 복구할 수 없어요", + leftButtonLabel = "취소", + rightButtonLabel = "삭제", + onLeftButtonClick = { + modalController.hideModal() + }, + onRightButtonClick = { + modalController.hideModal() + viewModel.handleIntent(DetailContract.DetailIntent.OnDeleteConfirmClick) + }, + onDismiss = { + modalController.hideModal() + }, + ) + } + } + } + } + + when (uiState.loadingState) { + LoadingState.LOADING -> + DPlayLoadingScreen() + + LoadingState.SUCCESS -> + DetailScreen( + state = uiState, + onTopAppBarLeftIconClick = { + viewModel.handleIntent(DetailContract.DetailIntent.OnBackButtonClick) + }, + onTopAppBarRightIconClick = { + viewModel.handleIntent(DetailContract.DetailIntent.OnMeatBallsClick) + }, + onBookmarkClick = { + viewModel.handleIntent(DetailContract.DetailIntent.OnBookmarkClick) + }, + onStreamClick = { + viewModel.handleIntent(DetailContract.DetailIntent.OnStreamClick) + }, + onLikeClick = { + viewModel.handleIntent(DetailContract.DetailIntent.OnLikeClick) + }, + onWriterProfileClick = { + viewModel.handleIntent(DetailContract.DetailIntent.OnWriterProfileClick) + }, + changeBottomSheetVisible = { visible -> + viewModel.handleIntent(DetailContract.DetailIntent.ChangeBottomSheetVisible(visible)) + }, + onDeleteClick = { + viewModel.handleIntent(DetailContract.DetailIntent.OnDeleteClick) + }, + ) + + LoadingState.FAILURE -> + DPlayErrorScreen( + onBackIconClick = { + viewModel.handleIntent(DetailContract.DetailIntent.OnBackButtonClick) + }, + ) + } +} + +@Composable +private fun DetailScreen( + onTopAppBarLeftIconClick: () -> Unit, + onTopAppBarRightIconClick: () -> Unit, + onBookmarkClick: () -> Unit, + onStreamClick: () -> Unit, + onLikeClick: () -> Unit, + onWriterProfileClick: () -> Unit, + onDeleteClick: () -> Unit, + changeBottomSheetVisible: (Boolean) -> Unit, + state: DetailContract.DetailState = DetailContract.DetailState(), +) { + val color = DPlayTheme.colors + val typography = DPlayTheme.typography + val horizontalModifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + + Box { + Box(modifier = Modifier.fillMaxSize().background(color = color.gray100)) { + Box { + AsyncImage( + model = state.track.coverImg, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .offset(y = (-80).dp), + contentScale = ContentScale.Crop, + ) + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .offset(y = (-80).dp) + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + DPlayTheme.colors.gray100.copy(alpha = 0f), + DPlayTheme.colors.gray100.copy(alpha = 1f), + ), + ), + ), + ) + } + Column { + DplayDualIconTitleTopAppBar( + modifier = Modifier.fillMaxWidth(), + title = state.date, + leftIconRes = R.drawable.ic_arrow_left_16, + rightIconRes = R.drawable.ic_more_24, + onLeftClick = onTopAppBarLeftIconClick, + onRightClick = onTopAppBarRightIconClick, + ) + Spacer(modifier = Modifier.height(24.dp)) + + Box(Modifier.padding(horizontal = 97.dp)) { + DPlayMusicDiscItem( + imageUrl = state.track.coverImg, + isStreaming = state.streamingTrackId == state.track.trackId, + ) + DPlayBookmarkButton( + isMarked = state.isScrapped, + onClick = onBookmarkClick, + modifier = Modifier.align(Alignment.TopEnd), + ) + state.badge?.let { badge -> + val chipType = + when (badge) { + Badge.BEST -> DPlayChipType.BEST + Badge.EDITOR -> DPlayChipType.EDITOR + Badge.NEW -> DPlayChipType.NEW + } + Image( + painter = painterResource(id = chipType.drawableRes), + contentDescription = null, + modifier = + Modifier + .height(height = 32.dp) + .align(Alignment.BottomCenter), + ) + } + } + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = state.track.songTitle, + modifier = Modifier.padding(horizontal = 16.dp).align(Alignment.CenterHorizontally), + style = typography.bodyBold20, + color = color.dplayBlack, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = state.track.artistName, + modifier = Modifier.padding(horizontal = 16.dp).align(Alignment.CenterHorizontally), + style = typography.bodySemi14, + color = color.gray400, + ) + Spacer(modifier = Modifier.height(20.dp)) + + Row(modifier = horizontalModifier) { + DPlayStreamingButton( + onClick = onStreamClick, + enabled = true, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + DPlayLikeButton( + isLiked = state.like.isLiked, + likeCount = state.like.count, + onClick = onLikeClick, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = + horizontalModifier + .border( + width = 1.dp, + color = color.gray200, + shape = RoundedCornerShape(12.dp), + ).roundedBackgroundWithPadding( + backgroundColor = color.dplayWhite, + cornerRadius = 12.dp, + padding = PaddingValues(horizontal = 12.dp, vertical = 16.dp), + ), + ) { + Text( + text = state.content, + style = typography.bodySemi14, + color = color.dplayBlack, + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .noRippleClickable(onClick = onWriterProfileClick), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = + if (state.writer.isAdmin) { + R.drawable.img_profile + } else { + state.writer.profileImg ?: R.drawable.base_profile_image + }, + contentDescription = null, + modifier = + Modifier + .size(32.dp) + .clip(CircleShape) + .border(1.dp, color = color.gray200, shape = CircleShape), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = state.writer.nickname, + style = typography.bodySemi14, + color = color.gray400, + ) + } + } + } + } + if (state.bottomSheetVisible) { + val bottomSheetModifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomStart) + .noRippleClickable() + + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dim40) + .noRippleClickable { changeBottomSheetVisible(false) }, + ) + + if (state.isHost) { + DPlayButtonBottomSheet( + mainText = "삭제하기", + subText = "취소하기", + mainOnClick = onDeleteClick, + subOnClick = { + changeBottomSheetVisible(false) + }, + mainButtonColor = DPlayTheme.colors.dplayPink, + modifier = bottomSheetModifier, + ) + } else { + DPlayReportBottomSheet( + onCloseClick = { changeBottomSheetVisible(false) }, + onButtonClick = { _ -> changeBottomSheetVisible(false) }, + modifier = bottomSheetModifier, + ) + } + } + } +} + +@Preview( + showBackground = true, + backgroundColor = 0xFFFFFFFF, +) +@Composable +private fun DetailScreenPreview() { + DPlayTheme { + DetailScreen( + onTopAppBarLeftIconClick = {}, + onTopAppBarRightIconClick = {}, + onBookmarkClick = {}, + onStreamClick = {}, + onLikeClick = {}, + onWriterProfileClick = {}, + changeBottomSheetVisible = {}, + onDeleteClick = {}, + ) + } +} diff --git a/feature/detail/src/main/java/com/example/detail/DetailViewModel.kt b/feature/detail/src/main/java/com/example/detail/DetailViewModel.kt new file mode 100644 index 00000000..6e3c2e87 --- /dev/null +++ b/feature/detail/src/main/java/com/example/detail/DetailViewModel.kt @@ -0,0 +1,249 @@ +package com.example.detail + +import androidx.lifecycle.viewModelScope +import com.example.common.audio.AudioPlayer +import com.example.common.event.HomeRefreshTrigger +import com.example.common.event.RegisteredTrackRefreshTrigger +import com.example.common.event.ScrappedTrackRefreshTrigger +import com.example.designsystem.component.snackbar.type.SnackBarType +import com.example.detail.DetailContract.DetailSideEffect.NavigateToMyPage +import com.example.detail.DetailContract.DetailSideEffect.ShowSnackBar +import com.example.domain.model.Badge +import com.example.domain.model.Like +import com.example.domain.model.LoadingState +import com.example.domain.model.UserRelation +import com.example.domain.repository.PostRepository +import com.example.domain.repository.TrackRepository +import com.example.domain.usecase.CheckUserRelationUseCase +import com.example.navigation.MyPageTab +import com.example.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class DetailViewModel + @Inject + constructor( + private val postRepository: PostRepository, + private val trackRepository: TrackRepository, + private val audioPlayer: AudioPlayer, + private val homeRefreshTrigger: HomeRefreshTrigger, + private val scrappedTrackRefreshTrigger: ScrappedTrackRefreshTrigger, + private val registeredTrackRefreshTrigger: RegisteredTrackRefreshTrigger, + private val checkUserRelationUseCase: CheckUserRelationUseCase, + ) : BaseViewModel( + DetailContract.DetailState(), + ) { + init { + observePlaybackState() + } + + private fun observePlaybackState() { + audioPlayer.playbackState + .onEach { playbackState -> + updateState { + copy( + streamingTrackId = + if (playbackState.isPlaying) { + playbackState.currentTrackId + } else { + null + }, + ) + } + }.launchIn(viewModelScope) + } + + override fun handleIntent(intent: DetailContract.DetailIntent) { + when (intent) { + is DetailContract.DetailIntent.LoadData -> loadData(intent.postId, intent.badge) + is DetailContract.DetailIntent.OnBackButtonClick -> { + if (currentState.homeRefreshRequired) { + viewModelScope.launch { homeRefreshTrigger.refresh() } + } + setSideEffect(DetailContract.DetailSideEffect.NavigateBackStack) + } + + is DetailContract.DetailIntent.OnBookmarkClick -> toggleBookmark() + is DetailContract.DetailIntent.OnDeleteClick -> { + changeBottomSheetVisible(visible = false) + setSideEffect(DetailContract.DetailSideEffect.ShowDeleteConfirmModal) + } + + is DetailContract.DetailIntent.OnDeleteConfirmClick -> deletePost() + is DetailContract.DetailIntent.OnLikeClick -> toggleLike() + is DetailContract.DetailIntent.OnMeatBallsClick -> { + changeBottomSheetVisible(visible = true) + } + + is DetailContract.DetailIntent.OnReportClick -> reportPost() + is DetailContract.DetailIntent.OnStreamClick -> streamTrack() + is DetailContract.DetailIntent.OnWriterProfileClick -> { + if (!currentState.writer.isAdmin) navigateToOthersProfile() + } + + is DetailContract.DetailIntent.ChangeBottomSheetVisible -> { + changeBottomSheetVisible(visible = intent.visible) + } + } + } + + private fun loadData( + postId: Long, + badge: Badge?, + ) { + viewModelScope.launch { + postRepository + .getPostDetail(postId = postId) + .onSuccess { postDetail -> + updateState { + copy( + loadingState = LoadingState.SUCCESS, + postId = postDetail.postId, + isScrapped = postDetail.isScrapped, + initialIsScrapped = postDetail.isScrapped, + content = postDetail.content, + isHost = postDetail.isHost, + date = postDetail.displayDate, + track = postDetail.track, + writer = postDetail.writer, + like = postDetail.like, + initialIsLiked = postDetail.like.isLiked, + badge = badge, + ) + } + }.onFailure { e -> + Timber.e(e, "error") + updateState { copy(loadingState = LoadingState.FAILURE) } + } + } + } + + private fun toggleBookmark() { + viewModelScope.launch { + val postId = currentState.postId + val isScrapped = currentState.isScrapped + + val result = + if (isScrapped) { + postRepository.deletePostScrap(postId) + } else { + postRepository.postPostScrap(postId) + } + + result + .onSuccess { + updateState { copy(isScrapped = !isScrapped) } + scrappedTrackRefreshTrigger.refresh() + if (!isScrapped) { + setSideEffect( + ShowSnackBar( + snackBarType = SnackBarType.ADD, + action = { setSideEffect(NavigateToMyPage(initialTab = MyPageTab.BOOKMARKED)) }, + ), + ) + } + }.onFailure { e -> + Timber.e(e) + } + } + } + + private fun toggleLike() { + viewModelScope.launch { + val postId = currentState.postId + val isLiked = currentState.like.isLiked + + val result = + if (isLiked) { + postRepository.deletePostLike(postId) + } else { + postRepository.postPostLike(postId) + } + + result + .onSuccess { newCount -> + updateState { + copy( + like = + Like( + isLiked = !isLiked, + count = newCount, + ), + ) + } + }.onFailure { e -> + Timber.e(e) + } + } + } + + private fun deletePost() { + viewModelScope.launch { + postRepository + .deletePost(postId = currentState.postId) + .onSuccess { + homeRefreshTrigger.refresh() + registeredTrackRefreshTrigger.refresh() + scrappedTrackRefreshTrigger.refresh() + setSideEffect(DetailContract.DetailSideEffect.NavigateBackStack) + }.onFailure { e -> + Timber.e(e) + } + } + } + + private fun changeBottomSheetVisible(visible: Boolean) { + updateState { copy(bottomSheetVisible = visible) } + } + + private fun reportPost() { + } + + private fun streamTrack() { + val track = currentState.track + val trackId = track.trackId + val currentStreamingTrackId = audioPlayer.playbackState.value.currentTrackId + + if (currentStreamingTrackId == trackId && audioPlayer.playbackState.value.isPlaying) { + audioPlayer.stop() + return + } + + viewModelScope.launch { + trackRepository + .getTrackPreview(trackId = trackId) + .onSuccess { preview -> + audioPlayer.play( + url = preview.streamUrl, + trackId = trackId, + title = track.songTitle, + artist = track.artistName, + ) + }.onFailure { e -> + Timber.e(e) + setSideEffect( + ShowSnackBar( + snackBarType = SnackBarType.STREAMING_NOT_SUPPORT, + ), + ) + } + } + } + + private fun navigateToOthersProfile() { + viewModelScope.launch { + val userId = currentState.writer.userId + val userRelation = checkUserRelationUseCase(userId) + + when (userRelation) { + UserRelation.ME -> setSideEffect(NavigateToMyPage()) + UserRelation.OTHER -> setSideEffect(DetailContract.DetailSideEffect.NavigateToWriterProfile(userId)) + } + } + } + } diff --git a/feature/dummy/build.gradle.kts b/feature/dummy/build.gradle.kts new file mode 100644 index 00000000..d398c828 --- /dev/null +++ b/feature/dummy/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) + alias(libs.plugins.dplay.test) +} + +android { + namespace = "com.dplay.dummy" +} diff --git a/feature/dummy/src/main/java/com/example/dummy/DummyContract.kt b/feature/dummy/src/main/java/com/example/dummy/DummyContract.kt new file mode 100644 index 00000000..751f2336 --- /dev/null +++ b/feature/dummy/src/main/java/com/example/dummy/DummyContract.kt @@ -0,0 +1,24 @@ +package com.example.dummy + +import com.example.ui.base.BaseContract + +class DummyContract { + data class DummyState( + val loading: Boolean = false, + val count: Int = 0, + ) : BaseContract.State + + sealed interface DummyIntent : BaseContract.Intent { + data object Initialize : DummyIntent + + data class OnClickNumberButton( + val number: Int, + ) : DummyIntent + } + + sealed interface DummySideEffect : BaseContract.SideEffect { + data class ShowSnackBar( + val message: String, + ) : DummySideEffect + } +} diff --git a/feature/dummy/src/main/java/com/example/dummy/DummyScreen.kt b/feature/dummy/src/main/java/com/example/dummy/DummyScreen.kt new file mode 100644 index 00000000..0d1b3fc6 --- /dev/null +++ b/feature/dummy/src/main/java/com/example/dummy/DummyScreen.kt @@ -0,0 +1,90 @@ +package com.example.dummy + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun DummyScreen( + viewModel: DummyViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + viewModel.handleIntent(DummyContract.DummyIntent.Initialize) + } + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collectLatest { + when (it) { + is DummyContract.DummySideEffect.ShowSnackBar -> { + snackbarHostState.showSnackbar(it.message) + } + } + } + } + + DummyScreen( + snackbarHostState = snackbarHostState, + state = state, + onNumberButtonClick = { + viewModel.handleIntent(DummyContract.DummyIntent.OnClickNumberButton(it)) + }, + ) +} + +@Composable +private fun DummyScreen( + snackbarHostState: SnackbarHostState, + state: DummyContract.DummyState, + onNumberButtonClick: (Int) -> Unit, +) { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "Count: ${state.count}") + Button( + onClick = { onNumberButtonClick(1) }, + ) { + Text(text = "1") + } + + Button( + onClick = { onNumberButtonClick(2) }, + ) { + Text(text = "2") + } + } + } +} + +@Preview +@Composable +private fun DummyScreenPreview() { + DummyScreen() +} diff --git a/feature/dummy/src/main/java/com/example/dummy/DummyViewModel.kt b/feature/dummy/src/main/java/com/example/dummy/DummyViewModel.kt new file mode 100644 index 00000000..c0ca2fb5 --- /dev/null +++ b/feature/dummy/src/main/java/com/example/dummy/DummyViewModel.kt @@ -0,0 +1,35 @@ +package com.example.dummy + +import androidx.lifecycle.viewModelScope +import com.example.domain.repository.DummyRepository +import com.example.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DummyViewModel + @Inject + constructor( + private val dummyRepository: DummyRepository, + ) : BaseViewModel( + DummyContract.DummyState(), + ) { + override fun handleIntent(intent: DummyContract.DummyIntent) { + when (intent) { + DummyContract.DummyIntent.Initialize -> { + viewModelScope.launch { + dummyRepository.getDummy(dummyId = 1L).onSuccess { dummy -> }.onFailure {} + } + } + is DummyContract.DummyIntent.OnClickNumberButton -> increment(intent.number) + } + } + + private fun increment(count: Int) { + updateState { + copy(count = currentState.count + count) + } + setSideEffect(DummyContract.DummySideEffect.ShowSnackBar(count.toString())) + } + } diff --git a/feature/editprofile/build.gradle.kts b/feature/editprofile/build.gradle.kts new file mode 100644 index 00000000..8656240a --- /dev/null +++ b/feature/editprofile/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.editprofile" +} diff --git a/feature/editprofile/src/main/java/com/example/editprofile/EditProfileContract.kt b/feature/editprofile/src/main/java/com/example/editprofile/EditProfileContract.kt new file mode 100644 index 00000000..99279589 --- /dev/null +++ b/feature/editprofile/src/main/java/com/example/editprofile/EditProfileContract.kt @@ -0,0 +1,48 @@ +package com.example.editprofile + +import android.net.Uri +import com.example.designsystem.component.textfield.type.InputState +import com.example.designsystem.component.textfield.type.NicknameInputState +import com.example.domain.model.ProfileImageState +import com.example.ui.base.BaseContract + +class EditProfileContract { + data class EditProfileState( + val nickname: String = "", + val nicknameInputState: InputState = NicknameInputState.Success, + val profileImagePath: String? = null, + val profileImageState: ProfileImageState = ProfileImageState.Keep, + val isAlbumLauncherBottomSheetVisible: Boolean = false, + ) : BaseContract.State { + val isEditButtonEnabled: Boolean + get() = nicknameInputState is InputState.Success + } + + sealed interface EditProfileIntent : BaseContract.Intent { + data object OnBackButtonClick : EditProfileIntent + + data class OnNicknameChanged( + val input: String, + ) : EditProfileIntent + + data object OnProfileImageClick : EditProfileIntent + + data object OnAlbumLauncherBottomSheetDismiss : EditProfileIntent + + data object OnDefaultImageSelect : EditProfileIntent + + data object OnAlbumLauncherSelect : EditProfileIntent + + data class OnAlbumImageSelect( + val uri: Uri?, + ) : EditProfileIntent + + data object OnEditButtonClick : EditProfileIntent + } + + sealed interface EditProfileSideEffect : BaseContract.SideEffect { + data object LaunchAlbum : EditProfileSideEffect + + data object NavigateToBack : EditProfileSideEffect + } +} diff --git a/feature/editprofile/src/main/java/com/example/editprofile/EditProfileNavigationModule.kt b/feature/editprofile/src/main/java/com/example/editprofile/EditProfileNavigationModule.kt new file mode 100644 index 00000000..f9494179 --- /dev/null +++ b/feature/editprofile/src/main/java/com/example/editprofile/EditProfileNavigationModule.kt @@ -0,0 +1,26 @@ +package com.example.editprofile + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.EditProfile +import com.example.navigation.Navigator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object EditProfileNavigationModule { + @Provides + @IntoSet + fun provideEditProfileEntry( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { + EditProfileRoute(navigator = navigator) + } + } +} diff --git a/feature/editprofile/src/main/java/com/example/editprofile/EditProfileScreen.kt b/feature/editprofile/src/main/java/com/example/editprofile/EditProfileScreen.kt new file mode 100644 index 00000000..aa2e59f1 --- /dev/null +++ b/feature/editprofile/src/main/java/com/example/editprofile/EditProfileScreen.kt @@ -0,0 +1,223 @@ +package com.example.editprofile + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dplay.designsystem.R +import com.example.designsystem.component.DPlayButtonBottomSheet +import com.example.designsystem.component.DPlayProfileImageArea +import com.example.designsystem.component.DplayLeftIconTitleTopAppBar +import com.example.designsystem.component.button.DPlayCircleButton +import com.example.designsystem.component.button.DPlayLargePinkButton +import com.example.designsystem.component.button.type.CircleButtonType +import com.example.designsystem.component.textfield.DPlayTextInput +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.TextFieldConstant +import com.example.designsystem.util.noRippleClickable +import com.example.navigation.Navigator +import kotlinx.coroutines.flow.collectLatest +import timber.log.Timber + +@Composable +fun EditProfileRoute( + navigator: Navigator, + modifier: Modifier = Modifier, + viewModel: EditProfileViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + val photoPickerLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> + uri?.let { + viewModel.handleIntent( + EditProfileContract.EditProfileIntent.OnAlbumImageSelect(it), + ) + } + }, + ) + + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + EditProfileContract.EditProfileSideEffect.NavigateToBack -> { + navigator.navigateToBack() + } + + EditProfileContract.EditProfileSideEffect.LaunchAlbum -> { + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + } + } + } + } + + EditProfileScreen( + state = state, + onNicknameChanged = { + viewModel.handleIntent(EditProfileContract.EditProfileIntent.OnNicknameChanged(it)) + }, + onEditButtonClick = { + viewModel.handleIntent(EditProfileContract.EditProfileIntent.OnEditButtonClick) + }, + onProfileImageClick = { + viewModel.handleIntent(EditProfileContract.EditProfileIntent.OnProfileImageClick) + }, + onAlbumLauncherBottomSheetDismiss = { + viewModel.handleIntent(EditProfileContract.EditProfileIntent.OnAlbumLauncherBottomSheetDismiss) + }, + onDefaultImageSelect = { + viewModel.handleIntent(EditProfileContract.EditProfileIntent.OnDefaultImageSelect) + }, + onBackButtonClick = { + viewModel.handleIntent(EditProfileContract.EditProfileIntent.OnBackButtonClick) + }, + onAlbumLauncherSelect = { + viewModel.handleIntent(EditProfileContract.EditProfileIntent.OnAlbumLauncherSelect) + }, + ) +} + +@Composable +fun EditProfileScreen( + state: EditProfileContract.EditProfileState, + modifier: Modifier = Modifier, + onNicknameChanged: (String) -> Unit = {}, + onProfileImageClick: () -> Unit = {}, + onAlbumLauncherBottomSheetDismiss: () -> Unit = {}, + onDefaultImageSelect: () -> Unit = {}, + onEditButtonClick: () -> Unit = {}, + onBackButtonClick: () -> Unit = {}, + onAlbumLauncherSelect: () -> Unit = {}, +) { + BackHandler(enabled = state.isAlbumLauncherBottomSheetVisible, onBack = onAlbumLauncherBottomSheetDismiss) + + Box( + modifier = + modifier + .fillMaxSize(), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayWhite) + .padding(bottom = 16.dp), + ) { + DplayLeftIconTitleTopAppBar( + title = stringResource(com.dplay.editprofile.R.string.edit_profile_screen_title), + ) { onBackButtonClick() } + + Spacer(modifier = Modifier.height(24.dp)) + + DPlayProfileImageArea( + onProfileImageClick = onProfileImageClick, + profileImagePath = state.profileImagePath, + modifier = + Modifier + .size(116.dp) + .align(Alignment.CenterHorizontally), + ) { + DPlayCircleButton( + circleButtonType = + CircleButtonType.SmallPlus( + R.string.add_profile_image_button_icon_description, + ), + onClick = { onProfileImageClick() }, + ) + } + Timber.d("state.profileImagePath : ${state.profileImagePath}") + + Spacer(modifier = Modifier.height(24.dp)) + + DPlayTextInput( + value = state.nickname, + inputState = state.nicknameInputState, + onValueChange = { onNicknameChanged(it) }, + onFocusChange = {}, + placeholder = stringResource(R.string.placeholder_nickname), + maxLength = TextFieldConstant.MAX_NICKNAME_LENGTH, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + DPlayLargePinkButton( + onClick = { onEditButtonClick() }, + label = stringResource(R.string.modify_button_label), + modifier = + Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + enabled = state.isEditButtonEnabled, + ) + } + + // scrim 효과 + if (state.isAlbumLauncherBottomSheetVisible) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dim40) + .noRippleClickable { onAlbumLauncherBottomSheetDismiss() }, + ) + } + + AnimatedVisibility( + visible = state.isAlbumLauncherBottomSheetVisible, + modifier = Modifier.align(Alignment.BottomCenter), + enter = + slideInVertically( + initialOffsetY = { it }, + ), + exit = + slideOutVertically( + targetOffsetY = { it }, + ), + ) { + DPlayButtonBottomSheet( + mainText = stringResource(R.string.launch_album_bottomsheet_main_text), + subText = stringResource(R.string.launch_album_bottomsheet_sub_text), + mainOnClick = { onAlbumLauncherSelect() }, + subOnClick = { onDefaultImageSelect() }, + modifier = Modifier.noRippleClickable(), + ) + } + } +} + +@Preview +@Composable +private fun EditProfileScreenPreview() { + DPlayTheme { + EditProfileScreen( + state = EditProfileContract.EditProfileState(), + ) + } +} diff --git a/feature/editprofile/src/main/java/com/example/editprofile/EditProfileViewModel.kt b/feature/editprofile/src/main/java/com/example/editprofile/EditProfileViewModel.kt new file mode 100644 index 00000000..7d49b7ed --- /dev/null +++ b/feature/editprofile/src/main/java/com/example/editprofile/EditProfileViewModel.kt @@ -0,0 +1,120 @@ +package com.example.editprofile + +import androidx.lifecycle.viewModelScope +import com.example.domain.model.NicknameValidationResult +import com.example.domain.model.ProfileImageState +import com.example.domain.repository.UserRepository +import com.example.domain.usecase.ValidateNicknameUseCase +import com.example.ui.base.BaseViewModel +import com.example.ui.mapper.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class EditProfileViewModel + @Inject + constructor( + private val validateNicknameUseCase: ValidateNicknameUseCase, + private val userRepository: UserRepository, + ) : BaseViewModel( + EditProfileContract.EditProfileState(), + ) { + init { + initializeUserProfile() + } + + override fun handleIntent(intent: EditProfileContract.EditProfileIntent) { + when (intent) { + is EditProfileContract.EditProfileIntent.OnAlbumImageSelect -> { + val imagePath = intent.uri.toString() + updateState { + copy( + profileImagePath = imagePath, + profileImageState = ProfileImageState.Update(imagePath), + isAlbumLauncherBottomSheetVisible = false, + ) + } + } + EditProfileContract.EditProfileIntent.OnAlbumLauncherBottomSheetDismiss -> { + updateState { + copy( + isAlbumLauncherBottomSheetVisible = false, + ) + } + } + EditProfileContract.EditProfileIntent.OnAlbumLauncherSelect -> { + setSideEffect(EditProfileContract.EditProfileSideEffect.LaunchAlbum) + } + EditProfileContract.EditProfileIntent.OnBackButtonClick -> { + setSideEffect(EditProfileContract.EditProfileSideEffect.NavigateToBack) + } + EditProfileContract.EditProfileIntent.OnDefaultImageSelect -> { + updateState { + copy( + profileImagePath = null, + isAlbumLauncherBottomSheetVisible = false, + profileImageState = ProfileImageState.Delete, + ) + } + } + EditProfileContract.EditProfileIntent.OnEditButtonClick -> { + editProfile() + } + is EditProfileContract.EditProfileIntent.OnNicknameChanged -> { + validateAndUpdateNickname(intent.input.trim()) + } + EditProfileContract.EditProfileIntent.OnProfileImageClick -> { + updateState { + copy(isAlbumLauncherBottomSheetVisible = true) + } + } + } + } + + private fun editProfile() { + viewModelScope.launch { + userRepository + .updateProfile( + nickname = currentState.nickname, + profileImageState = currentState.profileImageState, + ).onSuccess { validationResult -> + if (validationResult is NicknameValidationResult.Error.Duplicated) { + updateState { + copy( + nicknameInputState = NicknameValidationResult.Error.Duplicated.toUiState(), + ) + } + } else if (validationResult is NicknameValidationResult.Success) { + setSideEffect(EditProfileContract.EditProfileSideEffect.NavigateToBack) + } + }.onFailure { } + } + } + + private fun validateAndUpdateNickname(nickname: String) { + val validationResult = validateNicknameUseCase(nickname) + val inputState = validationResult.toUiState() + updateState { + copy( + nickname = nickname, + nicknameInputState = inputState, + ) + } + } + + private fun initializeUserProfile() { + userRepository + .getUser() + .onEach { user -> + updateState { + copy( + profileImagePath = user?.profileImagePath, + nickname = user?.nickname ?: "", + ) + } + }.launchIn(viewModelScope) + } + } diff --git a/feature/editprofile/src/main/res/values/strings.xml b/feature/editprofile/src/main/res/values/strings.xml new file mode 100644 index 00000000..df9f811a --- /dev/null +++ b/feature/editprofile/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 프로필 수정 + \ No newline at end of file diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts new file mode 100644 index 00000000..ddcca061 --- /dev/null +++ b/feature/home/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) + alias(libs.plugins.dplay.test) +} + +android { + namespace = "com.dplay.home" +} + +dependencies { + implementation(projects.core.ui) + implementation(projects.core.common) +} diff --git a/feature/home/src/main/java/com/example/home/HomeContract.kt b/feature/home/src/main/java/com/example/home/HomeContract.kt new file mode 100644 index 00000000..e9422eb3 --- /dev/null +++ b/feature/home/src/main/java/com/example/home/HomeContract.kt @@ -0,0 +1,81 @@ +package com.example.home + +import com.example.designsystem.component.snackbar.type.SnackBarType +import com.example.domain.model.Badge +import com.example.domain.model.DailyQuestion +import com.example.domain.model.FeedItem +import com.example.navigation.MyPageTab +import com.example.ui.base.BaseContract +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +class HomeContract { + data class HomeState( + val isLoading: Boolean = true, + val todayQuestion: DailyQuestion = + DailyQuestion( + questionId = 12345, + title = "여행 갈 때 플레이리스트에 꼭 넣는 노래는?", + date = "2025-10-19", + ), + val hasPosted: Boolean = false, + val locked: Boolean = true, + val totalCount: Int = 257, + val feedItems: ImmutableList = persistentListOf(), + val streamingTrackId: String? = null, + ) : BaseContract.State + + sealed interface HomeIntent : BaseContract.Intent { + data object OnRefreshClick : HomeIntent + + data class OnBookmarkClick( + val postId: Long, + ) : HomeIntent + + data class OnStreamClick( + val trackId: String, + ) : HomeIntent + + data class OnLikeClick( + val postId: Long, + ) : HomeIntent + + data object OnListClick : HomeIntent + + data class OnWriterProfileClick( + val writerUserId: Long, + ) : HomeIntent + + data class OnCoverClick( + val postId: Long, + ) : HomeIntent + + data object OnLockedCoverClick : HomeIntent + } + + sealed interface HomeSideEffect : BaseContract.SideEffect { + data object ShowLockedModal : HomeSideEffect + + data class NavigateToWriterProfile( + val writerUserId: Long, + ) : HomeSideEffect + + data class NavigateToPostDetail( + val postId: Long, + val badge: Badge?, + ) : HomeSideEffect + + data object NavigateToRecord : HomeSideEffect + + data class ShowSnackBar( + val snackBarType: SnackBarType, + val action: (() -> Unit)? = null, + ) : HomeSideEffect + + data class NavigateToMyPage( + val initialTab: MyPageTab = MyPageTab.REGISTERED, + ) : HomeSideEffect + + data object ScrollToFirstPage : HomeSideEffect + } +} diff --git a/feature/home/src/main/java/com/example/home/HomeNavigationModule.kt b/feature/home/src/main/java/com/example/home/HomeNavigationModule.kt new file mode 100644 index 00000000..2013b0af --- /dev/null +++ b/feature/home/src/main/java/com/example/home/HomeNavigationModule.kt @@ -0,0 +1,26 @@ +package com.example.home + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.Home +import com.example.navigation.Navigator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object HomeNavigationModule { + @Provides + @IntoSet + fun provideHomeEntry( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { + HomeRoute(navigator = navigator) + } + } +} diff --git a/feature/home/src/main/java/com/example/home/HomeScreen.kt b/feature/home/src/main/java/com/example/home/HomeScreen.kt new file mode 100644 index 00000000..0d87b316 --- /dev/null +++ b/feature/home/src/main/java/com/example/home/HomeScreen.kt @@ -0,0 +1,305 @@ +package com.example.home + +import androidx.compose.foundation.Image +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dplay.designsystem.R +import com.example.designsystem.component.DPlayLargeCover +import com.example.designsystem.component.DPlaySubjectItem +import com.example.designsystem.component.DplayClickableIcon +import com.example.designsystem.component.DplayLogoTopAppBar +import com.example.designsystem.component.button.DPlayBookmarkButton +import com.example.designsystem.component.chip.type.DPlayChipType +import com.example.designsystem.component.snackbar.LocalShowSnackBar +import com.example.designsystem.theme.DPlayTheme +import com.example.domain.model.Badge +import com.example.domain.model.FeedItem +import com.example.navigation.Detail +import com.example.navigation.MyPage +import com.example.navigation.Navigator +import com.example.navigation.OtherProfile +import com.example.navigation.Record +import com.example.navigation.Search +import com.example.ui.controller.LocalModalController +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun HomeRoute( + navigator: Navigator, + viewModel: HomeViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val showSnackBar = LocalShowSnackBar.current + val modalController = LocalModalController.current + val pagerState = rememberPagerState(pageCount = { state.feedItems.size }) + + val lockedModalMainText = stringResource(R.string.recommend_prompt_modal_main_text) + val lockedModalSubText = stringResource(R.string.recommend_prompt_modal_sub_text) + val lockedModalButtonLabel = stringResource(R.string.recommend_prompt_modal_button_label) + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collectLatest { + when (it) { + is HomeContract.HomeSideEffect.NavigateToRecord -> { + navigator.navigateTo(destination = Record) + } + + is HomeContract.HomeSideEffect.NavigateToPostDetail -> { + navigator.navigateTo( + Detail( + postId = it.postId, + badge = it.badge, + ), + ) + } + + is HomeContract.HomeSideEffect.NavigateToWriterProfile -> { + navigator.navigateTo(OtherProfile(userId = it.writerUserId)) + } + + is HomeContract.HomeSideEffect.ShowSnackBar -> { + showSnackBar(it.snackBarType, it.action) + } + + is HomeContract.HomeSideEffect.NavigateToMyPage -> { + navigator.navigateTo(destination = MyPage(initialTab = it.initialTab)) + } + + is HomeContract.HomeSideEffect.ShowLockedModal -> { + modalController.showGraphicModal( + mainText = lockedModalMainText, + subText = lockedModalSubText, + buttonLabel = lockedModalButtonLabel, + onButtonClick = { + modalController.hideModal() + navigator.navigateTo(destination = Search) + }, + onDismiss = { + modalController.hideModal() + }, + ) + } + + is HomeContract.HomeSideEffect.ScrollToFirstPage -> { + pagerState.animateScrollToPage(0) + } + } + } + } + HomeScreen( + uiState = state, + pagerState = pagerState, + onRefresh = { + viewModel.handleIntent(HomeContract.HomeIntent.OnRefreshClick) + }, + onPostClick = { postId -> + viewModel.handleIntent(HomeContract.HomeIntent.OnCoverClick(postId = postId)) + }, + onBookmarkClick = { postId -> + viewModel.handleIntent(HomeContract.HomeIntent.OnBookmarkClick(postId = postId)) + }, + onStreamClick = { trackId -> + viewModel.handleIntent(HomeContract.HomeIntent.OnStreamClick(trackId = trackId)) + }, + onLikeClick = { postId -> + viewModel.handleIntent(HomeContract.HomeIntent.OnLikeClick(postId = postId)) + }, + onWriterProfileClick = { writerUserId -> + viewModel.handleIntent(HomeContract.HomeIntent.OnWriterProfileClick(writerUserId = writerUserId)) + }, + onListClick = { + viewModel.handleIntent(HomeContract.HomeIntent.OnListClick) + }, + onLockedCoverClick = { + viewModel.handleIntent(HomeContract.HomeIntent.OnLockedCoverClick) + }, + ) +} + +@Composable +private fun HomeScreen( + uiState: HomeContract.HomeState = HomeContract.HomeState(), + pagerState: PagerState, + onRefresh: () -> Unit, + onPostClick: (postId: Long) -> Unit, + onBookmarkClick: (postId: Long) -> Unit, + onStreamClick: (trackId: String) -> Unit, + onLikeClick: (postId: Long) -> Unit, + onWriterProfileClick: (Long) -> Unit, + onListClick: () -> Unit, + onLockedCoverClick: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DplayLogoTopAppBar(onListClick = onListClick) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = uiState.todayQuestion.homeTitleDateText, style = DPlayTheme.typography.titleBold18, color = DPlayTheme.colors.dplayBlack) + DplayClickableIcon( + iconRes = R.drawable.ic_refresh_20, + modifier = Modifier.padding(8.dp), + onClick = onRefresh, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + DPlaySubjectItem( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + subject = uiState.todayQuestion.title, + ) + Spacer(modifier = Modifier.height(32.dp)) + HomePager( + pagerState = pagerState, + feedItems = uiState.feedItems, + onPostClick = onPostClick, + onBookmarkClick = onBookmarkClick, + onStreamClick = onStreamClick, + onLikeClick = onLikeClick, + onWriterProfileClick = onWriterProfileClick, + onLockedCoverClick = onLockedCoverClick, + uiState = uiState, + ) + } +} + +@Composable +private fun HomePager( + pagerState: PagerState, + feedItems: List, + onPostClick: (postId: Long) -> Unit, + onBookmarkClick: (postId: Long) -> Unit, + onStreamClick: (trackId: String) -> Unit, + onLikeClick: (postId: Long) -> Unit, + onWriterProfileClick: (Long) -> Unit, + onLockedCoverClick: () -> Unit, + uiState: HomeContract.HomeState, +) { + val currentItem = feedItems.getOrNull(pagerState.currentPage) + val isCurrentPageLocked = uiState.locked && pagerState.currentPage >= 3 + val currentChipType: DPlayChipType? = + currentItem?.badge?.let { + when (it) { + Badge.BEST -> DPlayChipType.BEST + Badge.EDITOR -> DPlayChipType.EDITOR + Badge.NEW -> DPlayChipType.NEW + } + } + + Box(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.height(52.dp)) + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 40.dp), + pageSpacing = 24.dp, + ) { page -> + val item = feedItems[page] + val isLockedPage = uiState.locked && page >= 3 + + DPlayLargeCover( + modifier = Modifier.fillMaxWidth(), + isAdmin = item.writer.isAdmin, + isLocked = isLockedPage, + isLikeChecked = item.like.isLiked, + likeCount = item.like.count, + writerProfileImageUrl = item.writer.profileImg, + writerNickname = item.writer.nickname, + content = item.content, + musicImageUrl = item.track.coverImg, + onStreamClick = { onStreamClick(item.track.trackId) }, + onLikeClick = { onLikeClick(item.postId) }, + onCoverClick = { + if (isLockedPage) { + onLockedCoverClick() + } else { + onPostClick(item.postId) + } + }, + onWriterProfileClick = { + if (!item.writer.isAdmin) onWriterProfileClick(item.writer.userId) + }, + isStreaming = uiState.streamingTrackId == item.track.trackId, + ) + } + } + + currentChipType + ?.takeIf { !isCurrentPageLocked } + ?.let { + Image( + painter = painterResource(id = it.drawableRes), + contentDescription = null, + modifier = + Modifier + .height(height = 32.dp) + .align(Alignment.TopCenter), + ) + } + + currentItem + ?.takeIf { !isCurrentPageLocked } + ?.let { + DPlayBookmarkButton( + isMarked = it.isScrapped, + onClick = { onBookmarkClick(it.postId) }, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 52.dp, end = 40.dp), + ) + } + } +} + +@Preview( + showBackground = true, + backgroundColor = 0xFFFFFFFF, +) +@Composable +private fun HomePreview() { + DPlayTheme { + HomeScreen( + pagerState = rememberPagerState(pageCount = { 0 }), + onPostClick = {}, + onBookmarkClick = {}, + onStreamClick = {}, + onLikeClick = {}, + onWriterProfileClick = {}, + onRefresh = {}, + onListClick = {}, + onLockedCoverClick = {}, + ) + } +} diff --git a/feature/home/src/main/java/com/example/home/HomeViewModel.kt b/feature/home/src/main/java/com/example/home/HomeViewModel.kt new file mode 100644 index 00000000..ac73bca6 --- /dev/null +++ b/feature/home/src/main/java/com/example/home/HomeViewModel.kt @@ -0,0 +1,272 @@ +package com.example.home + +import androidx.lifecycle.viewModelScope +import com.example.common.audio.AudioPlayer +import com.example.common.event.HomeRefreshTrigger +import com.example.designsystem.component.snackbar.type.SnackBarType +import com.example.domain.model.FeedItem +import com.example.domain.model.Like +import com.example.domain.model.Track +import com.example.domain.model.UserRelation +import com.example.domain.model.Writer +import com.example.domain.repository.PostRepository +import com.example.domain.repository.TrackRepository +import com.example.domain.usecase.CheckUserRelationUseCase +import com.example.navigation.MyPageTab +import com.example.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel + @Inject + constructor( + private val postRepository: PostRepository, + private val trackRepository: TrackRepository, + private val audioPlayer: AudioPlayer, + private val homeRefreshTrigger: HomeRefreshTrigger, + private val checkUserRelationUseCase: CheckUserRelationUseCase, + ) : BaseViewModel( + HomeContract.HomeState(), + ) { + init { + observePlaybackState() + observeRefreshTrigger() + getTodayPosts() + } + + private fun observePlaybackState() { + audioPlayer.playbackState + .onEach { playbackState -> + updateState { + copy( + streamingTrackId = + if (playbackState.isPlaying) { + playbackState.currentTrackId + } else { + null + }, + ) + } + }.launchIn(viewModelScope) + } + + private fun observeRefreshTrigger() { + homeRefreshTrigger.refreshEvent + .onEach { + getTodayPosts() + }.launchIn(viewModelScope) + } + + override fun handleIntent(intent: HomeContract.HomeIntent) { + when (intent) { + is HomeContract.HomeIntent.OnBookmarkClick -> toggleBookmark(intent.postId) + is HomeContract.HomeIntent.OnLikeClick -> toggleLike(intent.postId) + is HomeContract.HomeIntent.OnRefreshClick -> refreshTodayPosts() + is HomeContract.HomeIntent.OnStreamClick -> previewStreaming(intent.trackId) + is HomeContract.HomeIntent.OnListClick -> { + setSideEffect(HomeContract.HomeSideEffect.NavigateToRecord) + } + + is HomeContract.HomeIntent.OnWriterProfileClick -> { + navigateToOthersProfile(intent.writerUserId) + } + + is HomeContract.HomeIntent.OnCoverClick -> { + val badge = currentState.feedItems.find { it.postId == intent.postId }?.badge + setSideEffect(HomeContract.HomeSideEffect.NavigateToPostDetail(postId = intent.postId, badge = badge)) + } + + is HomeContract.HomeIntent.OnLockedCoverClick -> { + setSideEffect(HomeContract.HomeSideEffect.ShowLockedModal) + } + } + } + + private fun getTodayPosts() { + viewModelScope.launch { + postRepository + .getTodayPosts() + .onSuccess { data -> + val feedItems = + if (data.locked) { + data.todayPosts + lockedDummyFeedItem + } else { + data.todayPosts + } + updateState { + copy( + todayQuestion = data.todayQuestion, + hasPosted = data.hasPosted, + locked = data.locked, + totalCount = data.totalCount, + feedItems = feedItems.toImmutableList(), + ) + } + }.onFailure { e -> + Timber.e(e) + } + } + } + + companion object { + private val lockedDummyFeedItem = + FeedItem( + postId = -1L, + isScrapped = false, + content = "", + badge = null, + track = + Track( + trackId = "", + songTitle = "", + coverImg = "", + artistName = "", + isrc = "", + ), + writer = + Writer( + userId = -1L, + nickname = "", + profileImg = "", + isAdmin = false, + ), + like = + Like( + isLiked = false, + count = 0, + ), + ) + } + + private fun previewStreaming(trackId: String) { + val currentStreamingTrackId = audioPlayer.playbackState.value.currentTrackId + if (currentStreamingTrackId == trackId && audioPlayer.playbackState.value.isPlaying) { + audioPlayer.stop() + return + } + + val feedItem = currentState.feedItems.find { it.track.trackId == trackId } + + viewModelScope.launch { + trackRepository + .getTrackPreview(trackId = trackId) + .onSuccess { preview -> + audioPlayer.play( + url = preview.streamUrl, + trackId = trackId, + title = feedItem?.track?.songTitle ?: "", + artist = feedItem?.track?.artistName ?: "", + ) + }.onFailure { e -> + Timber.e(e) + setSideEffect( + HomeContract.HomeSideEffect.ShowSnackBar( + snackBarType = SnackBarType.STREAMING_NOT_SUPPORT, + ), + ) + } + } + } + + private fun refreshTodayPosts() { + getTodayPosts() + setSideEffect(HomeContract.HomeSideEffect.ScrollToFirstPage) + } + + private fun toggleBookmark(postId: Long) { + viewModelScope.launch { + val feedItem = currentState.feedItems.find { it.postId == postId } ?: return@launch + val isScrapped = feedItem.isScrapped + + val result = + if (isScrapped) { + postRepository.deletePostScrap(postId) + } else { + postRepository.postPostScrap(postId) + } + + result + .onSuccess { + updateState { + copy( + feedItems = + feedItems + .map { item -> + if (item.postId == postId) { + item.copy(isScrapped = !isScrapped) + } else { + item + } + }.toImmutableList(), + ) + } + if (!isScrapped) { + setSideEffect( + HomeContract.HomeSideEffect.ShowSnackBar( + snackBarType = SnackBarType.ADD, + action = { setSideEffect(HomeContract.HomeSideEffect.NavigateToMyPage(initialTab = MyPageTab.BOOKMARKED)) }, + ), + ) + } + }.onFailure { e -> + Timber.e(e) + } + } + } + + private fun toggleLike(postId: Long) { + viewModelScope.launch { + val feedItem = currentState.feedItems.find { it.postId == postId } ?: return@launch + val isLiked = feedItem.like.isLiked + + val result = + if (isLiked) { + postRepository.deletePostLike(postId) + } else { + postRepository.postPostLike(postId) + } + + result + .onSuccess { newCount -> + updateState { + copy( + feedItems = + feedItems + .map { item -> + if (item.postId == postId) { + item.copy( + like = + Like( + isLiked = !isLiked, + count = newCount, + ), + ) + } else { + item + } + }.toImmutableList(), + ) + } + }.onFailure { e -> + Timber.e(e) + } + } + } + + private fun navigateToOthersProfile(userId: Long) { + viewModelScope.launch { + val userRelation = checkUserRelationUseCase(userId) + + when (userRelation) { + UserRelation.ME -> setSideEffect(HomeContract.HomeSideEffect.NavigateToMyPage()) + UserRelation.OTHER -> setSideEffect(HomeContract.HomeSideEffect.NavigateToWriterProfile(userId)) + } + } + } + } diff --git a/feature/login/build.gradle.kts b/feature/login/build.gradle.kts new file mode 100644 index 00000000..95b3ee0e --- /dev/null +++ b/feature/login/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.login" +} diff --git a/feature/login/src/main/java/com/example/login/LoginContract.kt b/feature/login/src/main/java/com/example/login/LoginContract.kt new file mode 100644 index 00000000..cd9750b5 --- /dev/null +++ b/feature/login/src/main/java/com/example/login/LoginContract.kt @@ -0,0 +1,21 @@ +package com.example.login + +import com.example.ui.base.BaseContract + +class LoginContract { + data class LoginState( + val loading: Boolean = false, + ) : BaseContract.State + + sealed interface LoginIntent : BaseContract.Intent { + data object OnKakaoLogin : LoginIntent + } + + sealed interface LoginSideEffect : BaseContract.SideEffect { + data class NavigateToOnboarding( + val kakaoAccessToken: String, + ) : LoginSideEffect + + data object NavigateToHome : LoginSideEffect + } +} diff --git a/feature/login/src/main/java/com/example/login/LoginNavigationModule.kt b/feature/login/src/main/java/com/example/login/LoginNavigationModule.kt new file mode 100644 index 00000000..06d7de63 --- /dev/null +++ b/feature/login/src/main/java/com/example/login/LoginNavigationModule.kt @@ -0,0 +1,28 @@ +package com.example.login + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.Login +import com.example.navigation.Navigator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object LoginNavigationModule { + @Provides + @IntoSet + fun provideLoginEntry( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { + LoginRoute( + navigator = navigator, + ) + } + } +} diff --git a/feature/login/src/main/java/com/example/login/LoginScreen.kt b/feature/login/src/main/java/com/example/login/LoginScreen.kt new file mode 100644 index 00000000..b2e3fedc --- /dev/null +++ b/feature/login/src/main/java/com/example/login/LoginScreen.kt @@ -0,0 +1,95 @@ +package com.example.login + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.dplay.designsystem.R +import com.example.designsystem.component.button.DPlayKakaoLoginButton +import com.example.designsystem.theme.DPlayTheme +import com.example.navigation.Home +import com.example.navigation.Navigator +import com.example.navigation.OnboardingGraph +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun LoginRoute( + navigator: Navigator, + viewModel: LoginViewModel = hiltViewModel(), +) { + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + is LoginContract.LoginSideEffect.NavigateToOnboarding -> { + navigator.navigateTo(OnboardingGraph(sideEffect.kakaoAccessToken)) + } + LoginContract.LoginSideEffect.NavigateToHome -> { + navigator.clearAndNavigateTo(Home) + } + } + } + } + LoginScreen( + onKaKaoLogin = { + viewModel.handleIntent(LoginContract.LoginIntent.OnKakaoLogin) + }, + ) +} + +@Composable +fun LoginScreen( + onKaKaoLogin: () -> Unit = {}, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayWhite) + .padding(top = 200.dp, bottom = 16.dp) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(com.dplay.login.R.string.login_title), + style = DPlayTheme.typography.bodyBold16, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Image( + painter = painterResource(R.drawable.img_wordmark_pink), + contentDescription = null, + modifier = Modifier.size(width = 200.dp, height = 60.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + DPlayKakaoLoginButton( + onClick = onKaKaoLogin, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun LoginScreenPreview() { + DPlayTheme { + LoginScreen() + } +} diff --git a/feature/login/src/main/java/com/example/login/LoginViewModel.kt b/feature/login/src/main/java/com/example/login/LoginViewModel.kt new file mode 100644 index 00000000..e65606ad --- /dev/null +++ b/feature/login/src/main/java/com/example/login/LoginViewModel.kt @@ -0,0 +1,41 @@ +package com.example.login + +import androidx.lifecycle.viewModelScope +import com.example.domain.repository.AuthRepository +import com.example.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel + @Inject + constructor( + private val authRepository: AuthRepository, + ) : BaseViewModel( + LoginContract.LoginState(), + ) { + override fun handleIntent(intent: LoginContract.LoginIntent) { + when (intent) { + LoginContract.LoginIntent.OnKakaoLogin -> { + kakaoLogin() + } + } + } + + private fun kakaoLogin() { + viewModelScope.launch { + authRepository + .kakaoLogin() + .onSuccess { data -> + if (data.isEmpty()) { + setSideEffect(LoginContract.LoginSideEffect.NavigateToHome) + } else { + setSideEffect(LoginContract.LoginSideEffect.NavigateToOnboarding(data)) + } + }.onFailure { + // 카카오 로그인 실패 처리 + } + } + } + } diff --git a/feature/login/src/main/res/values/strings.xml b/feature/login/src/main/res/values/strings.xml new file mode 100644 index 00000000..aaa30930 --- /dev/null +++ b/feature/login/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 새로움을 발견하는 순간 + \ No newline at end of file diff --git a/feature/main/.gitignore b/feature/main/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/main/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts new file mode 100644 index 00000000..b1da0018 --- /dev/null +++ b/feature/main/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) + alias(libs.plugins.dplay.test) +} + +android { + namespace = "com.dplay.main" +} + +dependencies { + implementation(projects.feature.splash) + implementation(projects.feature.login) + implementation(projects.feature.home) + implementation(projects.feature.detail) + implementation(projects.feature.mypage) + implementation(projects.feature.search) + implementation(projects.feature.comment) + implementation(projects.feature.record) + implementation(projects.feature.onboarding) + implementation(projects.feature.setting) + implementation(projects.feature.editprofile) + implementation(projects.feature.otherprofile) +} diff --git a/feature/main/consumer-rules.pro b/feature/main/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/main/proguard-rules.pro b/feature/main/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/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 \ No newline at end of file diff --git a/app/src/androidTest/java/com/dplay/ExampleInstrumentedTest.kt b/feature/main/src/androidTest/java/com/example/main/ExampleInstrumentedTest.kt similarity index 80% rename from app/src/androidTest/java/com/dplay/ExampleInstrumentedTest.kt rename to feature/main/src/androidTest/java/com/example/main/ExampleInstrumentedTest.kt index 37055ad4..fc2dfd32 100644 --- a/app/src/androidTest/java/com/dplay/ExampleInstrumentedTest.kt +++ b/feature/main/src/androidTest/java/com/example/main/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ -package com.dplay +package com.example.main -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -19,6 +17,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.dplay", appContext.packageName) + assertEquals("com.example.main.test", appContext.packageName) } -} \ No newline at end of file +} diff --git a/feature/main/src/main/AndroidManifest.xml b/feature/main/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a6534b62 --- /dev/null +++ b/feature/main/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature/main/src/main/java/com/example/main/BottomNavigationBar.kt b/feature/main/src/main/java/com/example/main/BottomNavigationBar.kt new file mode 100644 index 00000000..dd5a5f25 --- /dev/null +++ b/feature/main/src/main/java/com/example/main/BottomNavigationBar.kt @@ -0,0 +1,118 @@ +package com.example.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.designsystem.component.DplayBaseIcon +import com.example.designsystem.component.button.DPlayCircleButton +import com.example.designsystem.component.button.type.CircleButtonType +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable +import com.example.navigation.Home +import com.example.navigation.MyPage +import com.example.navigation.TopLevelRoute +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun BottomNavigationBar( + isVisible: Boolean, + topLevelRouteList: ImmutableList, + currentTab: Any?, + onBottomNavigationItemClick: (TopLevelRoute) -> Unit, + onPlusButtonClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (isVisible) { + Box( + modifier = + modifier + .fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)) + .background(color = DPlayTheme.colors.dplayWhite) + .border( + width = 1.dp, + color = DPlayTheme.colors.gray200, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + ).padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + topLevelRouteList.forEach { tab -> + BottomNavigationItem( + isSelected = currentTab?.let { it::class == tab::class } ?: false, + tab = tab, + onBottomNavigationItemClick = { onBottomNavigationItemClick(it) }, + ) + } + } + + DPlayCircleButton( + circleButtonType = CircleButtonType.LargePlus(), + onClick = onPlusButtonClick, + modifier = + Modifier + .align(Alignment.TopCenter) + .offset(y = (-28).dp), + ) + } + } +} + +@Composable +private fun BottomNavigationItem( + isSelected: Boolean, + tab: TopLevelRoute, + onBottomNavigationItemClick: (TopLevelRoute) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .noRippleClickable( + onClick = { onBottomNavigationItemClick(tab) }, + ).padding(vertical = 12.dp, horizontal = 48.dp), + contentAlignment = Alignment.Center, + ) { + DplayBaseIcon( + iconRes = if (isSelected) tab.selectedIconRes else tab.unselectedIconRes, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun BottomNavigationBarPreview() { + var currentTab by remember { mutableStateOf(Home) } + DPlayTheme { + BottomNavigationBar( + isVisible = true, + topLevelRouteList = persistentListOf(Home, MyPage()), + currentTab = currentTab, + onBottomNavigationItemClick = { + currentTab = it + }, + onPlusButtonClick = {}, + ) + } +} diff --git a/feature/main/src/main/java/com/example/main/MainActivity.kt b/feature/main/src/main/java/com/example/main/MainActivity.kt new file mode 100644 index 00000000..c4dc44a9 --- /dev/null +++ b/feature/main/src/main/java/com/example/main/MainActivity.kt @@ -0,0 +1,162 @@ +package com.example.main + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.background +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.material3.Scaffold +import androidx.compose.runtime.CompositionLocalProvider +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.unit.dp +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.example.designsystem.component.snackbar.DPlaySnackBar +import com.example.designsystem.component.snackbar.LocalShowSnackBar +import com.example.designsystem.component.snackbar.LocalSnackBarState +import com.example.designsystem.component.snackbar.type.SnackBarType +import com.example.designsystem.theme.DPlayTheme +import com.example.navigation.Navigator +import com.example.navigation.Search +import com.example.ui.controller.BottomNavigationController +import com.example.ui.controller.LocalBottomNavigationController +import com.example.ui.controller.LocalModalController +import com.example.ui.controller.ModalController +import com.example.ui.handler.AppTerminationHandler +import com.example.ui.handler.GlobalModalHandler +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + @Inject + lateinit var navigator: Navigator + + @Inject + lateinit var entryProviders: Set<@JvmSuppressWildcards EntryProviderScope.() -> Unit> + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + val modalController = remember { ModalController() } + val bottomNavigationController = remember { BottomNavigationController() } + val appTerminationHandler = remember(this) { AppTerminationHandler(this) } + var snackBarType by remember { mutableStateOf(null) } + var snackBarAction by remember { mutableStateOf<(() -> Unit)?>(null) } + + CompositionLocalProvider( + LocalModalController provides modalController, + LocalBottomNavigationController provides bottomNavigationController, + LocalSnackBarState provides snackBarType, + LocalShowSnackBar provides { type, action -> + snackBarType = type + snackBarAction = action + }, + ) { + DPlayTheme { + BackHandler(enabled = navigator.backStack.size <= 1) { + appTerminationHandler.onBackPress() + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + Scaffold( + modifier = + Modifier.navigationBarsPadding(), + bottomBar = { + BottomNavigationBar( + isVisible = navigator.shouldShowBottomSheet && bottomNavigationController.bottomNavigationVisible, + topLevelRouteList = navigator.topLevelRoutes, + currentTab = navigator.currentScreen, + onBottomNavigationItemClick = { route -> + navigator.navigateToTopLevelRoute(route) + }, + onPlusButtonClick = { + navigator.navigateTo(Search) + }, + ) + }, + ) { padding -> + val bottomPadding = if (navigator.shouldShowBottomSheet) padding.calculateBottomPadding() else 0.dp + NavDisplay( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayWhite) + .padding(bottom = bottomPadding), + backStack = navigator.backStack, + onBack = { + navigator.navigateToBack() + }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + transitionSpec = { + ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None, + ) + }, + popTransitionSpec = { + ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None, + ) + }, + entryProvider = + entryProvider { + entryProviders.forEach { installer -> + installer() + } + }, + ) + } + + GlobalModalHandler( + modifier = Modifier.align(Alignment.Center), + ) + snackBarType?.let { type -> + DPlaySnackBar( + type = type, + onActionClick = { + snackBarAction?.invoke() + snackBarType = null + snackBarAction = null + }, + onDismiss = { + snackBarType = null + snackBarAction = null + }, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 80.dp, start = 16.dp, end = 16.dp), + ) + } + } + } + } + } + } +} diff --git a/app/src/test/java/com/dplay/ExampleUnitTest.kt b/feature/main/src/test/java/com/example/main/ExampleUnitTest.kt similarity index 81% rename from app/src/test/java/com/dplay/ExampleUnitTest.kt rename to feature/main/src/test/java/com/example/main/ExampleUnitTest.kt index 94fcfa56..72d8fe96 100644 --- a/app/src/test/java/com/dplay/ExampleUnitTest.kt +++ b/feature/main/src/test/java/com/example/main/ExampleUnitTest.kt @@ -1,9 +1,8 @@ -package com.dplay +package com.example.main +import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.Assert.* - /** * Example local unit test, which will execute on the development machine (host). * @@ -14,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/feature/mypage/build.gradle.kts b/feature/mypage/build.gradle.kts new file mode 100644 index 00000000..fbebe5ea --- /dev/null +++ b/feature/mypage/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.mypage" +} diff --git a/feature/mypage/src/main/java/com/example/mypage/MyPageContract.kt b/feature/mypage/src/main/java/com/example/mypage/MyPageContract.kt new file mode 100644 index 00000000..431b2e76 --- /dev/null +++ b/feature/mypage/src/main/java/com/example/mypage/MyPageContract.kt @@ -0,0 +1,62 @@ +package com.example.mypage + +import com.example.ui.base.BaseContract +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentSetOf + +class MyPageContract { + data class MyPageState( + val isLoading: Boolean = false, + val userNickname: String = "디플레이", + val profileImagePath: String? = null, + val selectedTabIndex: Int = 0, + val registeredMusicCount: Int = 0, + val isDeleteBottomSheetVisible: Boolean = false, + val selectedPostId: Long = -1, + val deletedTrackIds: PersistentSet = persistentSetOf(), + ) : BaseContract.State + + sealed interface MyPageIntent : BaseContract.Intent { + data object OnSettingIconClick : MyPageIntent + + data object OnProfileClick : MyPageIntent + + data class OnTabClick( + val tabIndex: Int, + ) : MyPageIntent + + data class OnScrappedTrackClick( + val postId: Long, + ) : MyPageIntent + + data class OnKebabIconClick( + val musicId: Long, + ) : MyPageIntent + + data class OnRegisteredTrackClick( + val postId: Long, + ) : MyPageIntent + + data object OnBottomSheetDeleteClick : MyPageIntent + + data object OnBottomSheetCancelClick : MyPageIntent + + data object OnDialogDeleteClick : MyPageIntent + } + + sealed interface MyPageSideEffect : BaseContract.SideEffect { + data object NavigateToSettings : MyPageSideEffect + + data object NavigateToEditProfile : MyPageSideEffect + + data class NavigateToDetail( + val postId: Long, + ) : MyPageSideEffect + + data object ShowDeleteDialogue : MyPageSideEffect + + data object HideBottomNavigation : MyPageSideEffect + + data object ShowBottomNavigation : MyPageSideEffect + } +} diff --git a/feature/mypage/src/main/java/com/example/mypage/MyPageNavigationModule.kt b/feature/mypage/src/main/java/com/example/mypage/MyPageNavigationModule.kt new file mode 100644 index 00000000..afbc9ffd --- /dev/null +++ b/feature/mypage/src/main/java/com/example/mypage/MyPageNavigationModule.kt @@ -0,0 +1,29 @@ +package com.example.mypage + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.MyPage +import com.example.navigation.Navigator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object MyPageNavigationModule { + @Provides + @IntoSet + fun provideMyPageEntry( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { myPage -> + MyPageRoute( + navigator = navigator, + initialTab = myPage.initialTab, + ) + } + } +} diff --git a/feature/mypage/src/main/java/com/example/mypage/MyPageScreen.kt b/feature/mypage/src/main/java/com/example/mypage/MyPageScreen.kt new file mode 100644 index 00000000..b34bacc6 --- /dev/null +++ b/feature/mypage/src/main/java/com/example/mypage/MyPageScreen.kt @@ -0,0 +1,564 @@ +package com.example.mypage + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.dplay.designsystem.R +import com.example.designsystem.component.DPlayButtonBottomSheet +import com.example.designsystem.component.DPlayMusicGridItem +import com.example.designsystem.component.DPlayMusicListItem +import com.example.designsystem.component.DPlayProfileImageArea +import com.example.designsystem.component.DplayRightIconTitleTopAppBar +import com.example.designsystem.component.button.DPlayCircleButton +import com.example.designsystem.component.button.type.CircleButtonType +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable +import com.example.navigation.Detail +import com.example.navigation.EditProfile +import com.example.navigation.MyPageTab +import com.example.navigation.Navigator +import com.example.navigation.Setting +import com.example.ui.controller.LocalBottomNavigationController +import com.example.ui.controller.LocalModalController +import com.example.ui.emptyLazyPagingItems +import com.example.ui.model.RegisteredTrackState +import com.example.ui.model.ScrappedTrackState +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun MyPageRoute( + navigator: Navigator, + modifier: Modifier = Modifier, + initialTab: MyPageTab = MyPageTab.REGISTERED, + viewModel: MyPageViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var hasAppliedInitialTab by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + if (!hasAppliedInitialTab) { + val tabIndex = + when (initialTab) { + MyPageTab.REGISTERED -> 0 + MyPageTab.BOOKMARKED -> 1 + } + viewModel.handleIntent(MyPageContract.MyPageIntent.OnTabClick(tabIndex)) + hasAppliedInitialTab = true + } + } + + val registeredTracks = viewModel.registeredTracks.collectAsLazyPagingItems() + val scrappedTracks = viewModel.scrappedTracks.collectAsLazyPagingItems() + + val context = LocalContext.current + val bottomNavigationController = LocalBottomNavigationController.current + val modalController = LocalModalController.current + + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + is MyPageContract.MyPageSideEffect.NavigateToDetail -> { + navigator.navigateTo(destination = Detail(postId = sideEffect.postId)) + } + MyPageContract.MyPageSideEffect.NavigateToEditProfile -> { + navigator.navigateTo(destination = EditProfile) + } + MyPageContract.MyPageSideEffect.NavigateToSettings -> { + navigator.navigateTo(destination = Setting) + } + MyPageContract.MyPageSideEffect.HideBottomNavigation -> { + bottomNavigationController.hide() + } + MyPageContract.MyPageSideEffect.ShowBottomNavigation -> { + bottomNavigationController.show() + } + MyPageContract.MyPageSideEffect.ShowDeleteDialogue -> { + modalController.showWarningModal( + mainText = "정말 삭제하시겠어요?", + subText = null, + onLeftButtonClick = { modalController.hideModal() }, + onRightButtonClick = { + modalController.hideModal() + viewModel.handleIntent(MyPageContract.MyPageIntent.OnDialogDeleteClick) + }, + onDismiss = { modalController.hideModal() }, + leftButtonLabel = "취소", + rightButtonLabel = "삭제하기", + ) + } + } + } + } + + MyPageScreen( + state = state, + registeredTrackList = registeredTracks, + scrappedTrackList = scrappedTracks, + modifier = modifier, + onTabSelected = { + viewModel.handleIntent(MyPageContract.MyPageIntent.OnTabClick(it)) + }, + onSettingIconClick = { + viewModel.handleIntent(MyPageContract.MyPageIntent.OnSettingIconClick) + }, + onProfileImageClick = { + viewModel.handleIntent(MyPageContract.MyPageIntent.OnProfileClick) + }, + onScrappedTrackClick = { + viewModel.handleIntent(MyPageContract.MyPageIntent.OnScrappedTrackClick(it)) + }, + onKebabIconClick = { + viewModel.handleIntent(MyPageContract.MyPageIntent.OnKebabIconClick(it)) + }, + onBottomSheetCancelClick = { + viewModel.handleIntent(MyPageContract.MyPageIntent.OnBottomSheetCancelClick) + }, + onBottomSheetDeleteClick = { + viewModel.handleIntent(MyPageContract.MyPageIntent.OnBottomSheetDeleteClick) + }, + onRegisteredTrackClick = { + viewModel.handleIntent(MyPageContract.MyPageIntent.OnRegisteredTrackClick(it)) + }, + ) +} + +@Composable +fun MyPageScreen( + state: MyPageContract.MyPageState, + registeredTrackList: LazyPagingItems, + scrappedTrackList: LazyPagingItems, + modifier: Modifier = Modifier, + onTabSelected: (Int) -> Unit = {}, + onSettingIconClick: () -> Unit = {}, + onProfileImageClick: () -> Unit = {}, + onScrappedTrackClick: (Long) -> Unit = {}, + onKebabIconClick: (Long) -> Unit = {}, + onBottomSheetCancelClick: () -> Unit = {}, + onBottomSheetDeleteClick: () -> Unit = {}, + onRegisteredTrackClick: (Long) -> Unit = {}, +) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = + modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite), + ) { + DplayRightIconTitleTopAppBar( + title = stringResource(com.dplay.mypage.R.string.mypage_screen_title), + ) { + onSettingIconClick() + } + + Spacer(modifier = Modifier.height(12.dp)) + + UserInformationRow( + nickname = state.userNickname, + registeredMusicCount = state.registeredMusicCount, + profileImagePath = state.profileImagePath, + onProfileImageClick = { onProfileImageClick() }, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + TabContent( + selectedTabIndex = state.selectedTabIndex, + onTabSelected = onTabSelected, + registeredTrackList = registeredTrackList, + scrappedTrackList = scrappedTrackList, + onScrappedTrackClick = onScrappedTrackClick, + onKebabIconClick = onKebabIconClick, + onRegisteredTrackClick = onRegisteredTrackClick, + ) + } + if (state.isDeleteBottomSheetVisible) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dim40) + .noRippleClickable { onBottomSheetCancelClick() }, + ) + } + + AnimatedVisibility( + visible = state.isDeleteBottomSheetVisible, + modifier = Modifier.align(Alignment.BottomCenter), + enter = + slideInVertically( + initialOffsetY = { it }, + ), + exit = + slideOutVertically( + targetOffsetY = { 0 }, + ), + ) { + DPlayButtonBottomSheet( + mainText = "삭제하기", + subText = "취소하기", + mainOnClick = { onBottomSheetDeleteClick() }, + subOnClick = { onBottomSheetCancelClick() }, + modifier = Modifier.noRippleClickable(), + mainButtonColor = DPlayTheme.colors.alertRed, + ) + } + } +} + +@Composable +private fun UserInformationRow( + nickname: String, + registeredMusicCount: Int, + profileImagePath: String?, + onProfileImageClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.padding(vertical = 12.5.dp), + ) { + Text( + text = nickname, + style = DPlayTheme.typography.titleBold24, + color = DPlayTheme.colors.dplayBlack, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = + buildAnnotatedString { + val rawText = stringResource(id = com.dplay.mypage.R.string.registered_music_count) + val parts = rawText.split($$"""%1$s""") + + append(parts[0]) + withStyle(style = SpanStyle(color = DPlayTheme.colors.dplayPink)) { + append("$registeredMusicCount") + } + append(parts[1]) + }, + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.gray400, + ) + } + + DPlayProfileImageArea( + onProfileImageClick = onProfileImageClick, + profileImagePath = profileImagePath, + modifier = Modifier.size(80.dp), + ) { + DPlayCircleButton( + circleButtonType = + CircleButtonType.SmallEdit( + R.string.edit_profile_image_button_icon_description, + ), + onClick = { onProfileImageClick() }, + ) + } + } +} + +@Composable +private fun TabContent( + selectedTabIndex: Int, + registeredTrackList: LazyPagingItems, + scrappedTrackList: LazyPagingItems, + onTabSelected: (Int) -> Unit, + onScrappedTrackClick: (Long) -> Unit = {}, + onKebabIconClick: (Long) -> Unit = {}, + onRegisteredTrackClick: (Long) -> Unit = {}, +) { + Column { + MyPageTabRow( + selectedTabIndex = selectedTabIndex, + onTabSelected = onTabSelected, + ) + + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.gray100), + ) { + when (selectedTabIndex) { + 0 -> + RegisteredMusicList( + registeredTrackList = registeredTrackList, + onKebabIconClick = onKebabIconClick, + onRegisteredTrackClick = onRegisteredTrackClick, + ) + 1 -> + BookmarkedMusicList( + scrappedTrackList = scrappedTrackList, + onScrappedTrackClick = onScrappedTrackClick, + ) + } + } + } +} + +@Composable +private fun MyPageTabRow( + selectedTabIndex: Int, + modifier: Modifier = Modifier, + onTabSelected: (Int) -> Unit = {}, +) { + val tabs = + listOf( + stringResource(com.dplay.mypage.R.string.registered_music_tab_label), + stringResource(com.dplay.mypage.R.string.bookmarked_music_tab_label), + ) + val indicatorHorizontalPadding = 28.dp + + val density = LocalDensity.current + val textWidths = + remember { + mutableStateListOf().apply { repeat(tabs.size) { add(0.dp) } } + } + + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val totalWidth = maxWidth + val tabWidth = totalWidth / tabs.size + + Column { + Row(modifier = Modifier.fillMaxWidth()) { + tabs.forEachIndexed { index, title -> + Box( + modifier = + Modifier + .weight(1f) + .noRippleClickable { + onTabSelected(index) + }.padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = title, + style = DPlayTheme.typography.bodyBold16, + color = if (selectedTabIndex == index) DPlayTheme.colors.dplayBlack else DPlayTheme.colors.gray300, + onTextLayout = { textLayoutResult -> + textWidths[index] = with(density) { textLayoutResult.size.width.toDp() } + }, + ) + } + } + } + + // 인디케이터 영역 + Box( + modifier = + Modifier + .fillMaxWidth() + .height(2.dp), + ) { + val currentTextWidth = textWidths[selectedTabIndex] + + val targetIndicatorWidth = currentTextWidth + (indicatorHorizontalPadding * 2) + val targetIndicatorOffset = (tabWidth * selectedTabIndex) + ((tabWidth - targetIndicatorWidth) / 2) + + val indicatorOffset by animateDpAsState(targetValue = targetIndicatorOffset, label = "offset") + val indicatorWidth by animateDpAsState(targetValue = targetIndicatorWidth, label = "width") + + // 인디케이터 + Box( + modifier = + Modifier + .offset(x = indicatorOffset) + .width(indicatorWidth) + .height(2.dp) + .background(color = DPlayTheme.colors.dplayBlack), + ) + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(color = DPlayTheme.colors.gray200) + .align(Alignment.BottomCenter), + ) + } + } + } +} + +@Composable +private fun RegisteredMusicList( + registeredTrackList: LazyPagingItems, + modifier: Modifier = Modifier, + onKebabIconClick: (Long) -> Unit = {}, + onRegisteredTrackClick: (Long) -> Unit = {}, +) { + if (registeredTrackList.itemCount == 0) { + RegisteredMusicEmptyView() + } else { + Spacer(modifier = Modifier.height(12.dp)) + + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + count = registeredTrackList.itemCount, + key = registeredTrackList.itemKey { it.postId }, + ) { index -> + val registeredTrack = registeredTrackList[index] + + if (registeredTrack != null) { + DPlayMusicListItem( + musicImageUrl = registeredTrack.track.thumbnailUrl, + musicName = registeredTrack.track.musicTitle, + musicArtistName = registeredTrack.track.artistName, + musicContent = registeredTrack.comment, + onMoreClick = { onKebabIconClick(registeredTrack.postId) }, + onClick = { onRegisteredTrackClick(registeredTrack.postId) }, + ) + } + } + } + } +} + +@Composable +private fun BookmarkedMusicList( + scrappedTrackList: LazyPagingItems, + modifier: Modifier = Modifier, + onScrappedTrackClick: (Long) -> Unit = {}, +) { + if (scrappedTrackList.itemCount == 0) { + ScrappedMusicEmptyView() + } else { + LazyVerticalGrid( + modifier = modifier, + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + count = scrappedTrackList.itemCount, + key = scrappedTrackList.itemKey { it.postId }, + ) { index -> + val scrappedTrack = scrappedTrackList[index] + + if (scrappedTrack != null) { + DPlayMusicGridItem( + musicImageUrl = scrappedTrack.track.thumbnailUrl, + musicName = scrappedTrack.track.musicTitle, + musicArtistName = scrappedTrack.track.artistName, + onClick = { onScrappedTrackClick(scrappedTrack.postId) }, + ) + } else { + } + } + } + } +} + +@Composable +private fun RegisteredMusicEmptyView() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(184.dp)) + + Text( + text = "아직 등록한 곡이 없어요", + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.gray400, + ) + } +} + +@Composable +private fun ScrappedMusicEmptyView() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(184.dp)) + + Text( + text = "아직 저장한 곡이 없어요", + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.gray400, + ) + } +} + +@Preview +@Composable +private fun MyPageScreenPreview() { + var uiState by remember { + mutableStateOf( + MyPageContract.MyPageState( + userNickname = "디플레이", + ), + ) + } + + DPlayTheme { + MyPageScreen( + state = uiState, + registeredTrackList = emptyLazyPagingItems(), + scrappedTrackList = emptyLazyPagingItems(), + onTabSelected = { + uiState = uiState.copy(selectedTabIndex = it) + }, + ) + } +} diff --git a/feature/mypage/src/main/java/com/example/mypage/MyPageViewModel.kt b/feature/mypage/src/main/java/com/example/mypage/MyPageViewModel.kt new file mode 100644 index 00000000..dae78e5e --- /dev/null +++ b/feature/mypage/src/main/java/com/example/mypage/MyPageViewModel.kt @@ -0,0 +1,164 @@ +package com.example.mypage + +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.filter +import androidx.paging.map +import com.example.common.event.RegisteredTrackRefreshTrigger +import com.example.common.event.ScrappedTrackRefreshTrigger +import com.example.domain.repository.PostRepository +import com.example.domain.repository.UserRepository +import com.example.domain.usecase.GetRegisteredTracksUseCase +import com.example.domain.usecase.GetScrappedTracksUseCase +import com.example.ui.base.BaseViewModel +import com.example.ui.model.RegisteredTrackState +import com.example.ui.model.ScrappedTrackState +import com.example.ui.model.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class MyPageViewModel + @Inject + constructor( + private val userRepository: UserRepository, + private val postRepository: PostRepository, + private val getMyRegisteredTracksUseCase: GetRegisteredTracksUseCase, + private val getMyScrappedTracksUseCase: GetScrappedTracksUseCase, + private val scrappedTrackRefreshTrigger: ScrappedTrackRefreshTrigger, + private val registeredTrackRefreshTrigger: RegisteredTrackRefreshTrigger, + ) : BaseViewModel( + MyPageContract.MyPageState(), + ) { + init { + initializeUserInfo() + } + + val registeredTracks: Flow> = + registeredTrackRefreshTrigger.refreshEvent + .onStart { emit(Unit) } + .flatMapLatest { + getMyRegisteredTracksUseCase( + onTotalCountFetched = { + updateState { + copy(registeredMusicCount = it) + } + }, + ) + }.map { pagingData -> + pagingData.map { registeredTrack -> + registeredTrack.toUiState() + } + }.cachedIn(viewModelScope) + .combine(uiState) { pagingData, state -> + pagingData.filter { !state.deletedTrackIds.contains(it.postId) } + } + + val scrappedTracks: Flow> = + scrappedTrackRefreshTrigger.refreshEvent + .onStart { emit(Unit) } + .flatMapLatest { + getMyScrappedTracksUseCase() + }.map { pagingData -> + pagingData.map { scrappedTrack -> + scrappedTrack.toUiState() + } + }.cachedIn(viewModelScope) + + override fun handleIntent(intent: MyPageContract.MyPageIntent) { + when (intent) { + MyPageContract.MyPageIntent.OnBottomSheetCancelClick -> { + updateState { + copy( + isDeleteBottomSheetVisible = false, + ) + } + setSideEffect(MyPageContract.MyPageSideEffect.ShowBottomNavigation) + } + + MyPageContract.MyPageIntent.OnBottomSheetDeleteClick -> { + setSideEffect(MyPageContract.MyPageSideEffect.ShowBottomNavigation) + updateState { + copy( + isDeleteBottomSheetVisible = false, + ) + } + setSideEffect(MyPageContract.MyPageSideEffect.ShowDeleteDialogue) + } + + is MyPageContract.MyPageIntent.OnRegisteredTrackClick -> { + setSideEffect(MyPageContract.MyPageSideEffect.NavigateToDetail(intent.postId)) + } + + MyPageContract.MyPageIntent.OnDialogDeleteClick -> { + viewModelScope.launch { + val selectedPostId = currentState.selectedPostId + postRepository + .deletePost(selectedPostId) + .onSuccess { + updateState { + copy( + deletedTrackIds = deletedTrackIds.add(selectedPostId), + registeredMusicCount = currentState.registeredMusicCount - 1, + ) + } + }.onFailure { + Timber.d(t = it, message = "deletePost 실패") + } + } + } + + is MyPageContract.MyPageIntent.OnKebabIconClick -> { + setSideEffect(MyPageContract.MyPageSideEffect.HideBottomNavigation) + updateState { + copy( + isDeleteBottomSheetVisible = true, + selectedPostId = intent.musicId, + ) + } + } + + is MyPageContract.MyPageIntent.OnScrappedTrackClick -> { + setSideEffect(MyPageContract.MyPageSideEffect.NavigateToDetail(intent.postId)) + } + + MyPageContract.MyPageIntent.OnProfileClick -> { + setSideEffect(MyPageContract.MyPageSideEffect.NavigateToEditProfile) + } + + MyPageContract.MyPageIntent.OnSettingIconClick -> { + setSideEffect(MyPageContract.MyPageSideEffect.NavigateToSettings) + } + + is MyPageContract.MyPageIntent.OnTabClick -> { + updateState { + copy(selectedTabIndex = intent.tabIndex) + } + } + } + } + + private fun initializeUserInfo() { + userRepository + .getUser() + .onEach { user -> + updateState { + copy( + userNickname = user?.nickname ?: "", + profileImagePath = user?.profileImagePath, + ) + } + Timber.d("user: $user") + }.launchIn(viewModelScope) + } + } diff --git a/feature/mypage/src/main/res/values/strings.xml b/feature/mypage/src/main/res/values/strings.xml new file mode 100644 index 00000000..d0094695 --- /dev/null +++ b/feature/mypage/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + 마이페이지 + 총 %1$s개의 노래를 공유했어요 + 등록한 곡 + 보관함 + \ No newline at end of file diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts new file mode 100644 index 00000000..42f4c78f --- /dev/null +++ b/feature/onboarding/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.onboarding" +} + +dependencies { + implementation(libs.coil.compose) +} diff --git a/feature/onboarding/src/main/java/com/example/onboarding/OnboardingContract.kt b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingContract.kt new file mode 100644 index 00000000..77745ff4 --- /dev/null +++ b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingContract.kt @@ -0,0 +1,96 @@ +package com.example.onboarding + +import android.net.Uri +import com.example.common.type.TermType +import com.example.designsystem.component.textfield.type.InputState +import com.example.ui.base.BaseContract + +class OnboardingContract { + data class OnboardingState( + val kakaoAccessToken: String? = null, + val agreedTerms: Set = emptySet(), + val nickname: String = "", + val profileImagePath: String? = null, + val nicknameInputState: InputState = InputState.Default, + val isAlbumLauncherBottomSheetVisible: Boolean = false, + ) : BaseContract.State { + val isTermsScreenNextButtonEnabled: Boolean + get() = agreedTerms.containsAll(TermType.mandatoryTerms) + + val isAllTermsAgreed: Boolean + get() = agreedTerms.size == TermType.entries.size + + val isProfileScreenNextButtonEnabled: Boolean + get() = nicknameInputState is InputState.Success + } + + sealed interface OnboardingIntent : BaseContract.Intent { + data class Initialize( + val kakaoAccessToken: String, + ) : OnboardingIntent + + data object OnBackButtonClick : OnboardingIntent + + data class OnToggleTerm( + val term: TermType, + ) : OnboardingIntent + + data object OnToggleAllTerms : OnboardingIntent + + data object OnTermsScreenNextButtonClick : OnboardingIntent + + data class OnTermsArrowClick( + val term: TermType, + ) : OnboardingIntent + + data class OnNicknameChanged( + val input: String, + ) : OnboardingIntent + + data object OnProfileImageClick : OnboardingIntent + + data object OnAlbumLauncherBottomSheetDismiss : OnboardingIntent + + data object OnDefaultImageSelect : OnboardingIntent + + data object OnAlbumLauncherSelect : OnboardingIntent + + data class OnAlbumImageSelect( + val uri: Uri?, + ) : OnboardingIntent + + data object OnProfileScreenNextButtonClick : OnboardingIntent + + data object OnStartButtonClick : OnboardingIntent + + data object OnBackGestureAfterSignup : OnboardingIntent + + data object OnPermissionConfirmButtonClick : OnboardingIntent + + data class OnNotificationPermissionResult( + val isGranted: Boolean, + ) : OnboardingIntent + } + + sealed interface OnboardingSideEffect : BaseContract.SideEffect { + data object NavigateToBack : OnboardingSideEffect + + data object NavigateToProfile : OnboardingSideEffect + + data object NavigateToOnboarding : OnboardingSideEffect + + data object NavigateToPermission : OnboardingSideEffect + + data object NavigateToHome : OnboardingSideEffect + + data object NavigateToLogin : OnboardingSideEffect + + data object ShowPermissionDialog : OnboardingSideEffect + + data object LaunchAlbum : OnboardingSideEffect + + data class OpenWebView( + val url: String, + ) : OnboardingSideEffect + } +} diff --git a/feature/onboarding/src/main/java/com/example/onboarding/OnboardingNavDisplay.kt b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingNavDisplay.kt new file mode 100644 index 00000000..9e4e168e --- /dev/null +++ b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingNavDisplay.kt @@ -0,0 +1,82 @@ +package com.example.onboarding + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.example.navigation.Navigator +import com.example.navigation.OnboardingGraph + +@Composable +fun OnboardingNavDisplay( + kakaoAccessToken: String, + globalNavigator: Navigator, + viewModel: OnboardingViewModel = hiltViewModel(), +) { + val onboardingNavigator = remember { Navigator(OnboardingGraph.Terms) } + + LaunchedEffect(Unit) { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.Initialize(kakaoAccessToken)) + } + + NavDisplay( + backStack = onboardingNavigator.backStack, + onBack = { + if (onboardingNavigator.backStack.size > 1) { + onboardingNavigator.navigateToBack() + } else { + globalNavigator.navigateToBack() + } + }, + transitionSpec = { + ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None, + ) + }, + popTransitionSpec = { + ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None, + ) + }, + entryProvider = + entryProvider { + // 1. 약관 동의 화면 + entry { + OnboardingTermsRoute( + onboardingNavigator = onboardingNavigator, + globalNavigator = globalNavigator, + ) + } + + // 2. 프로필 등록 화면 + entry { + OnboardingProfileRoute( + onboardingNavigator = onboardingNavigator, + ) + } + + // 3. 튜토리얼 화면 (Pager 포함) + entry { + OnboardingRoute( + onboardingNavigator = onboardingNavigator, + globalNavigator = globalNavigator, + ) + } + + // 4. 권한 동의 화면 + entry { + OnboardingPermissionRoute( + onboardingNavigator = onboardingNavigator, + globalNavigator = globalNavigator, + ) + } + }, + ) +} diff --git a/feature/onboarding/src/main/java/com/example/onboarding/OnboardingNavigationModule.kt b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingNavigationModule.kt new file mode 100644 index 00000000..c3b0e68c --- /dev/null +++ b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingNavigationModule.kt @@ -0,0 +1,29 @@ +package com.example.onboarding + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.Navigator +import com.example.navigation.OnboardingGraph +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object OnboardingNavigationModule { + @Provides + @IntoSet + fun provideOnboardingEntries( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { key -> + OnboardingNavDisplay( + kakaoAccessToken = key.kakaoAccessToken, + globalNavigator = navigator, + ) + } + } +} diff --git a/feature/onboarding/src/main/java/com/example/onboarding/OnboardingPermissionScreen.kt b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingPermissionScreen.kt new file mode 100644 index 00000000..4037f679 --- /dev/null +++ b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingPermissionScreen.kt @@ -0,0 +1,179 @@ +package com.example.onboarding + +import android.Manifest +import android.os.Build +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.foundation.shape.RoundedCornerShape +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.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.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.dplay.designsystem.R +import com.example.designsystem.component.DplayBaseIcon +import com.example.designsystem.component.button.DPlayLargePinkButton +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable +import com.example.navigation.Home +import com.example.navigation.Navigator +import com.example.ui.handler.rememberPermissionHandler +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun OnboardingPermissionRoute( + onboardingNavigator: Navigator, + globalNavigator: Navigator, + modifier: Modifier = Modifier, + viewModel: OnboardingViewModel = hiltViewModel(), +) { + val permissionHandler = + rememberPermissionHandler { isGranted -> + viewModel.handleIntent( + OnboardingContract.OnboardingIntent.OnNotificationPermissionResult(isGranted), + ) + } + + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + OnboardingContract.OnboardingSideEffect.ShowPermissionDialog -> { + permissionHandler.requestIf( + permission = Manifest.permission.POST_NOTIFICATIONS, + condition = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU, + ) + } + + OnboardingContract.OnboardingSideEffect.NavigateToBack -> { + onboardingNavigator.navigateToBack() + } + + OnboardingContract.OnboardingSideEffect.NavigateToHome -> { + globalNavigator.clearAndNavigateTo(Home) + } + + else -> {} + } + } + } + OnboardingPermissionScreen( + onPermissionConfirmButtonClick = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnPermissionConfirmButtonClick) + }, + ) +} + +@Composable +fun OnboardingPermissionScreen( + modifier: Modifier = Modifier, + onPermissionConfirmButtonClick: () -> Unit = {}, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayWhite) + .padding(horizontal = 16.dp) + .padding(top = 110.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.img_wordmark_pink), + contentDescription = null, + modifier = Modifier.size(width = 100.dp, height = 30.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(com.dplay.onboarding.R.string.permission_screen_title), + style = DPlayTheme.typography.titleBold18, + color = DPlayTheme.colors.dplayBlack, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + PermissionBox() + + Spacer(modifier = Modifier.weight(1f)) + + DPlayLargePinkButton( + onClick = { onPermissionConfirmButtonClick() }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.confirm_button_label), + ) + } +} + +@Composable +private fun PermissionBox( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .noRippleClickable( + onClick = onClick, + ).border( + width = 1.dp, + color = DPlayTheme.colors.gray200, + shape = RoundedCornerShape(16.dp), + ).padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .background( + color = DPlayTheme.colors.gray100, + shape = CircleShape, + ).padding(10.dp), + ) { + DplayBaseIcon( + iconRes = R.drawable.ic_alert_24, + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = stringResource(com.dplay.onboarding.R.string.permission_box_title), + style = DPlayTheme.typography.bodyBold14, + ) + + Text( + text = stringResource(com.dplay.onboarding.R.string.permission_box_sub_text), + style = DPlayTheme.typography.bodyMed14, + color = DPlayTheme.colors.gray400, + ) + } + } +} + +@Preview +@Composable +private fun OnboardingPermissionScreenPreview() { + OnboardingPermissionScreen() +} diff --git a/feature/onboarding/src/main/java/com/example/onboarding/OnboardingProfileScreen.kt b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingProfileScreen.kt new file mode 100644 index 00000000..c3565f9a --- /dev/null +++ b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingProfileScreen.kt @@ -0,0 +1,233 @@ +package com.example.onboarding + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dplay.designsystem.R +import com.example.designsystem.component.DPlayButtonBottomSheet +import com.example.designsystem.component.DPlayProfileImageArea +import com.example.designsystem.component.DplayLeftIconTopAppBar +import com.example.designsystem.component.button.DPlayCircleButton +import com.example.designsystem.component.button.DPlayLargePinkButton +import com.example.designsystem.component.button.type.CircleButtonType +import com.example.designsystem.component.textfield.DPlayTextInput +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.TextFieldConstant +import com.example.designsystem.util.noRippleClickable +import com.example.navigation.Navigator +import com.example.navigation.OnboardingGraph +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun OnboardingProfileRoute( + onboardingNavigator: Navigator, + modifier: Modifier = Modifier, + viewModel: OnboardingViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + val photoPickerLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> + uri?.let { + viewModel.handleIntent( + OnboardingContract.OnboardingIntent.OnAlbumImageSelect(uri), + ) + } + }, + ) + + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + OnboardingContract.OnboardingSideEffect.NavigateToBack -> { + onboardingNavigator.navigateToBack() + } + OnboardingContract.OnboardingSideEffect.NavigateToOnboarding -> { + onboardingNavigator.navigateTo(OnboardingGraph.Onboarding) + } + OnboardingContract.OnboardingSideEffect.LaunchAlbum -> { + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + } + else -> {} + } + } + } + + OnboardingProfileScreen( + state = state, + onNicknameChanged = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnNicknameChanged(it)) + }, + onNextButtonClick = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnProfileScreenNextButtonClick) + }, + onProfileImageClick = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnProfileImageClick) + }, + onAlbumLauncherBottomSheetDismiss = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnAlbumLauncherBottomSheetDismiss) + }, + onDefaultImageSelect = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnDefaultImageSelect) + }, + onBackButtonClick = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnBackButtonClick) + }, + onAlbumLauncherSelect = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnAlbumLauncherSelect) + }, + ) +} + +@Composable +fun OnboardingProfileScreen( + state: OnboardingContract.OnboardingState, + modifier: Modifier = Modifier, + onNicknameChanged: (String) -> Unit = {}, + onProfileImageClick: () -> Unit = {}, + onAlbumLauncherBottomSheetDismiss: () -> Unit = {}, + onDefaultImageSelect: () -> Unit = {}, + onNextButtonClick: () -> Unit = {}, + onBackButtonClick: () -> Unit = {}, + onAlbumLauncherSelect: () -> Unit = {}, +) { + BackHandler(enabled = state.isAlbumLauncherBottomSheetVisible, onBack = onAlbumLauncherBottomSheetDismiss) + + Box( + modifier = + Modifier + .fillMaxSize(), + ) { + Column( + modifier = + modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayWhite) + .padding(bottom = 16.dp), + ) { + DplayLeftIconTopAppBar { onBackButtonClick() } + + Spacer(Modifier.height(20.dp)) + + Text( + text = stringResource(com.dplay.onboarding.R.string.profile_screen_title), + modifier = Modifier.padding(start = 16.dp), + style = DPlayTheme.typography.titleBold24, + color = DPlayTheme.colors.dplayBlack, + ) + + Spacer(modifier = Modifier.height(28.dp)) + + DPlayProfileImageArea( + onProfileImageClick = onProfileImageClick, + profileImagePath = state.profileImagePath, + modifier = + Modifier + .size(116.dp) + .align(Alignment.CenterHorizontally), + ) { + DPlayCircleButton( + circleButtonType = + CircleButtonType.SmallPlus( + R.string.add_profile_image_button_icon_description, + ), + onClick = { onProfileImageClick() }, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + DPlayTextInput( + value = state.nickname, + inputState = state.nicknameInputState, + onValueChange = { onNicknameChanged(it) }, + onFocusChange = {}, + placeholder = stringResource(R.string.placeholder_nickname), + maxLength = TextFieldConstant.MAX_NICKNAME_LENGTH, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + DPlayLargePinkButton( + onClick = { onNextButtonClick() }, + label = stringResource(R.string.enroll_button_label), + modifier = + Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + enabled = state.isProfileScreenNextButtonEnabled, + ) + } + + // scrim 효과 + if (state.isAlbumLauncherBottomSheetVisible) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dim40) + .noRippleClickable { onAlbumLauncherBottomSheetDismiss() }, + ) + } + + AnimatedVisibility( + visible = state.isAlbumLauncherBottomSheetVisible, + modifier = Modifier.align(Alignment.BottomCenter), + enter = + slideInVertically( + initialOffsetY = { it }, + ), + exit = + slideOutVertically( + targetOffsetY = { it }, + ), + ) { + DPlayButtonBottomSheet( + mainText = stringResource(R.string.launch_album_bottomsheet_main_text), + subText = stringResource(R.string.launch_album_bottomsheet_sub_text), + mainOnClick = { onAlbumLauncherSelect() }, + subOnClick = { onDefaultImageSelect() }, + modifier = Modifier.noRippleClickable(), + ) + } + } +} + +@Preview +@Composable +private fun OnboardingProfileScreenPreview() { + DPlayTheme { + OnboardingProfileScreen( + state = OnboardingContract.OnboardingState(), + ) + } +} diff --git a/feature/onboarding/src/main/java/com/example/onboarding/OnboardingScreen.kt b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingScreen.kt new file mode 100644 index 00000000..86294c58 --- /dev/null +++ b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingScreen.kt @@ -0,0 +1,264 @@ +package com.example.onboarding + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +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.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.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.dplay.designsystem.R +import com.dplay.onboarding.R.drawable +import com.example.designsystem.component.DplayTopAppBar +import com.example.designsystem.component.button.DPlayLargePinkButton +import com.example.designsystem.theme.DPlayTheme +import com.example.navigation.Login +import com.example.navigation.Navigator +import com.example.navigation.OnboardingGraph +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun OnboardingRoute( + globalNavigator: Navigator, + onboardingNavigator: Navigator, + modifier: Modifier = Modifier, + viewModel: OnboardingViewModel = hiltViewModel(), +) { + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + OnboardingContract.OnboardingSideEffect.NavigateToBack -> { + onboardingNavigator.navigateToBack() + } + OnboardingContract.OnboardingSideEffect.NavigateToPermission -> { + onboardingNavigator.navigateTo(OnboardingGraph.Permission) + } + OnboardingContract.OnboardingSideEffect.NavigateToLogin -> { + globalNavigator.clearAndNavigateTo(Login) + } + else -> {} + } + } + } + + OnboardingScreen( + onStartButtonClick = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnStartButtonClick) + }, + onBackGesture = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnBackGestureAfterSignup) + }, + ) +} + +@Composable +fun OnboardingScreen( + modifier: Modifier = Modifier, + onStartButtonClick: () -> Unit = {}, + onBackGesture: () -> Unit = {}, +) { + BackHandler( + enabled = true, + onBack = onBackGesture, + ) + + val pagerState = rememberPagerState(pageCount = { 3 }) + + Column( + modifier = + modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayWhite) + .padding(bottom = 16.dp), + ) { + DplayTopAppBar { } + + Spacer(modifier = Modifier.height(40.dp)) + + HorizontalPager( + state = pagerState, + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + verticalAlignment = Alignment.Top, + ) { page -> + when (page) { + 0 -> FirstOnboardingPage() + 1 -> SecondOnboardingPage() + 2 -> ThirdOnboardingPage() + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + DotIndicator( + currentPage = pagerState.currentPage, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + Spacer(modifier = Modifier.height(54.dp)) + + DPlayLargePinkButton( + modifier = + Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + onClick = { onStartButtonClick() }, + label = stringResource(R.string.start_button_label), + ) + } +} + +@Composable +private fun FirstOnboardingPage( + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(com.dplay.onboarding.R.string.first_onboarding_page_main_text), + style = DPlayTheme.typography.titleBold24, + color = DPlayTheme.colors.dplayBlack, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(com.dplay.onboarding.R.string.first_onboarding_page_sub_text), + style = DPlayTheme.typography.bodyMed16, + color = DPlayTheme.colors.gray400, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Image( + painter = painterResource(id = drawable.img_onboarding_1), + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun SecondOnboardingPage(modifier: Modifier = Modifier) { + Column( + modifier = + modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(com.dplay.onboarding.R.string.second_onboarding_page_main_text), + style = DPlayTheme.typography.titleBold24, + color = DPlayTheme.colors.dplayBlack, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(com.dplay.onboarding.R.string.second_onboarding_page_sub_text), + style = DPlayTheme.typography.bodyMed16, + color = DPlayTheme.colors.gray400, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Image( + painter = painterResource(id = drawable.img_onboarding_2), + contentDescription = null, + ) + } +} + +@Composable +private fun ThirdOnboardingPage(modifier: Modifier = Modifier) { + Column( + modifier = + modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(com.dplay.onboarding.R.string.third_onboarding_page_main_text), + style = DPlayTheme.typography.titleBold24, + color = DPlayTheme.colors.dplayBlack, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(com.dplay.onboarding.R.string.third_onboarding_page_sub_text), + style = DPlayTheme.typography.bodyMed16, + color = DPlayTheme.colors.gray400, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Image( + painter = painterResource(id = drawable.img_onboarding_3), + contentDescription = null, + ) + } +} + +@Composable +private fun DotIndicator( + currentPage: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + repeat(3) { iteration -> + val color = if (currentPage == iteration) DPlayTheme.colors.dplayBlack else DPlayTheme.colors.gray200 + + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(color), + ) + } + } +} + +@Preview +@Composable +private fun OnboardingScreenPreview() { + DPlayTheme { + OnboardingScreen() + } +} diff --git a/feature/onboarding/src/main/java/com/example/onboarding/OnboardingTermsScreen.kt b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingTermsScreen.kt new file mode 100644 index 00000000..5e8f0109 --- /dev/null +++ b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingTermsScreen.kt @@ -0,0 +1,155 @@ +package com.example.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dplay.designsystem.R +import com.example.common.type.TermType +import com.example.designsystem.component.DPlayCheckArrow +import com.example.designsystem.component.DPlayCheckBox +import com.example.designsystem.component.DplayLeftIconTopAppBar +import com.example.designsystem.component.button.DPlayLargePinkButton +import com.example.designsystem.theme.DPlayTheme +import com.example.navigation.Navigator +import com.example.navigation.OnboardingGraph +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun OnboardingTermsRoute( + onboardingNavigator: Navigator, + globalNavigator: Navigator, + modifier: Modifier = Modifier, + viewModel: OnboardingViewModel = hiltViewModel(), +) { + val uriHandler = LocalUriHandler.current + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + OnboardingContract.OnboardingSideEffect.NavigateToBack -> { + globalNavigator.navigateToBack() + } + OnboardingContract.OnboardingSideEffect.NavigateToProfile -> { + onboardingNavigator.navigateTo(OnboardingGraph.Profile) + } + is OnboardingContract.OnboardingSideEffect.OpenWebView -> { + uriHandler.openUri(sideEffect.url) + } + else -> {} + } + } + } + + OnboardingTermsScreen( + state = state, + onToggleTerm = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnToggleTerm(it)) + }, + onToggleAllTerms = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnToggleAllTerms) + }, + onNextButtonClick = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnTermsScreenNextButtonClick) + }, + onBackButtonClick = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnBackButtonClick) + }, + onTermsArrowClick = { + viewModel.handleIntent(OnboardingContract.OnboardingIntent.OnTermsArrowClick(it)) + }, + ) +} + +@Composable +fun OnboardingTermsScreen( + state: OnboardingContract.OnboardingState, + modifier: Modifier = Modifier, + onToggleTerm: (TermType) -> Unit = {}, + onTermsArrowClick: (TermType) -> Unit = {}, + onToggleAllTerms: () -> Unit = {}, + onNextButtonClick: () -> Unit = {}, + onBackButtonClick: () -> Unit = {}, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayWhite), + ) { + DplayLeftIconTopAppBar { onBackButtonClick() } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .padding(top = 20.dp, bottom = 16.dp), + ) { + Text( + text = stringResource(com.dplay.onboarding.R.string.terms_screen_title), + style = DPlayTheme.typography.titleBold24, + color = DPlayTheme.colors.dplayBlack, + ) + + Spacer(modifier = Modifier.height(40.dp)) + + DPlayCheckBox( + text = stringResource(R.string.agree_all_terms), + isChecked = state.isAllTermsAgreed, + onClick = onToggleAllTerms, + modifier = Modifier.fillMaxWidth(), + ) + + DPlayCheckArrow( + text = stringResource(id = R.string.terms_service_required), + isChecked = state.agreedTerms.contains(TermType.TERMS_OF_SERVICE), + onArrowClick = { onTermsArrowClick(TermType.TERMS_OF_SERVICE) }, + onCheckBoxClick = { onToggleTerm(TermType.TERMS_OF_SERVICE) }, + modifier = Modifier.fillMaxWidth(), + ) + + DPlayCheckArrow( + text = stringResource(id = R.string.privacy_policy_required), + isChecked = state.agreedTerms.contains(TermType.PRIVACY_POLICY), + onArrowClick = { onTermsArrowClick(TermType.PRIVACY_POLICY) }, + onCheckBoxClick = { onToggleTerm(TermType.PRIVACY_POLICY) }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.weight(1f)) + + DPlayLargePinkButton( + onClick = onNextButtonClick, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.next_button_label), + enabled = state.isTermsScreenNextButtonEnabled, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun OnboardingTermsScreenPreview() { + DPlayTheme { + OnboardingTermsScreen( + state = OnboardingContract.OnboardingState(), + ) + } +} diff --git a/feature/onboarding/src/main/java/com/example/onboarding/OnboardingViewModel.kt b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingViewModel.kt new file mode 100644 index 00000000..fc093640 --- /dev/null +++ b/feature/onboarding/src/main/java/com/example/onboarding/OnboardingViewModel.kt @@ -0,0 +1,175 @@ +package com.example.onboarding + +import androidx.lifecycle.viewModelScope +import com.example.common.type.TermType +import com.example.domain.model.NicknameValidationResult +import com.example.domain.repository.AuthRepository +import com.example.domain.repository.UserRepository +import com.example.domain.usecase.ValidateNicknameUseCase +import com.example.ui.base.BaseViewModel +import com.example.ui.mapper.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OnboardingViewModel + @Inject + constructor( + private val validateNicknameUseCase: ValidateNicknameUseCase, + private val authRepository: AuthRepository, + private val userRepository: UserRepository, + ) : BaseViewModel( + OnboardingContract.OnboardingState(), + ) { + override fun handleIntent(intent: OnboardingContract.OnboardingIntent) { + when (intent) { + is OnboardingContract.OnboardingIntent.Initialize -> { + updateState { + copy( + kakaoAccessToken = intent.kakaoAccessToken, + ) + } + } + + OnboardingContract.OnboardingIntent.OnBackButtonClick -> { + setSideEffect(OnboardingContract.OnboardingSideEffect.NavigateToBack) + } + + is OnboardingContract.OnboardingIntent.OnToggleTerm -> { + toggleEachTerm(intent.term) + } + + OnboardingContract.OnboardingIntent.OnToggleAllTerms -> { + toggleAllTerms() + } + + is OnboardingContract.OnboardingIntent.OnTermsArrowClick -> { + setSideEffect(OnboardingContract.OnboardingSideEffect.OpenWebView(intent.term.url)) + } + + OnboardingContract.OnboardingIntent.OnTermsScreenNextButtonClick -> { + setSideEffect(OnboardingContract.OnboardingSideEffect.NavigateToProfile) + } + + is OnboardingContract.OnboardingIntent.OnNicknameChanged -> { + validateAndUpdateNickname(intent.input.trim()) + } + + OnboardingContract.OnboardingIntent.OnProfileScreenNextButtonClick -> { + signUpWithKakao() + } + + is OnboardingContract.OnboardingIntent.OnAlbumImageSelect -> { + updateState { + copy( + profileImagePath = intent.uri.toString(), + isAlbumLauncherBottomSheetVisible = false, + ) + } + } + OnboardingContract.OnboardingIntent.OnAlbumLauncherBottomSheetDismiss -> { + updateState { + copy(isAlbumLauncherBottomSheetVisible = false) + } + } + OnboardingContract.OnboardingIntent.OnDefaultImageSelect -> { + updateState { + copy( + profileImagePath = null, + isAlbumLauncherBottomSheetVisible = false, + ) + } + } + OnboardingContract.OnboardingIntent.OnProfileImageClick -> { + updateState { + copy(isAlbumLauncherBottomSheetVisible = true) + } + } + + OnboardingContract.OnboardingIntent.OnAlbumLauncherSelect -> { + setSideEffect(OnboardingContract.OnboardingSideEffect.LaunchAlbum) + } + + OnboardingContract.OnboardingIntent.OnBackGestureAfterSignup -> { + setSideEffect(OnboardingContract.OnboardingSideEffect.NavigateToLogin) + } + + OnboardingContract.OnboardingIntent.OnStartButtonClick -> { + setSideEffect(OnboardingContract.OnboardingSideEffect.NavigateToPermission) + } + + OnboardingContract.OnboardingIntent.OnPermissionConfirmButtonClick -> { + setSideEffect(OnboardingContract.OnboardingSideEffect.ShowPermissionDialog) + } + + is OnboardingContract.OnboardingIntent.OnNotificationPermissionResult -> { + viewModelScope.launch { + userRepository + .updateNotificationEnabled(intent.isGranted) + .onSuccess { + setSideEffect(OnboardingContract.OnboardingSideEffect.NavigateToHome) + }.onFailure { + } + } + } + } + } + + private fun signUpWithKakao() { + viewModelScope.launch { + authRepository + .signupWithKakao( + kakaoAccessToken = currentState.kakaoAccessToken, + profileImage = currentState.profileImagePath, + nickname = currentState.nickname, + ).onSuccess { validationResult -> + if (validationResult is NicknameValidationResult.Error.Duplicated) { + updateState { + copy( + nicknameInputState = NicknameValidationResult.Error.Duplicated.toUiState(), + ) + } + } else if (validationResult is NicknameValidationResult.Success) { + setSideEffect(OnboardingContract.OnboardingSideEffect.NavigateToOnboarding) + } + }.onFailure { + } + } + } + + private fun toggleAllTerms() { + updateState { + val newAgreedTerms = + if (currentState.isAllTermsAgreed) { + emptySet() + } else { + TermType.entries.toSet() + } + copy(agreedTerms = newAgreedTerms) + } + } + + private fun toggleEachTerm(term: TermType) { + updateState { + val newAgreedTerms = + if (agreedTerms.contains(term)) { + agreedTerms - term + } else { + agreedTerms + term + } + copy(agreedTerms = newAgreedTerms) + } + } + + private fun validateAndUpdateNickname(nickname: String) { + val validationResult = validateNicknameUseCase(nickname) + val inputState = validationResult.toUiState() + updateState { + copy( + nickname = nickname, + nicknameInputState = inputState, + ) + } + } + } diff --git a/feature/onboarding/src/main/res/drawable/img_onboarding_1.png b/feature/onboarding/src/main/res/drawable/img_onboarding_1.png new file mode 100644 index 00000000..e85f8098 Binary files /dev/null and b/feature/onboarding/src/main/res/drawable/img_onboarding_1.png differ diff --git a/feature/onboarding/src/main/res/drawable/img_onboarding_2.png b/feature/onboarding/src/main/res/drawable/img_onboarding_2.png new file mode 100644 index 00000000..94d92ba4 Binary files /dev/null and b/feature/onboarding/src/main/res/drawable/img_onboarding_2.png differ diff --git a/feature/onboarding/src/main/res/drawable/img_onboarding_3.png b/feature/onboarding/src/main/res/drawable/img_onboarding_3.png new file mode 100644 index 00000000..a260daa0 Binary files /dev/null and b/feature/onboarding/src/main/res/drawable/img_onboarding_3.png differ diff --git a/feature/onboarding/src/main/res/values/strings.xml b/feature/onboarding/src/main/res/values/strings.xml new file mode 100644 index 00000000..c5ca1e05 --- /dev/null +++ b/feature/onboarding/src/main/res/values/strings.xml @@ -0,0 +1,17 @@ + + + + 알림(선택) + 알림 메세지 제공 + 앱 사용을 위해\n접근 권한을 허용해주세요 + 오늘의 질문이 도착했어요 + 모두가 같은 질문을 받고,\n그 순간에 어울리는 노래를 추천해요 + 다른 사람들의 추천을 만나보세요 + 최대 3곡까지 먼저 보고,\n노래를 추천하면 더 많은 추천을 볼 수 있어요 + 노래 추천 받으러 가볼까요? + 마음에 드는 곡에 반응을 남기고,\n보관함에 추가할 수 있어요 + 디플레이에서 사용할\n프로필을 완성해주세요 + 앨범에서 선택하기 + 기본 이미지로 변경하기 + 디플레이 이용을 위해\n약관에 동의해주세요 + \ No newline at end of file diff --git a/feature/otherprofile/build.gradle.kts b/feature/otherprofile/build.gradle.kts new file mode 100644 index 00000000..d2bf0d46 --- /dev/null +++ b/feature/otherprofile/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.otherprofile" +} diff --git a/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileContract.kt b/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileContract.kt new file mode 100644 index 00000000..902dd001 --- /dev/null +++ b/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileContract.kt @@ -0,0 +1,38 @@ +package com.example.otherprofile + +import com.example.domain.model.LoadingState +import com.example.ui.base.BaseContract + +class OtherProfileContract { + data class OtherProfileState( + val loadingState: LoadingState = LoadingState.LOADING, + val userNickname: String = "디플레이", + val registeredMusicCount: Int = 0, + val profileImagePath: String? = null, + val selectedTabIndex: Int = 0, + ) : BaseContract.State + + sealed interface OtherProfileIntent : BaseContract.Intent { + data object OnBackIconClick : OtherProfileIntent + + data class OnTabClick( + val tabIndex: Int, + ) : OtherProfileIntent + + data class OnScrappedTrackClick( + val postId: Long, + ) : OtherProfileIntent + + data class OnRegisteredTrackClick( + val postId: Long, + ) : OtherProfileIntent + } + + sealed interface OtherProfileSideEffect : BaseContract.SideEffect { + data class NavigateToDetail( + val postId: Long, + ) : OtherProfileSideEffect + + data object NavigateToBack : OtherProfileSideEffect + } +} diff --git a/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileNavigationModule.kt b/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileNavigationModule.kt new file mode 100644 index 00000000..20f6c7e5 --- /dev/null +++ b/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileNavigationModule.kt @@ -0,0 +1,29 @@ +package com.example.otherprofile + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.Navigator +import com.example.navigation.OtherProfile +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object OtherProfileNavigationModule { + @Provides + @IntoSet + fun provideOtherProfileEntry( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { myPage -> + OtherProfileRoute( + navigator = navigator, + userId = myPage.userId, + ) + } + } +} diff --git a/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileScreen.kt b/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileScreen.kt new file mode 100644 index 00000000..a41d0f2f --- /dev/null +++ b/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileScreen.kt @@ -0,0 +1,468 @@ +package com.example.otherprofile + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +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.platform.LocalDensity +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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.example.designsystem.component.DPlayErrorScreen +import com.example.designsystem.component.DPlayLoadingScreen +import com.example.designsystem.component.DPlayMusicGridItem +import com.example.designsystem.component.DPlayMusicListItem +import com.example.designsystem.component.DPlayProfileImageArea +import com.example.designsystem.component.DplayLeftIconTopAppBar +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable +import com.example.domain.model.LoadingState +import com.example.navigation.Detail +import com.example.navigation.Navigator +import com.example.ui.emptyLazyPagingItems +import com.example.ui.model.RegisteredTrackState +import com.example.ui.model.ScrappedTrackState +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun OtherProfileRoute( + navigator: Navigator, + modifier: Modifier = Modifier, + userId: Long, + viewModel: OtherProfileViewModel = + hiltViewModel( + key = userId.toString(), + creationCallback = { factory: OtherProfileViewModel.Factory -> + factory.create(userId) + }, + ), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + val registeredTracks = viewModel.registeredTracks.collectAsLazyPagingItems() + val scrappedTracks = viewModel.scrappedTracks.collectAsLazyPagingItems() + + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + is OtherProfileContract.OtherProfileSideEffect.NavigateToDetail -> { + navigator.navigateTo(destination = Detail(postId = sideEffect.postId)) + } + + OtherProfileContract.OtherProfileSideEffect.NavigateToBack -> { + navigator.navigateToBack() + } + } + } + } + + when (state.loadingState) { + LoadingState.LOADING -> + DPlayLoadingScreen() + LoadingState.SUCCESS -> + OtherProfileScreen( + state = state, + registeredTrackList = registeredTracks, + scrappedTrackList = scrappedTracks, + modifier = modifier, + onBackIconClick = { + viewModel.handleIntent(OtherProfileContract.OtherProfileIntent.OnBackIconClick) + }, + onTabSelected = { + viewModel.handleIntent(OtherProfileContract.OtherProfileIntent.OnTabClick(it)) + }, + onScrappedTrackClick = { + viewModel.handleIntent(OtherProfileContract.OtherProfileIntent.OnScrappedTrackClick(it)) + }, + onRegisteredTrackClick = { + viewModel.handleIntent(OtherProfileContract.OtherProfileIntent.OnRegisteredTrackClick(it)) + }, + ) + LoadingState.FAILURE -> + DPlayErrorScreen( + onBackIconClick = { + viewModel.handleIntent(OtherProfileContract.OtherProfileIntent.OnBackIconClick) + }, + ) + } +} + +@Composable +fun OtherProfileScreen( + state: OtherProfileContract.OtherProfileState, + registeredTrackList: LazyPagingItems, + scrappedTrackList: LazyPagingItems, + modifier: Modifier = Modifier, + onBackIconClick: () -> Unit = {}, + onTabSelected: (Int) -> Unit = {}, + onScrappedTrackClick: (Long) -> Unit = {}, + onRegisteredTrackClick: (Long) -> Unit = {}, +) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = + modifier + .fillMaxSize() + .background(DPlayTheme.colors.dplayWhite), + ) { + DplayLeftIconTopAppBar { + onBackIconClick() + } + + Spacer(modifier = Modifier.height(12.dp)) + + UserInformationRow( + nickname = state.userNickname, + registeredMusicCount = state.registeredMusicCount, + profileImagePath = state.profileImagePath, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + TabContent( + selectedTabIndex = state.selectedTabIndex, + onTabSelected = onTabSelected, + registeredTrackList = registeredTrackList, + scrappedTrackList = scrappedTrackList, + onScrappedTrackClick = onScrappedTrackClick, + onRegisteredTrackClick = onRegisteredTrackClick, + ) + } + } +} + +@Composable +private fun UserInformationRow( + nickname: String, + registeredMusicCount: Int, + profileImagePath: String?, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.padding(vertical = 12.5.dp), + ) { + Text( + text = nickname, + style = DPlayTheme.typography.titleBold24, + color = DPlayTheme.colors.dplayBlack, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = + buildAnnotatedString { + val rawText = stringResource(id = com.dplay.otherprofile.R.string.registered_music_count) + val parts = rawText.split($$"""%1$s""") + + append(parts[0]) + withStyle(style = SpanStyle(color = DPlayTheme.colors.dplayPink)) { + append("$registeredMusicCount") + } + append(parts[1]) + }, + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.gray400, + ) + } + + DPlayProfileImageArea( + onProfileImageClick = { }, + profileImagePath = profileImagePath, + modifier = Modifier.size(80.dp), + ) + } +} + +@Composable +private fun TabContent( + selectedTabIndex: Int, + registeredTrackList: LazyPagingItems, + scrappedTrackList: LazyPagingItems, + onTabSelected: (Int) -> Unit, + onScrappedTrackClick: (Long) -> Unit = {}, + onRegisteredTrackClick: (Long) -> Unit = {}, +) { + Column { + OtherProfileTabRow( + selectedTabIndex = selectedTabIndex, + onTabSelected = onTabSelected, + ) + + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.gray100), + ) { + when (selectedTabIndex) { + 0 -> + RegisteredMusicList( + registeredTrackList = registeredTrackList, + onRegisteredTrackClick = onRegisteredTrackClick, + ) + 1 -> + BookmarkedMusicList( + scrappedTrackList = scrappedTrackList, + onScrappedTrackClick = onScrappedTrackClick, + ) + } + } + } +} + +@Composable +private fun OtherProfileTabRow( + selectedTabIndex: Int, + modifier: Modifier = Modifier, + onTabSelected: (Int) -> Unit = {}, +) { + val tabs = + listOf( + stringResource(com.dplay.otherprofile.R.string.registered_music_tab_label), + stringResource(com.dplay.otherprofile.R.string.bookmarked_music_tab_label), + ) + val indicatorHorizontalPadding = 28.dp + + val density = LocalDensity.current + val textWidths = + remember { + mutableStateListOf().apply { repeat(tabs.size) { add(0.dp) } } + } + + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val totalWidth = maxWidth + val tabWidth = totalWidth / tabs.size + + Column { + Row(modifier = Modifier.fillMaxWidth()) { + tabs.forEachIndexed { index, title -> + Box( + modifier = + Modifier + .weight(1f) + .noRippleClickable { + onTabSelected(index) + }.padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = title, + style = DPlayTheme.typography.bodyBold16, + color = if (selectedTabIndex == index) DPlayTheme.colors.dplayBlack else DPlayTheme.colors.gray300, + onTextLayout = { textLayoutResult -> + textWidths[index] = with(density) { textLayoutResult.size.width.toDp() } + }, + ) + } + } + } + + // 인디케이터 영역 + Box( + modifier = + Modifier + .fillMaxWidth() + .height(2.dp), + ) { + val currentTextWidth = textWidths[selectedTabIndex] + + val targetIndicatorWidth = currentTextWidth + (indicatorHorizontalPadding * 2) + val targetIndicatorOffset = (tabWidth * selectedTabIndex) + ((tabWidth - targetIndicatorWidth) / 2) + + val indicatorOffset by animateDpAsState(targetValue = targetIndicatorOffset, label = "offset") + val indicatorWidth by animateDpAsState(targetValue = targetIndicatorWidth, label = "width") + + // 인디케이터 + Box( + modifier = + Modifier + .offset(x = indicatorOffset) + .width(indicatorWidth) + .height(2.dp) + .background(color = DPlayTheme.colors.dplayBlack), + ) + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(color = DPlayTheme.colors.gray200) + .align(Alignment.BottomCenter), + ) + } + } + } +} + +@Composable +private fun RegisteredMusicList( + registeredTrackList: LazyPagingItems, + modifier: Modifier = Modifier, + onRegisteredTrackClick: (Long) -> Unit = {}, +) { + if (registeredTrackList.itemCount == 0) { + RegisteredMusicEmptyView() + } else { + Spacer(modifier = Modifier.height(12.dp)) + + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + count = registeredTrackList.itemCount, + key = registeredTrackList.itemKey { it.postId }, + ) { index -> + val registeredTrack = registeredTrackList[index] + + if (registeredTrack != null) { + DPlayMusicListItem( + musicImageUrl = registeredTrack.track.thumbnailUrl, + musicName = registeredTrack.track.musicTitle, + musicArtistName = registeredTrack.track.artistName, + musicContent = registeredTrack.comment, + onMoreClick = null, + onClick = { onRegisteredTrackClick(registeredTrack.postId) }, + ) + } + } + } + } +} + +@Composable +private fun BookmarkedMusicList( + scrappedTrackList: LazyPagingItems, + modifier: Modifier = Modifier, + onScrappedTrackClick: (Long) -> Unit = {}, +) { + if (scrappedTrackList.itemCount == 0) { + ScrappedMusicEmptyView() + } else { + LazyVerticalGrid( + modifier = modifier, + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + count = scrappedTrackList.itemCount, + key = scrappedTrackList.itemKey { it.postId }, + ) { index -> + val scrappedTrack = scrappedTrackList[index] + + if (scrappedTrack != null) { + DPlayMusicGridItem( + musicImageUrl = scrappedTrack.track.thumbnailUrl, + musicName = scrappedTrack.track.musicTitle, + musicArtistName = scrappedTrack.track.artistName, + onClick = { onScrappedTrackClick(scrappedTrack.postId) }, + ) + } else { + } + } + } + } +} + +@Composable +private fun RegisteredMusicEmptyView() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(184.dp)) + + Text( + text = "아직 등록한 곡이 없어요", + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.gray400, + ) + } +} + +@Composable +private fun ScrappedMusicEmptyView() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(184.dp)) + + Text( + text = "아직 저장한 곡이 없어요", + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.gray400, + ) + } +} + +@Preview +@Composable +private fun OtherProfileScreenPreview() { + var uiState by remember { + mutableStateOf( + OtherProfileContract.OtherProfileState( + userNickname = "디플레이", + ), + ) + } + + DPlayTheme { + OtherProfileScreen( + state = uiState, + registeredTrackList = emptyLazyPagingItems(), + scrappedTrackList = emptyLazyPagingItems(), + onTabSelected = { + uiState = uiState.copy(selectedTabIndex = it) + }, + ) + } +} diff --git a/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileViewModel.kt b/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileViewModel.kt new file mode 100644 index 00000000..47b23905 --- /dev/null +++ b/feature/otherprofile/src/main/java/com/example/otherprofile/OtherProfileViewModel.kt @@ -0,0 +1,109 @@ +package com.example.otherprofile + +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.example.domain.model.LoadingState +import com.example.domain.repository.UserRepository +import com.example.domain.usecase.GetRegisteredTracksUseCase +import com.example.domain.usecase.GetScrappedTracksUseCase +import com.example.ui.base.BaseViewModel +import com.example.ui.model.RegisteredTrackState +import com.example.ui.model.ScrappedTrackState +import com.example.ui.model.toUiState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@HiltViewModel(assistedFactory = OtherProfileViewModel.Factory::class) +class OtherProfileViewModel + @AssistedInject + constructor( + private val userRepository: UserRepository, + private val getMyRegisteredTracksUseCase: GetRegisteredTracksUseCase, + private val getMyScrappedTracksUseCase: GetScrappedTracksUseCase, + @Assisted private val userId: Long, + ) : BaseViewModel( + OtherProfileContract.OtherProfileState(), + ) { + @AssistedFactory + interface Factory { + fun create(userId: Long): OtherProfileViewModel + } + + init { + initializeUserInfo() + } + + val registeredTracks: Flow> = + getMyRegisteredTracksUseCase( + userId = userId, + onTotalCountFetched = { + updateState { + copy(registeredMusicCount = it) + } + }, + ).map { pagingData -> + pagingData.map { registeredTrack -> + registeredTrack.toUiState() + } + }.cachedIn(viewModelScope) + + val scrappedTracks: Flow> = + getMyScrappedTracksUseCase( + userId = userId, + ).map { pagingData -> + pagingData.map { scrappedTrack -> + scrappedTrack.toUiState() + } + }.cachedIn(viewModelScope) + + override fun handleIntent(intent: OtherProfileContract.OtherProfileIntent) { + when (intent) { + OtherProfileContract.OtherProfileIntent.OnBackIconClick -> { + setSideEffect(OtherProfileContract.OtherProfileSideEffect.NavigateToBack) + } + + is OtherProfileContract.OtherProfileIntent.OnRegisteredTrackClick -> { + setSideEffect(OtherProfileContract.OtherProfileSideEffect.NavigateToDetail(intent.postId)) + } + + is OtherProfileContract.OtherProfileIntent.OnScrappedTrackClick -> { + setSideEffect(OtherProfileContract.OtherProfileSideEffect.NavigateToDetail(intent.postId)) + } + + is OtherProfileContract.OtherProfileIntent.OnTabClick -> { + updateState { + copy(selectedTabIndex = intent.tabIndex) + } + } + } + } + + private fun initializeUserInfo() { + viewModelScope.launch { + userRepository + .getUser(userId) + .onSuccess { user -> + updateState { + copy( + loadingState = LoadingState.SUCCESS, + userNickname = user.nickname, + profileImagePath = user.profileImagePath, + ) + } + }.onFailure { + updateState { + copy( + loadingState = LoadingState.FAILURE, + ) + } + } + } + } + } diff --git a/feature/otherprofile/src/main/res/values/strings.xml b/feature/otherprofile/src/main/res/values/strings.xml new file mode 100644 index 00000000..deaf1746 --- /dev/null +++ b/feature/otherprofile/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + 마이페이지 + 총 %1$s개의 노래를 공유했어요 + 등록한 곡 + 보관함 + \ No newline at end of file diff --git a/feature/record/.gitignore b/feature/record/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/record/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/record/build.gradle.kts b/feature/record/build.gradle.kts new file mode 100644 index 00000000..590a2640 --- /dev/null +++ b/feature/record/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.record" +} + +dependencies { + implementation(libs.coil.compose) +} diff --git a/feature/record/consumer-rules.pro b/feature/record/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/record/proguard-rules.pro b/feature/record/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/record/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 \ No newline at end of file diff --git a/feature/record/src/main/java/com/example/record/RecordContract.kt b/feature/record/src/main/java/com/example/record/RecordContract.kt new file mode 100644 index 00000000..d0107007 --- /dev/null +++ b/feature/record/src/main/java/com/example/record/RecordContract.kt @@ -0,0 +1,57 @@ +package com.example.record + +import com.example.domain.model.DailyQuestion +import com.example.ui.base.BaseContract +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +class RecordContract { + data class RecordState( + val loading: Boolean = false, + val questionList: ImmutableList = persistentListOf(), + val year: Int = 2026, + val month: Int = 1, + val selectedQuestion: DailyQuestion? = null, + val datePickerBottomSheetVisible: Boolean = false, + val recordListTotalCount: Int = 0, + val tooltipVisible: Boolean = false, + val locked: Boolean = true, + ) : BaseContract.State + + sealed interface RecordIntent : BaseContract.Intent { + data object Initialize : RecordIntent + + data class OnQuestionClick( + val question: DailyQuestion, + ) : RecordIntent + + data object OnListBackButtonClick : RecordIntent + + data class ChangeBottomSheetVisible( + val isVisible: Boolean, + ) : RecordIntent + + data class OnMusicClick( + val postId: Long, + ) : RecordIntent + + data class SelectDate( + val year: Int, + val month: Int, + ) : RecordIntent + + data class ChangeTooltipVisible( + val isVisible: Boolean, + ) : RecordIntent + } + + sealed interface RecordSideEffect : BaseContract.SideEffect { + data class ShowSnackBar( + val message: String, + ) : RecordSideEffect + + data class NavigateToPostDetail( + val postId: Long, + ) : RecordSideEffect + } +} diff --git a/feature/record/src/main/java/com/example/record/RecordListScreen.kt b/feature/record/src/main/java/com/example/record/RecordListScreen.kt new file mode 100644 index 00000000..0bb60a87 --- /dev/null +++ b/feature/record/src/main/java/com/example/record/RecordListScreen.kt @@ -0,0 +1,118 @@ +package com.example.record + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.paging.compose.LazyPagingItems +import com.dplay.record.R +import com.example.designsystem.component.DPlayMusicListItem +import com.example.designsystem.component.DPlaySubjectItem +import com.example.designsystem.component.DplayLeftIconTitleTopAppBar +import com.example.designsystem.component.DplayTooltip +import com.example.designsystem.component.button.DPlayGuidelineButton +import com.example.designsystem.theme.DPlayTheme +import com.example.domain.model.Badge +import com.example.domain.model.FeedItem +import com.example.ui.emptyLazyPagingItems + +@Composable +fun RecordListScreen( + onBackButtonClick: () -> Unit, + onMusicClick: (postId: Long) -> Unit, + onGuideButtonClick: () -> Unit, + onTooltipCloseClick: () -> Unit, + modifier: Modifier = Modifier, + uiState: RecordContract.RecordState = RecordContract.RecordState(), + questionPosts: LazyPagingItems = emptyLazyPagingItems(), +) { + BackHandler { + onBackButtonClick() + } + Column(modifier = modifier.fillMaxSize()) { + DplayLeftIconTitleTopAppBar( + modifier = Modifier.fillMaxWidth(), + title = uiState.selectedQuestion!!.recordMMDD, + onLeftClick = onBackButtonClick, + ) + Spacer(modifier = Modifier.height(12.dp)) + + DPlaySubjectItem( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + subject = uiState.selectedQuestion.title, + ) + Spacer(modifier = Modifier.height(12.dp)) + + Text(text = stringResource(R.string.music_list_count, uiState.recordListTotalCount), modifier = Modifier.padding(start = 16.dp), style = DPlayTheme.typography.capMed12, color = DPlayTheme.colors.gray500) + Spacer(modifier = Modifier.height(12.dp)) + LazyColumn( + modifier = + Modifier + .weight(1f) + .background(color = DPlayTheme.colors.gray100) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + count = questionPosts.itemCount, + key = { index -> questionPosts[index]?.postId ?: index }, + contentType = { "record-item" }, + ) { index -> + val item = questionPosts[index] ?: return@items + DPlayMusicListItem( + musicImageUrl = item.track.coverImg, + musicName = item.track.songTitle, + musicArtistName = item.track.artistName, + musicContent = item.content, + isEditorPick = (item.badge == Badge.EDITOR), + onClick = { onMusicClick(item.postId) }, + ) + } + + if (uiState.locked) { + item { + DPlayGuidelineButton( + onClick = onGuideButtonClick, + textStringRes = R.string.record_locked_guide_button_text, + ) + if (uiState.tooltipVisible) { + Spacer(modifier = Modifier.height(8.dp)) + DplayTooltip( + onCloseButtonClicked = onTooltipCloseClick, + textStringRes = R.string.record_locked_tooltip_description, + onTextButtonClicked = null, + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun RecordListScreenPreview() { + DPlayTheme { + RecordListScreen( + onBackButtonClick = {}, + onMusicClick = {}, + onGuideButtonClick = {}, + onTooltipCloseClick = {}, + ) + } +} diff --git a/feature/record/src/main/java/com/example/record/RecordNavigationModule.kt b/feature/record/src/main/java/com/example/record/RecordNavigationModule.kt new file mode 100644 index 00000000..2290a945 --- /dev/null +++ b/feature/record/src/main/java/com/example/record/RecordNavigationModule.kt @@ -0,0 +1,26 @@ +package com.example.record + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.Navigator +import com.example.navigation.Record +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object RecordNavigationModule { + @Provides + @IntoSet + fun provideRecordEntry( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { + RecordRoute(navigator = navigator) + } + } +} diff --git a/feature/record/src/main/java/com/example/record/RecordRoute.kt b/feature/record/src/main/java/com/example/record/RecordRoute.kt new file mode 100644 index 00000000..3603f366 --- /dev/null +++ b/feature/record/src/main/java/com/example/record/RecordRoute.kt @@ -0,0 +1,64 @@ +package com.example.record + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems +import com.example.navigation.Detail +import com.example.navigation.Navigator +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun RecordRoute( + navigator: Navigator, + viewModel: RecordViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val questionPosts = viewModel.questionPosts.collectAsLazyPagingItems() + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collectLatest { + when (it) { + is RecordContract.RecordSideEffect.ShowSnackBar -> TODO() + is RecordContract.RecordSideEffect.NavigateToPostDetail -> { + navigator.navigateTo(Detail(it.postId)) + } + } + } + } + + when (uiState.selectedQuestion) { + null -> + RecordSelectScreen( + uiState = uiState, + onQuestionClick = { question -> + viewModel.handleIntent(RecordContract.RecordIntent.OnQuestionClick(question)) + }, + changeDatePickerBottomSheetVisible = { isVisible -> + viewModel.handleIntent(RecordContract.RecordIntent.ChangeBottomSheetVisible(isVisible = isVisible)) + }, + onDateSelectClick = { year, month -> + viewModel.handleIntent(RecordContract.RecordIntent.SelectDate(year = year, month = month)) + }, + onBackButtonClick = { navigator.navigateToBack() }, + ) + + else -> + RecordListScreen( + uiState = uiState, + questionPosts = questionPosts, + onBackButtonClick = { viewModel.handleIntent(RecordContract.RecordIntent.OnListBackButtonClick) }, + onMusicClick = { postId -> + viewModel.handleIntent(RecordContract.RecordIntent.OnMusicClick(postId = postId)) + }, + onGuideButtonClick = { + viewModel.handleIntent(RecordContract.RecordIntent.ChangeTooltipVisible(isVisible = true)) + }, + onTooltipCloseClick = { + viewModel.handleIntent(RecordContract.RecordIntent.ChangeTooltipVisible(isVisible = false)) + }, + ) + } +} diff --git a/feature/record/src/main/java/com/example/record/RecordSelectScreen.kt b/feature/record/src/main/java/com/example/record/RecordSelectScreen.kt new file mode 100644 index 00000000..b3ab9837 --- /dev/null +++ b/feature/record/src/main/java/com/example/record/RecordSelectScreen.kt @@ -0,0 +1,123 @@ +package com.example.record + +import androidx.activity.compose.BackHandler +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Text +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.example.designsystem.component.DPlayDatePickerBottomSheet +import com.example.designsystem.component.DPlayDayTopicItem +import com.example.designsystem.component.DplayTitleButtonTopAppBar +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable +import com.example.domain.model.DailyQuestion + +@Composable +fun RecordSelectScreen( + onQuestionClick: (question: DailyQuestion) -> Unit, + changeDatePickerBottomSheetVisible: (Boolean) -> Unit, + onDateSelectClick: (year: Int, month: Int) -> Unit, + onBackButtonClick: () -> Unit, + modifier: Modifier = Modifier, + uiState: RecordContract.RecordState = RecordContract.RecordState(), +) { + if (uiState.datePickerBottomSheetVisible) { + BackHandler { + changeDatePickerBottomSheetVisible(false) + } + } + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + DplayTitleButtonTopAppBar( + modifier = Modifier.fillMaxWidth(), + title = "${uiState.year}년 ${uiState.month}월", + onLeftClick = onBackButtonClick, + onButtonClick = { changeDatePickerBottomSheetVisible(true) }, + ) + Box( + modifier = + Modifier + .fillMaxSize(), + ) { + if (uiState.questionList.isEmpty()) { + Text( + text = "추천 기록이 없어요", + modifier = Modifier.align(Alignment.Center), + style = DPlayTheme.typography.bodyMed16, + color = DPlayTheme.colors.gray400, + ) + } else { + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + count = uiState.questionList.size, + key = { index -> uiState.questionList[index].questionId }, + ) { index -> + val item = uiState.questionList[index] + + DPlayDayTopicItem( + modifier = Modifier.fillMaxWidth(), + dayText = item.recordDayText, + topic = item.title, + onClick = { onQuestionClick(item) }, + ) + } + } + } + } + } + if (uiState.datePickerBottomSheetVisible) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dim40) + .noRippleClickable { changeDatePickerBottomSheetVisible(false) }, + ) + DPlayDatePickerBottomSheet( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomStart) + .noRippleClickable(), + onCloseClick = { changeDatePickerBottomSheetVisible(false) }, + onApplyClick = { year, month -> + onDateSelectClick(year, month) + changeDatePickerBottomSheetVisible(false) + }, + initialYear = uiState.year, + initialMonth = uiState.month, + ) + } + } +} + +@Preview +@Composable +private fun RecordSelectScreenPreview() { + DPlayTheme { + RecordSelectScreen( + onQuestionClick = {}, + changeDatePickerBottomSheetVisible = {}, + onDateSelectClick = { _, _ -> }, + onBackButtonClick = {}, + ) + } +} diff --git a/feature/record/src/main/java/com/example/record/RecordViewModel.kt b/feature/record/src/main/java/com/example/record/RecordViewModel.kt new file mode 100644 index 00000000..e12189f1 --- /dev/null +++ b/feature/record/src/main/java/com/example/record/RecordViewModel.kt @@ -0,0 +1,120 @@ +package com.example.record + +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.example.domain.model.DailyQuestion +import com.example.domain.model.FeedItem +import com.example.domain.model.QuestionError +import com.example.domain.repository.PostRepository +import com.example.domain.repository.QuestionRepository +import com.example.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import timber.log.Timber +import java.time.YearMonth +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class RecordViewModel + @Inject + constructor( + private val questionRepository: QuestionRepository, + private val postRepository: PostRepository, + ) : BaseViewModel( + RecordContract.RecordState(), + ) { + private val selectedQuestionId = MutableStateFlow(null) + + val questionPosts: Flow> = + selectedQuestionId + .flatMapLatest { questionId -> + if (questionId != null) { + postRepository.getPostsByQuestionId( + questionId = questionId, + onTotalCountFetched = { totalCount -> + updateState { copy(recordListTotalCount = totalCount) } + }, + onLockedFetched = { locked -> + updateState { copy(locked = locked) } + }, + ) + } else { + flowOf(PagingData.empty()) + } + }.cachedIn(viewModelScope) + + init { + val now = YearMonth.now() + setDate(year = now.year, month = now.month.value) + loadQuestions(year = now.year, month = now.month.value) + } + + override fun handleIntent(intent: RecordContract.RecordIntent) { + when (intent) { + is RecordContract.RecordIntent.Initialize -> {} + is RecordContract.RecordIntent.OnQuestionClick -> setQuestion(question = intent.question) + is RecordContract.RecordIntent.OnListBackButtonClick -> setQuestion(question = null) + is RecordContract.RecordIntent.OnMusicClick -> { + setSideEffect(RecordContract.RecordSideEffect.NavigateToPostDetail(postId = intent.postId)) + } + + is RecordContract.RecordIntent.SelectDate -> { + setDate(year = intent.year, month = intent.month) + } + + is RecordContract.RecordIntent.ChangeBottomSheetVisible -> { + updateState { copy(datePickerBottomSheetVisible = intent.isVisible) } + } + + is RecordContract.RecordIntent.ChangeTooltipVisible -> { + updateState { copy(tooltipVisible = intent.isVisible) } + } + } + } + + private fun loadQuestions( + year: Int, + month: Int, + ) { + viewModelScope.launch { + questionRepository + .getQuestionRecord(year = year, month = month) + .onSuccess { questions -> + updateState { copy(questionList = questions.toImmutableList()) } + }.onFailure { e -> + when (e) { + is QuestionError.NotFound -> { + updateState { copy(questionList = persistentListOf()) } + } + + else -> { + Timber.e(e, "error = $e") + updateState { copy(questionList = persistentListOf()) } + } + } + } + } + } + + private fun setQuestion(question: DailyQuestion?) { + selectedQuestionId.value = question?.questionId + updateState { copy(selectedQuestion = question, recordListTotalCount = 0) } + } + + private fun setDate( + year: Int, + month: Int, + ) { + loadQuestions(year = year, month = month) + updateState { copy(year = year, month = month) } + } + } diff --git a/feature/record/src/main/res/values/strings.xml b/feature/record/src/main/res/values/strings.xml new file mode 100644 index 00000000..47d26ae9 --- /dev/null +++ b/feature/record/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + 총 %1$d개의 곡 + 이 날의 추천은 여기까지에요.\n곡을 등록하지 않은 날에는 최대 3곡만 보여요. + 왜 일부 노래만 보이나요? + \ No newline at end of file diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts new file mode 100644 index 00000000..3cb46109 --- /dev/null +++ b/feature/search/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.search" +} diff --git a/feature/search/src/main/java/com/example/search/SearchContract.kt b/feature/search/src/main/java/com/example/search/SearchContract.kt new file mode 100644 index 00000000..417faa5c --- /dev/null +++ b/feature/search/src/main/java/com/example/search/SearchContract.kt @@ -0,0 +1,39 @@ +package com.example.search + +import com.example.ui.base.BaseContract +import com.example.ui.model.TrackState + +class SearchContract { + data class SearchState( + val searchInput: String = "", + val selectedTrack: TrackState? = null, + ) : BaseContract.State { + val isSearchIconEnabled: Boolean + get() = searchInput.isNotEmpty() + + val isNextButtonEnabled: Boolean + get() = selectedTrack != null + } + + sealed interface SearchIntent : BaseContract.Intent { + data class OnSearchInputChanged( + val input: String, + ) : SearchIntent + + data object OnNextButtonClick : SearchIntent + + data object OnBackIconClick : SearchIntent + + data class OnMusicSelected( + val track: TrackState, + ) : SearchIntent + } + + sealed interface SearchSideEffect : BaseContract.SideEffect { + data object NavigateToBack : SearchSideEffect + + data class NavigateToComment( + val track: TrackState, + ) : SearchSideEffect + } +} diff --git a/feature/search/src/main/java/com/example/search/SearchNavigationModule.kt b/feature/search/src/main/java/com/example/search/SearchNavigationModule.kt new file mode 100644 index 00000000..55848794 --- /dev/null +++ b/feature/search/src/main/java/com/example/search/SearchNavigationModule.kt @@ -0,0 +1,26 @@ +package com.example.search + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.Navigator +import com.example.navigation.Search +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object SearchNavigationModule { + @Provides + @IntoSet + fun provideSearchEntry( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { + SearchRoute(navigator = navigator) + } + } +} diff --git a/feature/search/src/main/java/com/example/search/SearchScreen.kt b/feature/search/src/main/java/com/example/search/SearchScreen.kt new file mode 100644 index 00000000..94bce0fa --- /dev/null +++ b/feature/search/src/main/java/com/example/search/SearchScreen.kt @@ -0,0 +1,192 @@ +package com.example.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.dplay.designsystem.R +import com.example.designsystem.component.DPlayImageCheck +import com.example.designsystem.component.DplayLeftIconTitleTopAppBar +import com.example.designsystem.component.button.DPlayLargePinkButton +import com.example.designsystem.component.textfield.DPlayTextInput +import com.example.designsystem.theme.DPlayTheme +import com.example.navigation.Comment +import com.example.navigation.Navigator +import com.example.ui.emptyLazyPagingItems +import com.example.ui.model.TrackState + +@Composable +fun SearchRoute( + navigator: Navigator, + modifier: Modifier = Modifier, + viewModel: SearchViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + val pagingSearchedMusics = viewModel.searchResults.collectAsLazyPagingItems() + + LaunchedEffect(Unit) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + SearchContract.SearchSideEffect.NavigateToBack -> { + navigator.navigateToBack() + } + is SearchContract.SearchSideEffect.NavigateToComment -> { + navigator.navigateTo(Comment(sideEffect.track)) + } + } + } + } + + SearchScreen( + state = state, + searchedTrackList = pagingSearchedMusics, + modifier = modifier, + onBackIconClick = { viewModel.handleIntent(SearchContract.SearchIntent.OnBackIconClick) }, + onSearchInputChanged = { viewModel.handleIntent(SearchContract.SearchIntent.OnSearchInputChanged(it)) }, + onMusicSelected = { viewModel.handleIntent(SearchContract.SearchIntent.OnMusicSelected(it)) }, + onNextButtonClick = { viewModel.handleIntent(SearchContract.SearchIntent.OnNextButtonClick) }, + ) +} + +@Composable +fun SearchScreen( + state: SearchContract.SearchState, + searchedTrackList: LazyPagingItems, + modifier: Modifier = Modifier, + onBackIconClick: () -> Unit = {}, + onSearchInputChanged: (String) -> Unit = {}, + onMusicSelected: (TrackState) -> Unit = {}, + onNextButtonClick: () -> Unit = {}, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayWhite) + .padding(bottom = 16.dp), + ) { + DplayLeftIconTitleTopAppBar( + title = stringResource(com.dplay.search.R.string.search_top_bar_title), + ) { onBackIconClick() } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = stringResource(com.dplay.search.R.string.search_title), + style = DPlayTheme.typography.titleBold24, + color = DPlayTheme.colors.dplayBlack, + modifier = Modifier.padding(start = 17.dp), + ) + + Spacer(modifier = Modifier.height(20.dp)) + + DPlayTextInput( + value = state.searchInput, + onValueChange = { onSearchInputChanged(it) }, + placeholder = stringResource(R.string.placeholder_music_search), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(20.dp)) + + SearchedMusicList( + searchedTrackList = searchedTrackList, + onMusicSelected = { onMusicSelected(it) }, + selectedTrackId = state.selectedTrack?.trackId, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + DPlayLargePinkButton( + onClick = { onNextButtonClick() }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + label = stringResource(R.string.next_button_label), + enabled = state.isNextButtonEnabled, + ) + } +} + +@Composable +private fun SearchedMusicList( + searchedTrackList: LazyPagingItems, + onMusicSelected: (TrackState) -> Unit, + modifier: Modifier = Modifier, + selectedTrackId: String? = null, +) { + if (searchedTrackList.itemCount == 0) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(153.dp)) + + Text( + text = stringResource(com.dplay.search.R.string.search_empty_view_text), + style = DPlayTheme.typography.bodySemi14, + color = DPlayTheme.colors.gray400, + ) + + Spacer(modifier = Modifier.weight(1f)) + } + } else { + LazyColumn( + modifier = modifier, + ) { + items( + count = searchedTrackList.itemCount, + key = searchedTrackList.itemKey { it.trackId }, + ) { index -> + + val music = searchedTrackList[index] + if (music != null) { + DPlayImageCheck( + imageUrl = music.thumbnailUrl, + musicName = music.musicTitle, + artistName = music.artistName, + isChecked = selectedTrackId == music.trackId, + onClick = { onMusicSelected(music) }, + ) + } else { + // 오류처리 + } + } + } + } +} + +@Preview +@Composable +fun SearchScreenPreview(modifier: Modifier = Modifier) { + DPlayTheme { + SearchScreen( + state = SearchContract.SearchState(), + searchedTrackList = emptyLazyPagingItems(), + ) + } +} diff --git a/feature/search/src/main/java/com/example/search/SearchViewModel.kt b/feature/search/src/main/java/com/example/search/SearchViewModel.kt new file mode 100644 index 00000000..95a40ef1 --- /dev/null +++ b/feature/search/src/main/java/com/example/search/SearchViewModel.kt @@ -0,0 +1,71 @@ +package com.example.search + +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.example.domain.repository.TrackRepository +import com.example.ui.base.BaseViewModel +import com.example.ui.model.TrackState +import com.example.ui.model.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel + @Inject + constructor( + private val trackRepository: TrackRepository, + ) : BaseViewModel( + SearchContract.SearchState(), + ) { + @OptIn(FlowPreview::class) + val searchResults: Flow> = + uiState + .map { it.searchInput } + .distinctUntilChanged() + .debounce(300L) + .flatMapLatest { query -> + if (query.isBlank()) { + flowOf(PagingData.empty()) + } else { + trackRepository.searchTracks(query).map { pagingData -> + pagingData.map { track -> + track.toUiState() + } + } + } + }.cachedIn(viewModelScope) + + override fun handleIntent(intent: SearchContract.SearchIntent) { + when (intent) { + SearchContract.SearchIntent.OnBackIconClick -> { + setSideEffect(SearchContract.SearchSideEffect.NavigateToBack) + } + is SearchContract.SearchIntent.OnMusicSelected -> { + updateState { + copy( + selectedTrack = intent.track, + ) + } + } + SearchContract.SearchIntent.OnNextButtonClick -> { + setSideEffect(SearchContract.SearchSideEffect.NavigateToComment(uiState.value.selectedTrack!!)) + } + is SearchContract.SearchIntent.OnSearchInputChanged -> { + updateState { + copy( + searchInput = intent.input, + ) + } + } + } + } + } diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml new file mode 100644 index 00000000..dd86451c --- /dev/null +++ b/feature/search/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + 노래 등록하기 + 추천하고 싶은\n노래를 검색해보세요! + 일치하는 검색 결과가 없어요 + \ No newline at end of file diff --git a/feature/setting/build.gradle.kts b/feature/setting/build.gradle.kts new file mode 100644 index 00000000..a4de2c6e --- /dev/null +++ b/feature/setting/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.setting" + + defaultConfig { + val appVersion = libs.versions.versionName.get() + buildConfigField("String", "APP_VERSION", "\"$appVersion\"") + } + + buildFeatures { + buildConfig = true + } +} diff --git a/feature/setting/src/main/java/com/example/setting/SettingContract.kt b/feature/setting/src/main/java/com/example/setting/SettingContract.kt new file mode 100644 index 00000000..b23939fe --- /dev/null +++ b/feature/setting/src/main/java/com/example/setting/SettingContract.kt @@ -0,0 +1,48 @@ +package com.example.setting + +import com.dplay.setting.BuildConfig +import com.example.ui.base.BaseContract + +class SettingContract { + data class SettingState( + val isLoading: Boolean = false, + val isPushNotificationEnabled: Boolean = false, + val appVersion: String = "v${BuildConfig.APP_VERSION}", + ) : BaseContract.State + + sealed interface SettingIntent : BaseContract.Intent { + data class OnMenuClick( + val type: SettingMenuType, + ) : SettingIntent + + data object OnLogoutConfirm : SettingIntent + + data object OnWithdrawConfirm : SettingIntent + + data object OnBackIconClick : SettingIntent + + data class UpdateNotificationPermission( + val isGranted: Boolean, + ) : SettingIntent + } + + sealed interface SettingSideEffect : BaseContract.SideEffect { + data class NavigateToWeb( + val url: String, + ) : SettingSideEffect + + data object NavigateToLogin : SettingSideEffect + + data object NavigateToBack : SettingSideEffect + + data object ShowLogoutWarningDialog : SettingSideEffect + + data object ShowWithdrawWarningDialog : SettingSideEffect + + data class OpenWebView( + val url: String, + ) : SettingSideEffect + + data object NavigateToNotificationSetting : SettingSideEffect + } +} diff --git a/feature/setting/src/main/java/com/example/setting/SettingMenuType.kt b/feature/setting/src/main/java/com/example/setting/SettingMenuType.kt new file mode 100644 index 00000000..72a2c52f --- /dev/null +++ b/feature/setting/src/main/java/com/example/setting/SettingMenuType.kt @@ -0,0 +1,19 @@ +package com.example.setting + +import androidx.annotation.StringRes +import com.dplay.setting.R +import com.example.common.constant.Url + +enum class SettingMenuType( + @StringRes val titleResId: Int, + val url: String? = null, +) { + PUSH_NOTIFICATION(R.string.setting_push_notification), + ANNOUNCEMENT(R.string.setting_announcement, Url.ANNOUNCEMENT), + INQUIRY(R.string.setting_inquiry, Url.INQUIRY), + TERMS(R.string.setting_terms, Url.TERMS_OF_SERVICE), + PRIVACY(R.string.setting_privacy, Url.PRIVACY_POLICY), + VERSION(R.string.setting_version), + LOGOUT(R.string.setting_logout), + WITHDRAW(R.string.setting_withdraw), +} diff --git a/feature/setting/src/main/java/com/example/setting/SettingNavigationModule.kt b/feature/setting/src/main/java/com/example/setting/SettingNavigationModule.kt new file mode 100644 index 00000000..654cfa44 --- /dev/null +++ b/feature/setting/src/main/java/com/example/setting/SettingNavigationModule.kt @@ -0,0 +1,28 @@ +package com.example.setting + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.Navigator +import com.example.navigation.Setting +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object SettingNavigationModule { + @Provides + @IntoSet + fun provideSettingEntry( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { + SettingRoute( + navigator = navigator, + ) + } + } +} diff --git a/feature/setting/src/main/java/com/example/setting/SettingScreen.kt b/feature/setting/src/main/java/com/example/setting/SettingScreen.kt new file mode 100644 index 00000000..7493f447 --- /dev/null +++ b/feature/setting/src/main/java/com/example/setting/SettingScreen.kt @@ -0,0 +1,251 @@ +package com.example.setting + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationManagerCompat +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dplay.designsystem.R +import com.example.designsystem.component.DplayBaseIcon +import com.example.designsystem.component.DplayLeftIconTitleTopAppBar +import com.example.designsystem.component.button.DPlayToggle +import com.example.designsystem.component.button.DPlayUnderlineTextButton +import com.example.designsystem.theme.DPlayTheme +import com.example.designsystem.util.noRippleClickable +import com.example.navigation.Login +import com.example.navigation.Navigator +import com.example.ui.controller.LocalModalController +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun SettingRoute( + navigator: Navigator, + modifier: Modifier = Modifier, + viewModel: SettingViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val modalController = LocalModalController.current + val uriHandler = LocalUriHandler.current + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + val isGranted = NotificationManagerCompat.from(context).areNotificationsEnabled() + viewModel.handleIntent(SettingContract.SettingIntent.UpdateNotificationPermission(isGranted)) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + SettingContract.SettingSideEffect.NavigateToBack -> { + navigator.navigateToBack() + } + SettingContract.SettingSideEffect.NavigateToLogin -> { + navigator.clearAndNavigateTo(Login) + } + is SettingContract.SettingSideEffect.NavigateToWeb -> {} + SettingContract.SettingSideEffect.ShowLogoutWarningDialog -> { + modalController.showWarningModal( + mainText = context.getString(com.dplay.setting.R.string.logout_warning_mainText), + subText = null, + onLeftButtonClick = { modalController.hideModal() }, + onRightButtonClick = { + modalController.hideModal() + viewModel.handleIntent(SettingContract.SettingIntent.OnLogoutConfirm) + }, + onDismiss = { modalController.hideModal() }, + leftButtonLabel = context.getString(com.dplay.setting.R.string.logout_warning_left_button_label), + rightButtonLabel = context.getString(com.dplay.setting.R.string.logout_warning_right_button_label), + ) + } + SettingContract.SettingSideEffect.ShowWithdrawWarningDialog -> { + modalController.showWarningModal( + mainText = context.getString(com.dplay.setting.R.string.withdraw_warning_main_text), + subText = context.getString(com.dplay.setting.R.string.withdraw_warning_sub_text), + onLeftButtonClick = { + modalController.hideModal() + viewModel.handleIntent(SettingContract.SettingIntent.OnWithdrawConfirm) + }, + onRightButtonClick = { modalController.hideModal() }, + onDismiss = { modalController.hideModal() }, + leftButtonLabel = context.getString(com.dplay.setting.R.string.withdraw_warning_left_button_label), + rightButtonLabel = context.getString(com.dplay.setting.R.string.withdraw_warning_right_button_label), + ) + } + is SettingContract.SettingSideEffect.OpenWebView -> { + uriHandler.openUri(sideEffect.url) + } + SettingContract.SettingSideEffect.NavigateToNotificationSetting -> { + val intent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + } + context.startActivity(intent) + } + } + } + } + + SettingScreen( + state = state, + onBackIconClick = { + viewModel.handleIntent(SettingContract.SettingIntent.OnBackIconClick) + }, + onMenuClick = { + viewModel.handleIntent(SettingContract.SettingIntent.OnMenuClick(it)) + }, + ) +} + +@Composable +fun SettingScreen( + state: SettingContract.SettingState, + modifier: Modifier = Modifier, + onBackIconClick: () -> Unit = {}, + onMenuClick: (SettingMenuType) -> Unit = {}, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayWhite) + .padding(bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DplayLeftIconTitleTopAppBar( + title = stringResource(com.dplay.setting.R.string.setting_screen_title), + ) { + onBackIconClick() + } + + SettingMenuType.entries.forEach { type -> + val titleColor = if (type == SettingMenuType.LOGOUT) DPlayTheme.colors.alertRed else DPlayTheme.colors.gray600 + + if (type != SettingMenuType.WITHDRAW) { + SettingActionRow( + stringResource(id = type.titleResId), + titleColor = titleColor, + onClick = { onMenuClick(type) }, + ) { + when (type) { + SettingMenuType.PUSH_NOTIFICATION -> { + DPlayToggle( + isChecked = state.isPushNotificationEnabled, + onClick = { + onMenuClick(type) + }, + ) + } + SettingMenuType.VERSION -> { + Text( + text = state.appVersion, + style = DPlayTheme.typography.bodyMed14, + color = DPlayTheme.colors.gray400, + ) + } + SettingMenuType.LOGOUT -> {} + else -> { + DplayBaseIcon( + iconRes = R.drawable.ic_arrow_right_16, + ) + } + } + } + } else { + Spacer(modifier = Modifier.weight(1f)) + + DPlayUnderlineTextButton( + text = stringResource(id = type.titleResId), + onClick = { onMenuClick(type) }, + ) + } + + if (type == SettingMenuType.INQUIRY) { + HorizontalDivider( + thickness = 8.dp, + color = DPlayTheme.colors.gray100, + ) + } + } + } +} + +@Composable +private fun SettingActionRow( + actionName: String, + modifier: Modifier = Modifier, + titleColor: Color = DPlayTheme.colors.gray600, + onClick: () -> Unit = {}, + trailingContent: @Composable () -> Unit = {}, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .noRippleClickable { onClick() } + .padding(all = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = actionName, + color = titleColor, + style = DPlayTheme.typography.bodySemi16, + ) + + Spacer(modifier = Modifier.weight(1f)) + + trailingContent() + } +} + +@Preview +@Composable +private fun SettingScreenPreview(modifier: Modifier = Modifier) { + DPlayTheme { + SettingScreen( + state = SettingContract.SettingState(), + ) + } +} diff --git a/feature/setting/src/main/java/com/example/setting/SettingViewModel.kt b/feature/setting/src/main/java/com/example/setting/SettingViewModel.kt new file mode 100644 index 00000000..fd8cca83 --- /dev/null +++ b/feature/setting/src/main/java/com/example/setting/SettingViewModel.kt @@ -0,0 +1,91 @@ +package com.example.setting + +import androidx.lifecycle.viewModelScope +import com.example.common.constant.Url +import com.example.domain.repository.AuthRepository +import com.example.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingViewModel + @Inject + constructor( + private val authRepository: AuthRepository, + ) : BaseViewModel( + SettingContract.SettingState(), + ) { + override fun handleIntent(intent: SettingContract.SettingIntent) { + when (intent) { + is SettingContract.SettingIntent.OnMenuClick -> { + handleMenuClick(intent.type) + } + SettingContract.SettingIntent.OnBackIconClick -> { + setSideEffect(SettingContract.SettingSideEffect.NavigateToBack) + } + SettingContract.SettingIntent.OnLogoutConfirm -> { + logout() + } + SettingContract.SettingIntent.OnWithdrawConfirm -> { + withdraw() + } + is SettingContract.SettingIntent.UpdateNotificationPermission -> { + updateState { + copy( + isPushNotificationEnabled = intent.isGranted, + ) + } + } + } + } + + private fun withdraw() { + viewModelScope.launch { + authRepository + .withdraw() + .onSuccess { + setSideEffect(SettingContract.SettingSideEffect.NavigateToLogin) + }.onFailure { + } + } + } + + private fun logout() { + viewModelScope.launch { + authRepository + .logout() + .onSuccess { + setSideEffect(SettingContract.SettingSideEffect.NavigateToLogin) + }.onFailure { + } + } + } + + private fun handleMenuClick(type: SettingMenuType) { + when (type) { + SettingMenuType.PUSH_NOTIFICATION -> { + setSideEffect(SettingContract.SettingSideEffect.NavigateToNotificationSetting) + } + SettingMenuType.ANNOUNCEMENT -> { + setSideEffect(SettingContract.SettingSideEffect.OpenWebView(type.url ?: Url.ERROR)) + } + SettingMenuType.INQUIRY -> { + setSideEffect(SettingContract.SettingSideEffect.OpenWebView(type.url ?: Url.ERROR)) + } + SettingMenuType.TERMS -> { + setSideEffect(SettingContract.SettingSideEffect.OpenWebView(type.url ?: Url.ERROR)) + } + SettingMenuType.PRIVACY -> { + setSideEffect(SettingContract.SettingSideEffect.OpenWebView(type.url ?: Url.ERROR)) + } + SettingMenuType.LOGOUT -> { + setSideEffect(SettingContract.SettingSideEffect.ShowLogoutWarningDialog) + } + SettingMenuType.WITHDRAW -> { + setSideEffect(SettingContract.SettingSideEffect.ShowWithdrawWarningDialog) + } + SettingMenuType.VERSION -> { /* 동작없음 */ } + } + } + } diff --git a/feature/setting/src/main/res/values/strings.xml b/feature/setting/src/main/res/values/strings.xml new file mode 100644 index 00000000..029200b9 --- /dev/null +++ b/feature/setting/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + 푸시 알림 + 공지사항 + 문의/제안하기 + 서비스 이용 약관 + 개인정보 처리방침 + 앱 버전 + 로그아웃 + 회원탈퇴 + 로그아웃하시겠어요? + 취소 + 로그아웃 + 정말 탈퇴하시겠어요? + 작성하신 글, 좋아요한 글, 저장한 글 등\n모든 기록이 삭제되며 복구가 불가능해요. + 탈퇴하기 + 머무르기 + 설정 + + + \ No newline at end of file diff --git a/feature/splash/build.gradle.kts b/feature/splash/build.gradle.kts new file mode 100644 index 00000000..0b3d0e3c --- /dev/null +++ b/feature/splash/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.dplay.feature) + alias(libs.plugins.dplay.hilt) +} + +android { + namespace = "com.dplay.splash" +} diff --git a/feature/splash/src/main/java/com/example/splash/SplashContract.kt b/feature/splash/src/main/java/com/example/splash/SplashContract.kt new file mode 100644 index 00000000..e62042ff --- /dev/null +++ b/feature/splash/src/main/java/com/example/splash/SplashContract.kt @@ -0,0 +1,19 @@ +package com.example.splash + +import com.example.ui.base.BaseContract + +class SplashContract { + data class SplashState( + val isLoading: Boolean = true, + ) : BaseContract.State + + sealed interface SplashIntent : BaseContract.Intent { + data object OnSplashScreenStart : SplashIntent + } + + sealed interface SplashSideEffect : BaseContract.SideEffect { + data object NavigateToLogin : SplashSideEffect + + data object NavigateToHome : SplashSideEffect + } +} diff --git a/feature/splash/src/main/java/com/example/splash/SplashNavigationModule.kt b/feature/splash/src/main/java/com/example/splash/SplashNavigationModule.kt new file mode 100644 index 00000000..859c2ae4 --- /dev/null +++ b/feature/splash/src/main/java/com/example/splash/SplashNavigationModule.kt @@ -0,0 +1,28 @@ +package com.example.splash + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.navigation.Navigator +import com.example.navigation.Splash +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object SplashNavigationModule { + @Provides + @IntoSet + fun provideSplashEntry( + navigator: Navigator, + ): EntryProviderScope.() -> Unit = + { + entry { + SplashRoute( + navigator = navigator, + ) + } + } +} diff --git a/feature/splash/src/main/java/com/example/splash/SplashScreen.kt b/feature/splash/src/main/java/com/example/splash/SplashScreen.kt new file mode 100644 index 00000000..8671846a --- /dev/null +++ b/feature/splash/src/main/java/com/example/splash/SplashScreen.kt @@ -0,0 +1,70 @@ +package com.example.splash + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.dplay.designsystem.R +import com.example.designsystem.theme.DPlayTheme +import com.example.navigation.Home +import com.example.navigation.Login +import com.example.navigation.Navigator +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun SplashRoute( + navigator: Navigator, + viewModel: SplashViewModel = hiltViewModel(), +) { + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + SplashContract.SplashSideEffect.NavigateToHome -> { + navigator.clearAndNavigateTo(Home) + } + + SplashContract.SplashSideEffect.NavigateToLogin -> { + navigator.clearAndNavigateTo(Login) + } + } + } + } + + LaunchedEffect(Unit) { + viewModel.handleIntent(SplashContract.SplashIntent.OnSplashScreenStart) + } + + SplashScreen() +} + +@Composable +fun SplashScreen() { + Column( + modifier = + Modifier + .fillMaxSize() + .background(color = DPlayTheme.colors.dplayPink) + .padding(top = 265.dp, start = 90.dp), + ) { + Image( + painter = painterResource(R.drawable.img_wordmark_white), + contentDescription = null, + modifier = Modifier.size(width = 200.dp, height = 60.dp), + ) + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun SplashScreenPreview() { + SplashScreen() +} diff --git a/feature/splash/src/main/java/com/example/splash/SplashViewModel.kt b/feature/splash/src/main/java/com/example/splash/SplashViewModel.kt new file mode 100644 index 00000000..8bd7bcdf --- /dev/null +++ b/feature/splash/src/main/java/com/example/splash/SplashViewModel.kt @@ -0,0 +1,56 @@ +package com.example.splash + +import androidx.lifecycle.viewModelScope +import com.example.domain.repository.UserRepository +import com.example.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel + @Inject + constructor( + private val userRepository: UserRepository, + ) : BaseViewModel( + SplashContract.SplashState(), + ) { + override fun handleIntent(intent: SplashContract.SplashIntent) { + when (intent) { + SplashContract.SplashIntent.OnSplashScreenStart -> { + viewModelScope.launch { + onSplashStart() + } + } + } + } + + private suspend fun checkAutoLogin() { + runCatching { + val accessToken = userRepository.getAccessToken().firstOrNull() + val refreshToken = userRepository.getRefreshToken().firstOrNull() + accessToken to refreshToken + }.onSuccess { (accessToken, refreshToken) -> + val destination = + if (accessToken.isNullOrEmpty() || refreshToken.isNullOrEmpty()) { + SplashContract.SplashSideEffect.NavigateToLogin + } else { + SplashContract.SplashSideEffect.NavigateToHome + } + setSideEffect(destination) + }.onFailure { + setSideEffect(SplashContract.SplashSideEffect.NavigateToLogin) + } + } + + private suspend fun onSplashStart() { + delay(SPLASH_DELAY_MS) + checkAutoLogin() + } + + companion object { + private const val SPLASH_DELAY_MS = 1000L + } + } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5d464bd3..09375570 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,32 +1,228 @@ [versions] +# SDK Versions +compileSdk = "36" +minSdk = "28" +targetSdk = "35" +jdkVersion = "17" +versionCode = "10000" +versionName = "1.0.0" + +# Build Tools agp = "8.12.3" -kotlin = "2.0.21" -coreKtx = "1.17.0" + +# AndroidX +androidx-core-ktx = "1.17.0" +androidx-appcompat = "1.7.1" +androidx-activity-compose = "1.11.0" +androidx-lifecycle-runtime-ktx = "2.9.4" +androidx-hilt-navigation-compose = "1.3.0" +androidx-lifecycle-navigation3 = "1.0.0" +androidx-datastore-preferences = "1.2.0" +androidx-paging = "3.3.6" +androidx-work = "2.11.0" + +# Compose +compose-bom = "2025.10.00" +compose-navigation = "2.9.5" +compose-navigation3 = "1.0.0" +compose-material3 = "1.4.0" + +# Coil +coil = "3.3.0" + +# Hilt +hilt = "2.57.2" + +# Java +javaxInject = "1" + +# Kotlin +kotlin = "2.2.20" +kotlinx-serialization-json = "1.9.0" +kotlinx-immutable = "0.4.0" +kotlinx-coroutines-core = "1.10.2" + +## Ksp +ksp = "2.2.20-2.0.4" + +## Ktlint +ktlint = "13.1.0" + +# Network +okhttp = "4.12.0" +retrofit = "2.11.0" + +# Splash +splash = "1.0.1" + +# Media3 +media3 = "1.6.0" + +# Timber +timber = "5.0.1" + +# Test junit = "4.13.2" -junitVersion = "1.3.0" -espressoCore = "3.7.0" -lifecycleRuntimeKtx = "2.9.4" -activityCompose = "1.11.0" -composeBom = "2024.09.00" +androidx-junit = "1.2.1" +espresso-core = "3.6.1" +material = "1.10.0" + +#kakao login +kakao-user = "2.23.0" [libraries] -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +# Androidx Core +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity-compose" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime-ktx" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidx-datastore-preferences" } +androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "androidx-paging"} +androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "androidx-paging"} +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidx-work" } + +# Coil +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } + +# Compose +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "compose-material3" } +compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "compose-navigation" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "compose-navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "compose-navigation3" } +androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle-navigation3" } +compose-hilt-navigation = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidx-hilt-navigation-compose" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } +compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } + +# Hilt +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } + +javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "javaxInject" } + +# Kotlin +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +kotlinx-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinx-immutable" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-core" } + +# Network +okhttp-bom = { group = "com.squareup.okhttp3", name = "okhttp-bom", version.ref = "okhttp" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp" } +okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor" } +retrofit-bom = { group = "com.squareup.retrofit2", name = "retrofit-bom", version.ref = "retrofit" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit" } +retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization" } + +# kako login +kakao-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-user" } + +# Splash +splash = { group = "androidx.core", name = "core-splashscreen", version.ref = "splash" } + +# Media3 +media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } + +# Timber +timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } + +# Test Libraries junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } -androidx-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } +espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } + +# Dependencies of the included build-logic +android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } +kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +compose-compiler-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } +ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } + +[bundles] +# AndroidX Core Libraries +androidx-core = [ + "androidx-core-ktx", + "androidx-appcompat", + "androidx-activity-compose", + "androidx-lifecycle-runtime-ktx", +] + +# Compose Libraries +compose = [ + "compose-ui", + "compose-ui-graphics", + "compose-ui-tooling", + "compose-ui-tooling-preview", + "compose-material3", + "compose-foundation", + "compose-runtime", + "androidx-activity-compose", + "androidx-navigation3-runtime", + "androidx-navigation3-ui" +] + +compose-debug = [ + "compose-ui-tooling", + "compose-ui-test-manifest" +] + +# Coil Libraries +coil = [ + "coil-compose", + "coil-network-okhttp", +] + +# Navigation +navigation = [ + "compose-navigation", + "compose-hilt-navigation", + "androidx-lifecycle-viewmodel-navigation3" +] + +# Network Libraries +retrofit = [ + "retrofit-kotlinx-serialization", + "retrofit" +] +okhttp = [ + "okhttp", + "okhttp-logging-interceptor" +] + +# Test Libraries +test = [ + "junit", + "androidx-junit", + "espresso-core", + "compose-ui-test-junit4", + "compose-ui-test-manifest", +] [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +dplay-android-application = { id = "dplay.android.application"} +dplay-android-library = { id = "dplay.android.library" } +dplay-android-compose = { id = "dplay.android.compose" } +dplay-feature = { id = "dplay.feature" } +dplay-data = { id = "dplay.data" } +dplay-domain = { id = "dplay.domain" } +dplay-hilt = { id = "dplay.hilt" } +dplay-test = { id = "dplay.test"} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index e02fa129..53cc1b6d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,5 @@ pluginManagement { + includeBuild("build-logic") repositories { google { content { @@ -16,9 +17,32 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://devrepo.kakao.com/nexus/content/groups/public/") } } } rootProject.name = "DPlay" include(":app") - \ No newline at end of file +include(":feature") +include(":core") +include(":core:domain") +include(":core:data") +include(":core:designsystem") +include(":core:common") +include(":core:network") +include(":feature:main") +include(":core:ui") +include(":feature:dummy") +include(":feature:home") +include(":feature:mypage") +include(":core:navigation") +include(":feature:detail") +include(":feature:splash") +include(":feature:login") +include(":feature:onboarding") +include(":feature:editprofile") +include(":feature:setting") +include(":feature:record") +include(":feature:search") +include(":feature:comment") +include(":feature:otherprofile")