From f2fe87f4df25aae0fa3b4405731f8997390e6c3b Mon Sep 17 00:00:00 2001 From: KwakEuiJin Date: Sat, 30 Dec 2023 16:51:30 +0900 Subject: [PATCH] [setting]: Add Core Module && Kotlin Version up --- app/build.gradle.kts | 30 +------- .../hamyeonham/plugin/AndroidFeaturePlugin.kt | 11 +++ core/common/.gitignore | 1 + core/common/build.gradle.kts | 18 +++++ core/common/consumer-rules.pro | 0 core/common/src/main/AndroidManifest.xml | 4 + .../hamyeonham/common/activity/ActivityExt.kt | 32 ++++++++ .../com/hmh/hamyeonham/common/ad/AdName.kt | 5 ++ .../hmh/hamyeonham/common/compose/Border.kt | 18 +++++ .../common/compose/DefaultPreview.kt | 11 +++ .../hamyeonham/common/context/ContextExt.kt | 68 ++++++++++++++++ .../hamyeonham/common/fragment/FragmentExt.kt | 51 ++++++++++++ .../common/image/BitampRequestBody.kt | 20 +++++ .../common/image/ContentUriReqeustBody.kt | 77 +++++++++++++++++++ .../com/hmh/hamyeonham/common/image/UriExt.kt | 73 ++++++++++++++++++ .../hmh/hamyeonham/common/intent/ArgsExt.kt | 25 ++++++ .../hmh/hamyeonham/common/intent/ExtraExt.kt | 69 +++++++++++++++++ .../hmh/hamyeonham/common/lifecycle/Event.kt | 27 +++++++ .../common/lifecycle/LifecycleExt.kt | 14 ++++ .../common/lifecycle/SingleLiveEvent.kt | 45 +++++++++++ .../common/navigation/NavigationProvider.kt | 11 +++ .../common/okhttp/RequestBodyExt.kt | 7 ++ .../hamyeonham/common/okhttp/ResponseExt.kt | 13 ++++ .../hamyeonham/common/primitive/StringExt.kt | 20 +++++ .../hmh/hamyeonham/common/qualifier/Auth.kt | 11 +++ .../common/qualifier/Interceptor.kt | 11 +++ .../hmh/hamyeonham/common/qualifier/OAuth.kt | 7 ++ .../com/hmh/hamyeonham/common/time/TimeExt.kt | 30 ++++++++ .../com/hmh/hamyeonham/common/view/Binding.kt | 60 +++++++++++++++ .../common/view/DialogFragmentExt.kt | 8 ++ .../com/hmh/hamyeonham/common/view/Pixel.kt | 9 +++ .../com/hmh/hamyeonham/common/view/ViewExt.kt | 69 +++++++++++++++++ .../common/src/main/res/drawable/ic_error.xml | 13 ++++ .../res/layout/fragment_error_fullscreen.xml | 42 ++++++++++ .../res/layout/fragment_progress_dialog.xml | 18 +++++ core/common/src/main/res/values/styles.xml | 8 ++ gradle/libs.versions.toml | 4 +- settings.gradle.kts | 1 + 38 files changed, 911 insertions(+), 30 deletions(-) create mode 100644 build-logic/convention/src/main/kotlin/com/hmh/hamyeonham/plugin/AndroidFeaturePlugin.kt create mode 100644 core/common/.gitignore create mode 100644 core/common/build.gradle.kts create mode 100644 core/common/consumer-rules.pro create mode 100644 core/common/src/main/AndroidManifest.xml create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/activity/ActivityExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/ad/AdName.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/compose/Border.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/compose/DefaultPreview.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/context/ContextExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/fragment/FragmentExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/image/BitampRequestBody.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/image/ContentUriReqeustBody.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/image/UriExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/intent/ArgsExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/intent/ExtraExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/Event.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/LifecycleExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/SingleLiveEvent.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/navigation/NavigationProvider.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/okhttp/RequestBodyExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/okhttp/ResponseExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/primitive/StringExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/Auth.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/Interceptor.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/OAuth.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/time/TimeExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/view/Binding.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/view/DialogFragmentExt.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/view/Pixel.kt create mode 100644 core/common/src/main/java/com/hmh/hamyeonham/common/view/ViewExt.kt create mode 100644 core/common/src/main/res/drawable/ic_error.xml create mode 100644 core/common/src/main/res/layout/fragment_error_fullscreen.xml create mode 100644 core/common/src/main/res/layout/fragment_progress_dialog.xml create mode 100644 core/common/src/main/res/values/styles.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6127a3c9d..94abb267e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,19 +11,11 @@ plugins { android { namespace = "com.hmh.hamyeonham" - compileSdk = 34 defaultConfig { applicationId = "com.hmh.hamyeonham" - minSdk = 28 - targetSdk = 34 - versionCode = 1 - versionName = "1.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary = true - } + versionCode = libs.versions.versionCode.get().toInt() + versionName = libs.versions.appVersion.get() } buildTypes { @@ -35,24 +27,6 @@ android { ) } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } - buildFeatures { - compose = true - } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.1" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } } dependencies { diff --git a/build-logic/convention/src/main/kotlin/com/hmh/hamyeonham/plugin/AndroidFeaturePlugin.kt b/build-logic/convention/src/main/kotlin/com/hmh/hamyeonham/plugin/AndroidFeaturePlugin.kt new file mode 100644 index 000000000..66de1961f --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/hmh/hamyeonham/plugin/AndroidFeaturePlugin.kt @@ -0,0 +1,11 @@ +package com.hmh.hamyeonham.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project + +class AndroidFeaturePlugin : Plugin { + override fun apply(target: Project) = with(target) { + plugins.apply("com.android.library") + configureAndroidCommonPlugin() + } +} diff --git a/core/common/.gitignore b/core/common/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/core/common/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts new file mode 100644 index 000000000..57bc993c7 --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,18 @@ +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + hmh("feature") + hmh("compose") +} + +android { + namespace = "com.hmh.hamyeonham.common" + + defaultConfig { + consumerProguardFiles("consumer-rules.pro") + } +} + +dependencies { + implementation(libs.fragment.ktx) + implementation(libs.retrofit) +} diff --git a/core/common/consumer-rules.pro b/core/common/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/core/common/src/main/AndroidManifest.xml b/core/common/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8bdb7e14b --- /dev/null +++ b/core/common/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/activity/ActivityExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/activity/ActivityExt.kt new file mode 100644 index 000000000..53a162c3e --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/activity/ActivityExt.kt @@ -0,0 +1,32 @@ +package com.hmh.hamyeonham.common.activity + +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.commit + +fun AppCompatActivity.showLoading() { + supportFragmentManager.commit(allowStateLoss = true) { + add(LoadingProgressIndicator.newInstance(), LoadingProgressIndicator.TAG) + } +} + +fun AppCompatActivity.hideLoading() { + supportFragmentManager.findFragmentByTag(LoadingProgressIndicator.TAG)?.let { fragment -> + supportFragmentManager.commit(allowStateLoss = true) { + remove(fragment) + } + } +} + +fun AppCompatActivity.showError() { + supportFragmentManager.commit(allowStateLoss = true) { + add(ErrorFullScreenDialogFragment.newInstance(), ErrorFullScreenDialogFragment.TAG) + } +} + +fun AppCompatActivity.hideError() { + supportFragmentManager.findFragmentByTag(ErrorFullScreenDialogFragment.TAG)?.let { fragment -> + supportFragmentManager.commit(allowStateLoss = true) { + remove(fragment) + } + } +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/ad/AdName.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/ad/AdName.kt new file mode 100644 index 000000000..6711ab6f2 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/ad/AdName.kt @@ -0,0 +1,5 @@ +package com.hmh.hamyeonham.common.ad + +enum class AdName(val adName: String) { + ALBUM_EDIT_COVER_REWARD_01("AlbumEditCover_reward_01") +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/compose/Border.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/compose/Border.kt new file mode 100644 index 000000000..4b16d1222 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/compose/Border.kt @@ -0,0 +1,18 @@ +package com.hmh.hamyeonham.common.compose + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp + +fun Modifier.bottomBorder(borderWidth: Dp, color: Color): Modifier = drawBehind { + val strokeWidth = borderWidth.value * density + val y = size.height - strokeWidth / 2 + drawLine( + color, + Offset(0f, y), + Offset(size.width, y), + strokeWidth + ) +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/compose/DefaultPreview.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/compose/DefaultPreview.kt new file mode 100644 index 000000000..53a0b7795 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/compose/DefaultPreview.kt @@ -0,0 +1,11 @@ +package com.hmh.hamyeonham.common.compose + +import androidx.compose.ui.tooling.preview.Preview + +@Preview( + name = "phone", + device = "spec:shape=Normal,width=360,height=760,unit=dp,dpi=480", + showBackground = true, + backgroundColor = 0xFFFFFF +) +annotation class DefaultPreview diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/context/ContextExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/context/ContextExt.kt new file mode 100644 index 000000000..c9943e4de --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/context/ContextExt.kt @@ -0,0 +1,68 @@ +package com.hmh.hamyeonham.common.context + +import android.app.Dialog +import android.content.Context +import android.graphics.Point +import android.os.Build +import android.view.View +import android.view.WindowInsets +import android.view.WindowManager +import android.widget.Toast +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import com.google.android.material.snackbar.Snackbar + +fun Context.toast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} + +fun Context.longToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() +} + +fun Context.snackBar(anchorView: View, message: () -> String) { + Snackbar.make(anchorView, message(), Snackbar.LENGTH_SHORT).show() +} + +fun Context.stringOf(@StringRes resId: Int) = getString(resId) + +fun Context.colorOf(@ColorRes resId: Int) = ContextCompat.getColor(this, resId) + +fun Context.drawableOf(@DrawableRes resId: Int) = ContextCompat.getDrawable(this, resId) + +fun Context.dialogWidthPercent(dialog: Dialog?, percent: Double = 0.8) { + val deviceSize = getDeviceSize() + dialog?.window?.run { + val params = attributes + params.width = (deviceSize[0] * percent).toInt() + attributes = params + } +} + +fun Context.getDeviceSize(): IntArray { + val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics = windowManager.currentWindowMetrics + val windowInsets = windowMetrics.windowInsets + + val insets = windowInsets.getInsetsIgnoringVisibility( + WindowInsets.Type.navigationBars() or WindowInsets.Type.displayCutout() + ) + val insetsWidth = insets.right + insets.left + val insetsHeight = insets.top + insets.bottom + + val bounds = windowMetrics.bounds + + return intArrayOf(bounds.width() - insetsWidth, bounds.height() - insetsHeight) + } else { + val display = windowManager.defaultDisplay + val size = Point() + + display?.getSize(size) + + return intArrayOf(size.x, size.y) + } +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/fragment/FragmentExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/fragment/FragmentExt.kt new file mode 100644 index 000000000..298d9c04d --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/fragment/FragmentExt.kt @@ -0,0 +1,51 @@ +package com.hmh.hamyeonham.common.fragment + +import android.view.View +import android.widget.Toast +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar + +fun Fragment.toast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() +} + +fun Fragment.longToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() +} + +fun Fragment.snackBar(anchorView: View, message: () -> String) { + Snackbar.make(anchorView, message(), Snackbar.LENGTH_SHORT).show() +} + +fun Fragment.stringOf(@StringRes resId: Int, formatArgs: Any? = null) = getString(resId, formatArgs) + +fun Fragment.colorOf(@ColorRes resId: Int) = ContextCompat.getColor(requireContext(), resId) + +fun Fragment.drawableOf(@DrawableRes resId: Int) = + ContextCompat.getDrawable(requireContext(), resId) + +fun Fragment.showLoading() { + childFragmentManager.commit(allowStateLoss = true) { + add(LoadingProgressIndicator.newInstance(), LoadingProgressIndicator.TAG) + } +} + +fun Fragment.hideLoading() { + childFragmentManager.findFragmentByTag(LoadingProgressIndicator.TAG)?.let { fragment -> + childFragmentManager.commit(allowStateLoss = true) { + remove(fragment) + } + } +} + +val Fragment.viewLifeCycle + get() = viewLifecycleOwner.lifecycle + +val Fragment.viewLifeCycleScope + get() = viewLifecycleOwner.lifecycleScope diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/image/BitampRequestBody.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/image/BitampRequestBody.kt new file mode 100644 index 000000000..bfa71a219 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/image/BitampRequestBody.kt @@ -0,0 +1,20 @@ +package com.hmh.hamyeonham.common.image + +import android.graphics.Bitmap +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okio.BufferedSink + +class BitmapRequestBody( + private val bitmap: Bitmap, + private val bytes: Long, + private val compressRate: Int = 100 +) : RequestBody() { + override fun contentLength() = bytes + override fun contentType(): MediaType = "image/jpeg".toMediaType() + + override fun writeTo(sink: BufferedSink) { + bitmap.compress(Bitmap.CompressFormat.JPEG, compressRate, sink.outputStream()) + } +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/image/ContentUriReqeustBody.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/image/ContentUriReqeustBody.kt new file mode 100644 index 000000000..5aa414dfe --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/image/ContentUriReqeustBody.kt @@ -0,0 +1,77 @@ +package com.hmh.hamyeonham.common.image + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.provider.MediaStore +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okio.BufferedSink +import java.io.ByteArrayOutputStream + +class ContentUriRequestBody( + context: Context, + private val uri: Uri? +) : RequestBody() { + private val contentResolver = context.contentResolver + + private var fileName = "" + private var size = -1L + private var compressedImage: ByteArray? = null + + init { + if (uri != null) { + contentResolver.query( + uri, + arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), + null, + null, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + size = + cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)) + fileName = + cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) + } + } + + // Compress bitmap + compressBitmap() + } + } + + private fun compressBitmap() { + if (uri != null) { + val originalBitmap = BitmapFactory.decodeStream(contentResolver.openInputStream(uri)) + val outputStream = ByteArrayOutputStream() + val imageSizeMb = size / (1024 * 1024.toDouble()) + outputStream.use { + val compressRate = ((3 / imageSizeMb) * 100).toInt() + originalBitmap.compress( + Bitmap.CompressFormat.JPEG, + if (imageSizeMb >= 3) compressRate else 100, + it + ) + } + compressedImage = outputStream.toByteArray() + size = compressedImage?.size?.toLong() ?: -1L + } + } + + private fun getFileName() = fileName + + override fun contentLength(): Long = size + + override fun contentType(): MediaType? = + uri?.let { contentResolver.getType(it)?.toMediaTypeOrNull() } + + override fun writeTo(sink: BufferedSink) { + compressedImage?.let(sink::write) + } + + fun toFormData(name: String) = MultipartBody.Part.createFormData(name, getFileName(), this) +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/image/UriExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/image/UriExt.kt new file mode 100644 index 000000000..0bfeb9c54 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/image/UriExt.kt @@ -0,0 +1,73 @@ +package com.hmh.hamyeonham.common.image + +import android.content.Context +import android.graphics.BitmapFactory +import android.media.ExifInterface +import android.net.Uri +import android.provider.MediaStore +import android.util.Size + +fun Uri.getImageSize(context: Context): Size { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + + if (scheme.equals("file")) { + BitmapFactory.decodeFile(path, options) + } else { + context.contentResolver.openInputStream(this)?.use { + BitmapFactory.decodeStream(it, null, options) + } + } + return Size(options.outWidth, options.outHeight) +} + +fun Uri.getAllocatedByte(context: Context): Long { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + + if (scheme.equals("file")) { + BitmapFactory.decodeFile(path, options) + } else { + context.contentResolver.openInputStream(this)?.use { + BitmapFactory.decodeStream(it, null, options) + } + } + return options.outWidth * options.outHeight * 4L +} + +fun Uri.getAllocatedBytes(context: Context) = context.contentResolver.query( + this, + arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), + null, + null, + null +)?.use { cursor -> + if (cursor.moveToFirst()) { + return@use cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)) + } + return@use 0L +} + +fun Uri.getAdjustedSize(context: Context): Size { + val exifInterface = context.contentResolver.openInputStream(this)?.use { inputStream -> + ExifInterface(inputStream) + } + + val rotation = exifInterface?.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) ?: ExifInterface.ORIENTATION_NORMAL + + val imageSize = getImageSize(context) + + return when (rotation) { + ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_ROTATE_270 -> { + Size(imageSize.height, imageSize.width) + } + + else -> Size(imageSize.width, imageSize.height) + } +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/intent/ArgsExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/intent/ArgsExt.kt new file mode 100644 index 000000000..023a6120c --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/intent/ArgsExt.kt @@ -0,0 +1,25 @@ +package com.hmh.hamyeonham.common.intent + +import android.os.Parcelable +import androidx.fragment.app.Fragment +import kotlin.properties.ReadOnlyProperty + +fun intArgs() = ReadOnlyProperty { thisRef, property -> + thisRef.requireArguments().getInt(property.name) +} + +fun longArgs() = ReadOnlyProperty { thisRef, property -> + thisRef.requireArguments().getLong(property.name) +} + +fun boolArgs() = ReadOnlyProperty { thisRef, property -> + thisRef.requireArguments().getBoolean(property.name) +} + +fun stringArgs() = ReadOnlyProperty { thisRef, property -> + thisRef.requireArguments().getString(property.name, "") +} + +fun

parcelableArgs() = ReadOnlyProperty { thisRef, property -> + thisRef.requireArguments().getParcelable(property.name) +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/intent/ExtraExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/intent/ExtraExt.kt new file mode 100644 index 000000000..386904cdd --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/intent/ExtraExt.kt @@ -0,0 +1,69 @@ +package com.hmh.hamyeonham.common.intent + +import android.app.Activity +import android.content.Intent +import android.os.Build +import android.os.Parcelable +import timber.log.Timber +import java.io.Serializable +import kotlin.properties.ReadOnlyProperty + +fun intExtra(defaultValue: Int = -1) = ReadOnlyProperty { thisRef, property -> + thisRef.intent.extras?.getInt( + property.name, + defaultValue + ) ?: defaultValue +} + +fun longExtra(defaultValue: Long = -1) = ReadOnlyProperty { thisRef, property -> + thisRef.intent.extras?.getLong( + property.name, + defaultValue + ) ?: defaultValue +} + +fun boolExtra(defaultValue: Boolean = false) = + ReadOnlyProperty { thisRef, property -> + thisRef.intent.extras?.getBoolean( + property.name, + defaultValue + ) ?: defaultValue + } + +fun stringExtra(defaultValue: String? = null) = + ReadOnlyProperty { thisRef, property -> + if (defaultValue == null) thisRef.intent.extras?.getString(property.name) + else thisRef.intent.extras?.getString(property.name, defaultValue) + } + +fun

parcelableExtra(defaultValue: P? = null) = + ReadOnlyProperty { thisRef, property -> + thisRef.intent.extras?.getParcelable(property.name) ?: defaultValue + } + + +inline fun serializableExtra(defaultValue: S? = null) = + ReadOnlyProperty { thisRef, property -> + thisRef.intent.serializableExtra(property.name) ?: defaultValue + } + +inline fun Intent.serializableExtra(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getSerializableExtra(key, T::class.java) + } else { + getSerializableExtra(key) as? T + } +} + +inline fun Intent.getCompatibleParcelableExtra(key: String): T? { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(key, T::class.java) + } else { + getParcelableExtra(key) + } + } catch (e: ClassCastException) { + Timber.e("IntentExtensions", "Failed to cast Parcelable object", e) + null + } +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/Event.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/Event.kt new file mode 100644 index 000000000..c74f48269 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/Event.kt @@ -0,0 +1,27 @@ +package com.hmh.hamyeonham.common.lifecycle + +import androidx.lifecycle.Observer + +open class Event(private val content: T) { + var hasBeenHandled = false + private set + + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + + fun peekContent(): T = content +} + +class EventObserver(private val onEventUnhandledContent: (T) -> Unit) : Observer> { + override fun onChanged(value: Event) { + value.getContentIfNotHandled()?.let { event -> + onEventUnhandledContent(event) + } + } +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/LifecycleExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/LifecycleExt.kt new file mode 100644 index 000000000..72c8313c3 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/LifecycleExt.kt @@ -0,0 +1,14 @@ +package com.hmh.hamyeonham.common.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +fun LifecycleOwner.launchInStarted(block: suspend CoroutineScope.() -> Unit) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED, block) + } +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/SingleLiveEvent.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/SingleLiveEvent.kt new file mode 100644 index 000000000..9d0ad961b --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/lifecycle/SingleLiveEvent.kt @@ -0,0 +1,45 @@ +package com.hmh.hamyeonham.common.lifecycle + +import androidx.annotation.MainThread +import androidx.annotation.Nullable +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean + +class SingleLiveEvent : MutableLiveData() { + private val pending: AtomicBoolean = AtomicBoolean(false) + + override fun observe(owner: LifecycleOwner, observer: Observer) { + if (hasActiveObservers()) { + Timber.w(TAG, "Multiple observers registered but only one will be notified of changes.") + } + + // Observe the internal MutableLiveData + super.observe(owner) { t -> + if (pending.compareAndSet(true, false)) { + observer.onChanged(t) + } + } + } + + @MainThread + override fun setValue(@Nullable t: T?) { + pending.set(true) + super.setValue(t) + } + + /** + * Used for cases where T is Void, to make calls cleaner. + */ + @MainThread + fun call() { + value = null + } + + companion object { + private const val TAG = "SingleLiveEvent" + } + +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/navigation/NavigationProvider.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/navigation/NavigationProvider.kt new file mode 100644 index 000000000..753fba19e --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/navigation/NavigationProvider.kt @@ -0,0 +1,11 @@ +package com.hmh.hamyeonham.common.navigation + +import android.content.Intent + +interface NavigationProvider { + fun toOnboarding(): Intent + fun toLicense(): Intent + fun toHome(): Intent + fun toAlbumList(albumId: Long): Intent + fun toSignUp(): Intent +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/okhttp/RequestBodyExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/okhttp/RequestBodyExt.kt new file mode 100644 index 000000000..dab47733c --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/okhttp/RequestBodyExt.kt @@ -0,0 +1,7 @@ +package com.hmh.hamyeonham.common.okhttp + +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody + +fun String?.toPlainRequestBody() = + requireNotNull(this).toRequestBody("text/plain".toMediaTypeOrNull()) diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/okhttp/ResponseExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/okhttp/ResponseExt.kt new file mode 100644 index 000000000..985896b36 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/okhttp/ResponseExt.kt @@ -0,0 +1,13 @@ +package com.hmh.hamyeonham.common.okhttp + +import retrofit2.HttpException +import retrofit2.Response + + +fun Response.getResponseBodyOrThrow(): T { + if (this.isSuccessful) { + return this.body() ?: Unit as T + } else { + throw HttpException(this) + } +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/primitive/StringExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/primitive/StringExt.kt new file mode 100644 index 000000000..eeaaf8a6c --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/primitive/StringExt.kt @@ -0,0 +1,20 @@ +package com.hmh.hamyeonham.common.primitive + +import android.content.Context +import android.graphics.Typeface +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import android.text.style.TextAppearanceSpan +import androidx.annotation.StyleRes +import androidx.core.text.inSpans + +inline fun SpannableStringBuilder.font( + typeface: Typeface? = null, + builderAction: SpannableStringBuilder.() -> Unit +) = inSpans(StyleSpan(typeface?.style ?: Typeface.DEFAULT.style), builderAction = builderAction) + +inline fun SpannableStringBuilder.textAppearance( + context: Context, + @StyleRes style: Int, + builderAction: SpannableStringBuilder.() -> Unit +) = inSpans(TextAppearanceSpan(context, style), builderAction = builderAction) diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/Auth.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/Auth.kt new file mode 100644 index 000000000..13a989c91 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/Auth.kt @@ -0,0 +1,11 @@ +package com.hmh.hamyeonham.common.qualifier + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Secured + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Unsecured diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/Interceptor.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/Interceptor.kt new file mode 100644 index 000000000..afeac5430 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/Interceptor.kt @@ -0,0 +1,11 @@ +package com.hmh.hamyeonham.common.qualifier + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Log + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Auth diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/OAuth.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/OAuth.kt new file mode 100644 index 000000000..7d85fe04c --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/qualifier/OAuth.kt @@ -0,0 +1,7 @@ +package com.hmh.hamyeonham.common.qualifier + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Kakao diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/time/TimeExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/time/TimeExt.kt new file mode 100644 index 000000000..fd90e0b2e --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/time/TimeExt.kt @@ -0,0 +1,30 @@ +package com.hmh.hamyeonham.common.time + +import android.content.Context +import android.text.format.DateUtils +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +fun Instant.Companion.systemNow(): Instant = Clock.System.now() + +fun Instant.toDefaultLocalDate(): LocalDate = toLocalDateTime(TimeZone.currentSystemDefault()).date + +fun Long.formatDate(context: Context): String = DateUtils.formatDateTime( + context, + this, + DateUtils.FORMAT_SHOW_YEAR or DateUtils.FORMAT_SHOW_DATE +) + +fun Instant.formatDate(context: Context): String = toEpochMilliseconds().formatDate(context) + +fun Long.formatNumericDate(context: Context): String = DateUtils.formatDateTime( + context, + this, + DateUtils.FORMAT_SHOW_YEAR or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NUMERIC_DATE +) + +fun Instant.formatNumericDate(context: Context): String = + toEpochMilliseconds().formatNumericDate(context) diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/view/Binding.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/view/Binding.kt new file mode 100644 index 000000000..cb7d85cf2 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/view/Binding.kt @@ -0,0 +1,60 @@ +package com.hmh.hamyeonham.common.view + +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.viewbinding.ViewBinding +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +inline fun AppCompatActivity.viewBinding( + crossinline inflater: (LayoutInflater) -> T +) = lazy(LazyThreadSafetyMode.NONE) { + inflater.invoke(layoutInflater) +} + +class FragmentViewBindingDelegate( + val fragment: F, + val viewBindingFactory: (View) -> T +) : ReadOnlyProperty { + private var binding: T? = null + + init { + fragment.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> + viewLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + binding = null + } + } + ) + } + } + } + ) + } + + override fun getValue(thisRef: F, property: KProperty<*>): T { + val binding = binding + if (binding != null) { + return binding + } + + val lifecycle = fragment.viewLifecycleOwner.lifecycle + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { + throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.") + } + + return viewBindingFactory(thisRef.requireView()).also { this.binding = it } + } +} + +fun Fragment.viewBinding(viewBinder: (View) -> T) = + FragmentViewBindingDelegate(this, viewBinder) diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/view/DialogFragmentExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/view/DialogFragmentExt.kt new file mode 100644 index 000000000..c5d29bb61 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/view/DialogFragmentExt.kt @@ -0,0 +1,8 @@ +package com.hmh.hamyeonham.common.view + +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager + +fun DialogFragment.showAllowingStateLoss(supportFragmentManager: FragmentManager, tag: String = "") { + supportFragmentManager.beginTransaction().add(this, tag).commitAllowingStateLoss() +} diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/view/Pixel.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/view/Pixel.kt new file mode 100644 index 000000000..ecf578256 --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/view/Pixel.kt @@ -0,0 +1,9 @@ +package com.hmh.hamyeonham.common.view + +import android.content.res.Resources + +val Int.dp + get() = this * Resources.getSystem().displayMetrics.density.toInt() + +val Int.px + get() = (this * Resources.getSystem().displayMetrics.density).toInt() diff --git a/core/common/src/main/java/com/hmh/hamyeonham/common/view/ViewExt.kt b/core/common/src/main/java/com/hmh/hamyeonham/common/view/ViewExt.kt new file mode 100644 index 000000000..886fda1fa --- /dev/null +++ b/core/common/src/main/java/com/hmh/hamyeonham/common/view/ViewExt.kt @@ -0,0 +1,69 @@ +package com.hmh.hamyeonham.common.view + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView + +inline fun View.setOnSingleClickListener( + delay: Long = 500L, + crossinline block: (View) -> Unit +) { + var previousClickedTime = 0L + setOnClickListener { view -> + val clickedTime = System.currentTimeMillis() + if (clickedTime - previousClickedTime >= delay) { + block(view) + previousClickedTime = clickedTime + } + } +} + +class ItemDiffCallback( + val onItemsTheSame: (T, T) -> Boolean, + val onContentsTheSame: (T, T) -> Boolean +) : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: T, newItem: T + ): Boolean = onItemsTheSame(oldItem, newItem) + + override fun areContentsTheSame( + oldItem: T, newItem: T + ): Boolean = onContentsTheSame(oldItem, newItem) +} + +class GridSpacingItemDecoration( + private val spanCount: Int, + private val spacing: Int, + private val includeEdge: Boolean +) : RecyclerView.ItemDecoration() { + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val position = parent.getChildAdapterPosition(view) // item position + val column = position % spanCount // item column + + if (!includeEdge) { + outRect.left = + spacing - column * spacing / spanCount // spacing - column * ((1f / spanCount) * spacing) + outRect.right = + (column + 1) * spacing / spanCount // (column + 1) * ((1f / spanCount) * spacing) + + if (position < spanCount) { // top edge + outRect.top = spacing + } + outRect.bottom = spacing // item bottom + } else { + outRect.left = column * spacing / spanCount // column * ((1f / spanCount) * spacing) + outRect.right = + spacing - (column + 1) * spacing / spanCount // spacing - (column + 1) * ((1f / spanCount) * spacing) + if (position >= spanCount) { + outRect.top = spacing // item top + } + } + } +} diff --git a/core/common/src/main/res/drawable/ic_error.xml b/core/common/src/main/res/drawable/ic_error.xml new file mode 100644 index 000000000..b0890c644 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_error.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/common/src/main/res/layout/fragment_error_fullscreen.xml b/core/common/src/main/res/layout/fragment_error_fullscreen.xml new file mode 100644 index 000000000..762e56f79 --- /dev/null +++ b/core/common/src/main/res/layout/fragment_error_fullscreen.xml @@ -0,0 +1,42 @@ + + + + + + + + + diff --git a/core/common/src/main/res/layout/fragment_progress_dialog.xml b/core/common/src/main/res/layout/fragment_progress_dialog.xml new file mode 100644 index 000000000..e753f831f --- /dev/null +++ b/core/common/src/main/res/layout/fragment_progress_dialog.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/core/common/src/main/res/values/styles.xml b/core/common/src/main/res/values/styles.xml new file mode 100644 index 000000000..a88e336a5 --- /dev/null +++ b/core/common/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b9f507f84..a0ab8e92c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ appVersion = "1.0.0" versionCode = "1" # kotlin -kotlin = "1.9.0" +kotlin = "1.9.20" kotlinx-serialization-json = "1.6.0" kotlinx-serialization-converter = "1.0.0" kotlinx-coroutines = "1.7.3" @@ -20,7 +20,7 @@ lifecycle = "2.6.2" navigation = "2.7.5" startup = "1.1.1" security = "1.1.0-alpha06" -compose-compiler = "1.5.4" +compose-compiler = "1.5.5" compose-bom = "2023.10.01" desugarJdk = "2.0.4" dagger-hilt = "2.48.1" diff --git a/settings.gradle.kts b/settings.gradle.kts index 741511cad..fcc2fc01e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,3 +18,4 @@ dependencyResolutionManagement { rootProject.name = "HMH-Android" include(":app") +include(":core:common")