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