diff --git a/.github/workflows/develop_branch.yml b/.github/workflows/develop_branch.yml index 1af3e99b..28ba3704 100644 --- a/.github/workflows/develop_branch.yml +++ b/.github/workflows/develop_branch.yml @@ -24,6 +24,7 @@ jobs: - name: Generate local.properties env: KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }} BITNAGIL_DEV_URL: ${{ secrets.BITNAGIL_DEV_URL }} BITNAGIL_PROD_URL: ${{ secrets.BITNAGIL_PROD_URL }} RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} @@ -31,6 +32,7 @@ jobs: RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} run: | echo "kakao.native.app.key=$KAKAO_NATIVE_APP_KEY" >> local.properties + echo "kakao.rest.api.key=$KAKAO_REST_API_KEY" >> local.properties echo "bitnagil.dev.url=$BITNAGIL_DEV_URL" >> local.properties echo "bitnagil.prod.url=$BITNAGIL_PROD_URL" >> local.properties echo "release.key.alias=$RELEASE_KEY_ALIAS" >> local.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1bf1f6a0..2519d233 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,6 +46,17 @@ android { name = "KAKAO_NATIVE_APP_KEY", value = "\"$kakaoNativeAppKey\"", ) + + val kakaoRestApiKey = + (properties["kakao.rest.api.key"] as? String) + ?: System.getenv("KAKAO_REST_API_KEY") + ?: throw GradleException("KAKAO_REST_API_KEY 값이 없습니다.") + + buildConfigField( + type = "String", + name = "KAKAO_REST_API_KEY", + value = "\"$kakaoRestApiKey\"", + ) } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f643fa4b..28c5ae01 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,11 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + + diff --git a/app/src/main/java/com/threegap/bitnagil/MainNavHost.kt b/app/src/main/java/com/threegap/bitnagil/MainNavHost.kt index d80369e0..4810d5b1 100644 --- a/app/src/main/java/com/threegap/bitnagil/MainNavHost.kt +++ b/app/src/main/java/com/threegap/bitnagil/MainNavHost.kt @@ -13,6 +13,7 @@ import com.threegap.bitnagil.presentation.login.LoginScreenContainer import com.threegap.bitnagil.presentation.onboarding.OnBoardingScreenContainer import com.threegap.bitnagil.presentation.onboarding.OnBoardingViewModel import com.threegap.bitnagil.presentation.onboarding.model.navarg.OnBoardingScreenArg +import com.threegap.bitnagil.presentation.report.ReportScreenContainer import com.threegap.bitnagil.presentation.routinelist.RoutineListScreenContainer import com.threegap.bitnagil.presentation.setting.SettingScreenContainer import com.threegap.bitnagil.presentation.splash.SplashScreenContainer @@ -134,6 +135,11 @@ fun MainNavHost( launchSingleTop = true } }, + navigateToReport = { + navigator.navController.navigate(Route.Report) { + launchSingleTop = true + } + }, ) } @@ -289,5 +295,15 @@ fun MainNavHost( }, ) } + + composable { + ReportScreenContainer( + navigateToBack = { + if (navigator.navController.previousBackStackEntry != null) { + navigator.navController.popBackStack() + } + }, + ) + } } } diff --git a/app/src/main/java/com/threegap/bitnagil/Route.kt b/app/src/main/java/com/threegap/bitnagil/Route.kt index 504f2993..93e1cd0e 100644 --- a/app/src/main/java/com/threegap/bitnagil/Route.kt +++ b/app/src/main/java/com/threegap/bitnagil/Route.kt @@ -48,4 +48,7 @@ sealed interface Route { @Serializable data object Guide : Route + + @Serializable + data object Report : Route } diff --git a/app/src/main/java/com/threegap/bitnagil/di/core/NetworkModule.kt b/app/src/main/java/com/threegap/bitnagil/di/core/NetworkModule.kt index 9d253400..1792fa7b 100644 --- a/app/src/main/java/com/threegap/bitnagil/di/core/NetworkModule.kt +++ b/app/src/main/java/com/threegap/bitnagil/di/core/NetworkModule.kt @@ -5,6 +5,9 @@ import android.content.Intent import com.threegap.bitnagil.BuildConfig import com.threegap.bitnagil.MainActivity import com.threegap.bitnagil.datastore.auth.storage.AuthTokenDataStore +import com.threegap.bitnagil.network.Auth +import com.threegap.bitnagil.network.Kakao +import com.threegap.bitnagil.network.NoneAuth import com.threegap.bitnagil.network.auth.AuthInterceptor import com.threegap.bitnagil.network.auth.TokenAuthenticator import com.threegap.bitnagil.network.token.ReissueService @@ -30,6 +33,8 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object NetworkModule { private const val APPLICATION_JSON = "application/json" + private const val REST_API_KEY = BuildConfig.KAKAO_REST_API_KEY + private const val KAKAO_URL = "https://dapi.kakao.com" @Provides @Singleton @@ -74,6 +79,43 @@ object NetworkModule { override suspend fun clearTokens() = dataStore.clearAuthToken() } + @Provides + @Singleton + @Kakao + fun provideKakaoAuthInterceptor(): Interceptor = + Interceptor { chain -> + val request = chain.request().newBuilder() + .addHeader("Authorization", "KakaoAK $REST_API_KEY") + .build() + chain.proceed(request) + } + + @Provides + @Singleton + @Kakao + fun provideKakaoOkHttpClient( + httpLoggingInterceptor: HttpLoggingInterceptor, + @Kakao kakaoAuthInterceptor: Interceptor, + ): OkHttpClient = OkHttpClient.Builder() + .addInterceptor(kakaoAuthInterceptor) + .addInterceptor(httpLoggingInterceptor) + .connectTimeout(10L, TimeUnit.SECONDS) + .writeTimeout(30L, TimeUnit.SECONDS) + .readTimeout(30L, TimeUnit.SECONDS) + .build() + + @Provides + @Singleton + @Kakao + fun provideKakaoRetrofit( + converterFactory: Converter.Factory, + @Kakao okHttpClient: OkHttpClient, + ): Retrofit = Retrofit.Builder() + .baseUrl(KAKAO_URL) + .addConverterFactory(converterFactory) + .client(okHttpClient) + .build() + @Provides @Singleton fun provideTokenAuthenticator( diff --git a/app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt b/app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt index 21a92847..0d399e5a 100644 --- a/app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt +++ b/app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt @@ -1,15 +1,23 @@ package com.threegap.bitnagil.di.data +import com.threegap.bitnagil.data.address.datasource.AddressDataSource +import com.threegap.bitnagil.data.address.datasource.LocationDataSource +import com.threegap.bitnagil.data.address.datasourceImpl.AddressDataSourceImpl +import com.threegap.bitnagil.data.address.datasourceImpl.LocationDataSourceImpl import com.threegap.bitnagil.data.auth.datasource.AuthLocalDataSource import com.threegap.bitnagil.data.auth.datasource.AuthRemoteDataSource import com.threegap.bitnagil.data.auth.datasourceimpl.AuthLocalDataSourceImpl import com.threegap.bitnagil.data.auth.datasourceimpl.AuthRemoteDataSourceImpl import com.threegap.bitnagil.data.emotion.datasource.EmotionDataSource import com.threegap.bitnagil.data.emotion.datasourceImpl.EmotionDataSourceImpl +import com.threegap.bitnagil.data.file.datasource.FileDataSource +import com.threegap.bitnagil.data.file.datasourceImpl.FileDataSourceImpl import com.threegap.bitnagil.data.onboarding.datasource.OnBoardingDataSource import com.threegap.bitnagil.data.onboarding.datasourceImpl.OnBoardingDataSourceImpl import com.threegap.bitnagil.data.recommendroutine.datasource.RecommendRoutineDataSource import com.threegap.bitnagil.data.recommendroutine.datasourceImpl.RecommendRoutineDataSourceImpl +import com.threegap.bitnagil.data.report.datasource.ReportDataSource +import com.threegap.bitnagil.data.report.datasourceImpl.ReportDataSourceImpl import com.threegap.bitnagil.data.routine.datasource.RoutineRemoteDataSource import com.threegap.bitnagil.data.routine.datasourceImpl.RoutineRemoteDataSourceImpl import com.threegap.bitnagil.data.user.datasource.UserDataSource @@ -63,4 +71,20 @@ abstract class DataSourceModule { @Binds @Singleton abstract fun bindVersionDataSource(versionDataSourceImpl: VersionDataSourceImpl): VersionDataSource + + @Binds + @Singleton + abstract fun bindLocationDataSource(locationDataSourceImpl: LocationDataSourceImpl): LocationDataSource + + @Binds + @Singleton + abstract fun bindAddressDataSource(addressDataSourceImpl: AddressDataSourceImpl): AddressDataSource + + @Binds + @Singleton + abstract fun bindFileDataSource(impl: FileDataSourceImpl): FileDataSource + + @Binds + @Singleton + abstract fun bindReportDataSource(impl: ReportDataSourceImpl): ReportDataSource } diff --git a/app/src/main/java/com/threegap/bitnagil/di/data/RepositoryModule.kt b/app/src/main/java/com/threegap/bitnagil/di/data/RepositoryModule.kt index 93fb9e37..cc8ddb90 100644 --- a/app/src/main/java/com/threegap/bitnagil/di/data/RepositoryModule.kt +++ b/app/src/main/java/com/threegap/bitnagil/di/data/RepositoryModule.kt @@ -1,17 +1,23 @@ package com.threegap.bitnagil.di.data +import com.threegap.bitnagil.data.address.repositoryImpl.AddressRepositoryImpl import com.threegap.bitnagil.data.auth.repositoryimpl.AuthRepositoryImpl import com.threegap.bitnagil.data.emotion.repositoryImpl.EmotionRepositoryImpl +import com.threegap.bitnagil.data.file.repositoryImpl.FileRepositoryImpl import com.threegap.bitnagil.data.onboarding.repositoryImpl.OnBoardingRepositoryImpl import com.threegap.bitnagil.data.recommendroutine.repositoryImpl.RecommendRoutineRepositoryImpl +import com.threegap.bitnagil.data.report.repositoryImpl.ReportRepositoryImpl import com.threegap.bitnagil.data.routine.repositoryImpl.RoutineRepositoryImpl import com.threegap.bitnagil.data.user.repositoryImpl.UserRepositoryImpl import com.threegap.bitnagil.data.version.repositoryImpl.VersionRepositoryImpl import com.threegap.bitnagil.data.writeroutine.repositoryImpl.WriteRoutineRepositoryImpl +import com.threegap.bitnagil.domain.address.repository.AddressRepository import com.threegap.bitnagil.domain.auth.repository.AuthRepository import com.threegap.bitnagil.domain.emotion.repository.EmotionRepository +import com.threegap.bitnagil.domain.file.repository.FileRepository import com.threegap.bitnagil.domain.onboarding.repository.OnBoardingRepository import com.threegap.bitnagil.domain.recommendroutine.repository.RecommendRoutineRepository +import com.threegap.bitnagil.domain.report.repository.ReportRepository import com.threegap.bitnagil.domain.routine.repository.RoutineRepository import com.threegap.bitnagil.domain.user.repository.UserRepository import com.threegap.bitnagil.domain.version.repository.VersionRepository @@ -57,4 +63,16 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindVersionRepository(versionRepositoryImpl: VersionRepositoryImpl): VersionRepository + + @Binds + @Singleton + abstract fun bindAddressRepository(addressRepositoryImpl: AddressRepositoryImpl): AddressRepository + + @Binds + @Singleton + abstract fun bindFileRepository(impl: FileRepositoryImpl): FileRepository + + @Binds + @Singleton + abstract fun bindReportRepository(impl: ReportRepositoryImpl): ReportRepository } diff --git a/app/src/main/java/com/threegap/bitnagil/di/data/ServiceModule.kt b/app/src/main/java/com/threegap/bitnagil/di/data/ServiceModule.kt index 66376c96..602d4a26 100644 --- a/app/src/main/java/com/threegap/bitnagil/di/data/ServiceModule.kt +++ b/app/src/main/java/com/threegap/bitnagil/di/data/ServiceModule.kt @@ -1,15 +1,19 @@ package com.threegap.bitnagil.di.data +import com.threegap.bitnagil.data.address.service.AddressService import com.threegap.bitnagil.data.auth.service.AuthService import com.threegap.bitnagil.data.emotion.service.EmotionService +import com.threegap.bitnagil.data.file.service.FileService import com.threegap.bitnagil.data.onboarding.service.OnBoardingService import com.threegap.bitnagil.data.recommendroutine.service.RecommendRoutineService +import com.threegap.bitnagil.data.report.service.ReportService import com.threegap.bitnagil.data.routine.service.RoutineService import com.threegap.bitnagil.data.user.service.UserService import com.threegap.bitnagil.data.version.service.VersionService import com.threegap.bitnagil.data.writeroutine.service.WriteRoutineService -import com.threegap.bitnagil.di.core.Auth -import com.threegap.bitnagil.di.core.NoneAuth +import com.threegap.bitnagil.network.Auth +import com.threegap.bitnagil.network.Kakao +import com.threegap.bitnagil.network.NoneAuth import com.threegap.bitnagil.network.token.ReissueService import dagger.Module import dagger.Provides @@ -66,4 +70,19 @@ object ServiceModule { @Singleton fun provideVersionService(@NoneAuth retrofit: Retrofit): VersionService = retrofit.create(VersionService::class.java) + + @Provides + @Singleton + fun provideAddressService(@Kakao retrofit: Retrofit): AddressService = + retrofit.create(AddressService::class.java) + + @Provides + @Singleton + fun provideFileService(@Auth retrofit: Retrofit): FileService = + retrofit.create(FileService::class.java) + + @Provides + @Singleton + fun provideReportService(@Auth retrofit: Retrofit): ReportService = + retrofit.create(ReportService::class.java) } diff --git a/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavHost.kt b/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavHost.kt index 7025bc7d..249a9aef 100644 --- a/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavHost.kt +++ b/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavHost.kt @@ -43,6 +43,7 @@ fun HomeNavHost( navigateToRegisterRoutine: (String?) -> Unit, navigateToEmotion: () -> Unit, navigateToRoutineList: (String) -> Unit, + navigateToReport: () -> Unit, ) { val navigator = rememberHomeNavigator() var showFloatingOverlay by remember { mutableStateOf(false) } @@ -106,6 +107,11 @@ fun HomeNavHost( BitnagilFloatingActionMenu( actions = listOf( + FloatingActionItem( + icon = R.drawable.ic_complaint, + text = "제보하기", + onClick = { navigateToReport() }, + ), FloatingActionItem( icon = R.drawable.ic_routine_add, text = "루틴 등록", diff --git a/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/atom/BitnagilFloatingButton.kt b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/atom/BitnagilFloatingButton.kt index ed883b23..391b246e 100644 --- a/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/atom/BitnagilFloatingButton.kt +++ b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/atom/BitnagilFloatingButton.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text @@ -101,7 +102,7 @@ fun BitnagilFloatingActionMenu( ), ) { Column( - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { actions.forEach { action -> @@ -173,6 +174,8 @@ private fun FloatingActionMenuItem( horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = modifier .scale(scale) + .padding(horizontal = 16.dp) + .width(112.dp) .clickableWithoutRipple { onClick() }, ) { BitnagilIcon( diff --git a/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/atom/BitnagilTextButton.kt b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/atom/BitnagilTextButton.kt index b6898558..9afed8d6 100644 --- a/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/atom/BitnagilTextButton.kt +++ b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/atom/BitnagilTextButton.kt @@ -95,13 +95,20 @@ data class BitnagilTextButtonColor( ) { companion object { @Composable - fun default(): BitnagilTextButtonColor = BitnagilTextButtonColor( - defaultBackgroundColor = BitnagilTheme.colors.coolGray10, - pressedBackgroundColor = BitnagilTheme.colors.coolGray5, - disabledBackgroundColor = BitnagilTheme.colors.coolGray96, - defaultTextColor = BitnagilTheme.colors.white, - pressedTextColor = BitnagilTheme.colors.coolGray20, - disabledTextColor = BitnagilTheme.colors.white, + fun default( + defaultBackgroundColor: Color = BitnagilTheme.colors.coolGray10, + pressedBackgroundColor: Color = BitnagilTheme.colors.coolGray5, + disabledBackgroundColor: Color = BitnagilTheme.colors.coolGray96, + defaultTextColor: Color = BitnagilTheme.colors.white, + pressedTextColor: Color = BitnagilTheme.colors.coolGray20, + disabledTextColor: Color = BitnagilTheme.colors.white, + ): BitnagilTextButtonColor = BitnagilTextButtonColor( + defaultBackgroundColor = defaultBackgroundColor, + pressedBackgroundColor = pressedBackgroundColor, + disabledBackgroundColor = disabledBackgroundColor, + defaultTextColor = defaultTextColor, + pressedTextColor = pressedTextColor, + disabledTextColor = disabledTextColor, ) @Composable diff --git a/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/atom/BitnagilTextField.kt b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/atom/BitnagilTextField.kt new file mode 100644 index 00000000..4417c1ac --- /dev/null +++ b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/atom/BitnagilTextField.kt @@ -0,0 +1,89 @@ +package com.threegap.bitnagil.designsystem.component.atom + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme + +@Composable +fun BitnagilTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + textStyle: TextStyle = BitnagilTheme.typography.body2Medium, + textColor: Color = BitnagilTheme.colors.coolGray10, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + visualTransformation: VisualTransformation = VisualTransformation.None, + onTextLayout: (TextLayoutResult) -> Unit = {}, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + placeholder: @Composable (() -> Unit)? = null, +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .fillMaxWidth() + .background( + color = BitnagilTheme.colors.coolGray99, + shape = RoundedCornerShape(12.dp), + ) + .padding(vertical = 16.dp, horizontal = 20.dp), + textStyle = textStyle.copy(color = textColor), + enabled = enabled, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + interactionSource = interactionSource, + ) { innerTextField -> + Box( + contentAlignment = Alignment.TopStart, + ) { + if (value.isEmpty() && placeholder != null) { + placeholder() + } + innerTextField() + } + } +} + +@Preview +@Composable +private fun Preview() { + BitnagilTextField( + value = "", + onValueChange = {}, + placeholder = { + Text( + text = "기타사항(직접 입력)", + color = BitnagilTheme.colors.coolGray80, + style = BitnagilTheme.typography.subtitle1Medium, + ) + }, + ) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/setting/component/block/LogoutConfirmDialog.kt b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/block/BitnagilAlertDialog.kt similarity index 81% rename from presentation/src/main/java/com/threegap/bitnagil/presentation/setting/component/block/LogoutConfirmDialog.kt rename to core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/block/BitnagilAlertDialog.kt index efe64301..af64bafe 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/setting/component/block/LogoutConfirmDialog.kt +++ b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/block/BitnagilAlertDialog.kt @@ -1,4 +1,4 @@ -package com.threegap.bitnagil.presentation.setting.component.block +package com.threegap.bitnagil.designsystem.component.block import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -22,7 +22,11 @@ import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextButtonColor @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LogoutConfirmDialog( +fun BitnagilAlertDialog( + title: String, + description: String, + dismissButtonText: String, + confirmButtonText: String, onDismiss: () -> Unit, onConfirm: () -> Unit, modifier: Modifier = Modifier, @@ -45,7 +49,7 @@ fun LogoutConfirmDialog( verticalArrangement = Arrangement.Center, ) { Text( - text = "로그아웃할까요?", + text = title, color = BitnagilTheme.colors.coolGray10, style = BitnagilTheme.typography.subtitle1SemiBold, ) @@ -53,7 +57,7 @@ fun LogoutConfirmDialog( Spacer(modifier = Modifier.height(6.dp)) Text( - text = "이 기기에서 계정이 로그아웃되고, 다시\n로그인해야 서비스를 계속 이용할 수 있어요.", + text = description, color = BitnagilTheme.colors.coolGray40, style = BitnagilTheme.typography.body2Medium, ) @@ -65,7 +69,7 @@ fun LogoutConfirmDialog( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { BitnagilTextButton( - text = "취소", + text = dismissButtonText, onClick = onDismiss, colors = BitnagilTextButtonColor.cancel(), textStyle = BitnagilTheme.typography.body2Medium, @@ -73,7 +77,7 @@ fun LogoutConfirmDialog( ) BitnagilTextButton( - text = "로그아웃", + text = confirmButtonText, onClick = onConfirm, textStyle = BitnagilTheme.typography.body2Medium, modifier = Modifier.weight(1f), @@ -85,8 +89,12 @@ fun LogoutConfirmDialog( @Preview @Composable -private fun LogoutConfirmDialogPreview() { - LogoutConfirmDialog( +private fun Preview() { + BitnagilAlertDialog( + title = "로그아웃 할까요?", + description = "이 기기에서 계정이 로그아웃되고, 다시\n로그인해야 서비스를 계속 이용할 수 있어요.", + dismissButtonText = "취소", + confirmButtonText = "로그아웃", onDismiss = {}, onConfirm = {}, ) diff --git a/core/designsystem/src/main/res/drawable-hdpi/img_pomo_loading.webp b/core/designsystem/src/main/res/drawable-hdpi/img_pomo_loading.webp new file mode 100644 index 00000000..7f81da5f Binary files /dev/null and b/core/designsystem/src/main/res/drawable-hdpi/img_pomo_loading.webp differ diff --git a/core/designsystem/src/main/res/drawable-mdpi/img_pomo_loading.webp b/core/designsystem/src/main/res/drawable-mdpi/img_pomo_loading.webp new file mode 100644 index 00000000..3ebfb84e Binary files /dev/null and b/core/designsystem/src/main/res/drawable-mdpi/img_pomo_loading.webp differ diff --git a/core/designsystem/src/main/res/drawable-xhdpi/img_pomo_loading.webp b/core/designsystem/src/main/res/drawable-xhdpi/img_pomo_loading.webp new file mode 100644 index 00000000..21d836bf Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xhdpi/img_pomo_loading.webp differ diff --git a/core/designsystem/src/main/res/drawable-xxhdpi/img_pomo_loading.webp b/core/designsystem/src/main/res/drawable-xxhdpi/img_pomo_loading.webp new file mode 100644 index 00000000..62063a17 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxhdpi/img_pomo_loading.webp differ diff --git a/core/designsystem/src/main/res/drawable-xxxhdpi/img_pomo_loading.webp b/core/designsystem/src/main/res/drawable-xxxhdpi/img_pomo_loading.webp new file mode 100644 index 00000000..c8c77c8e Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxxhdpi/img_pomo_loading.webp differ diff --git a/core/designsystem/src/main/res/drawable/ic_btn_close_sm.xml b/core/designsystem/src/main/res/drawable/ic_btn_close_sm.xml new file mode 100644 index 00000000..658dc4ee --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_btn_close_sm.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_camera.xml b/core/designsystem/src/main/res/drawable/ic_camera.xml new file mode 100644 index 00000000..a23169f7 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_camera_gray.xml b/core/designsystem/src/main/res/drawable/ic_camera_gray.xml new file mode 100644 index 00000000..1d968a9e --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_camera_gray.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_car.xml b/core/designsystem/src/main/res/drawable/ic_car.xml new file mode 100644 index 00000000..2c11c1a7 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_car.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_complaint.xml b/core/designsystem/src/main/res/drawable/ic_complaint.xml new file mode 100644 index 00000000..0a3c7adb --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_complaint.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_hammer.xml b/core/designsystem/src/main/res/drawable/ic_hammer.xml new file mode 100644 index 00000000..1120d1b2 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_hammer.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_light.xml b/core/designsystem/src/main/res/drawable/ic_light.xml new file mode 100644 index 00000000..17127aeb --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_light.xml @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_loading.xml b/core/designsystem/src/main/res/drawable/ic_loading.xml new file mode 100644 index 00000000..b42a852d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_loading.xml @@ -0,0 +1,40 @@ + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_location.xml b/core/designsystem/src/main/res/drawable/ic_location.xml new file mode 100644 index 00000000..71f8bf6a --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_location.xml @@ -0,0 +1,38 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_outside_gray.xml b/core/designsystem/src/main/res/drawable/ic_outside_gray.xml new file mode 100644 index 00000000..2fd18396 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_outside_gray.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_report.xml b/core/designsystem/src/main/res/drawable/ic_report.xml deleted file mode 100644 index aa86b3dc..00000000 --- a/core/designsystem/src/main/res/drawable/ic_report.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_water.xml b/core/designsystem/src/main/res/drawable/ic_water.xml new file mode 100644 index 00000000..5e6f2439 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_water.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_down_arrow_black.xml b/core/designsystem/src/main/res/drawable/icon_down_arrow_black.xml new file mode 100644 index 00000000..7d076be8 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_down_arrow_black.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 36852524..f3aa8160 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -13,4 +13,5 @@ dependencies { implementation(libs.bundles.retrofit) implementation(platform(libs.okhttp.bom)) implementation(libs.bundles.okhttp) + implementation(libs.javax.inject) } diff --git a/app/src/main/java/com/threegap/bitnagil/di/core/Qualifier.kt b/core/network/src/main/java/com/threegap/bitnagil/network/Qualifier.kt similarity index 61% rename from app/src/main/java/com/threegap/bitnagil/di/core/Qualifier.kt rename to core/network/src/main/java/com/threegap/bitnagil/network/Qualifier.kt index d01cd30a..5fd9294e 100644 --- a/app/src/main/java/com/threegap/bitnagil/di/core/Qualifier.kt +++ b/core/network/src/main/java/com/threegap/bitnagil/network/Qualifier.kt @@ -1,4 +1,4 @@ -package com.threegap.bitnagil.di.core +package com.threegap.bitnagil.network import javax.inject.Qualifier @@ -9,3 +9,7 @@ annotation class NoneAuth @Qualifier @Retention(AnnotationRetention.BINARY) annotation class Auth + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Kakao diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 7f7e00f7..fcee75af 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -18,4 +18,6 @@ dependencies { implementation(libs.bundles.okhttp) implementation(platform(libs.retrofit.bom)) implementation(libs.bundles.retrofit) + implementation(libs.play.services.location) + implementation(libs.kotlinx.coroutines.play) } diff --git a/data/src/main/java/com/threegap/bitnagil/data/address/datasource/AddressDataSource.kt b/data/src/main/java/com/threegap/bitnagil/data/address/datasource/AddressDataSource.kt new file mode 100644 index 00000000..6eab8826 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/address/datasource/AddressDataSource.kt @@ -0,0 +1,7 @@ +package com.threegap.bitnagil.data.address.datasource + +import com.threegap.bitnagil.data.address.model.response.Coord2AddressResponse + +interface AddressDataSource { + suspend fun fetchCurrentAddress(longitude: Double, latitude: Double): Result +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/address/datasource/LocationDataSource.kt b/data/src/main/java/com/threegap/bitnagil/data/address/datasource/LocationDataSource.kt new file mode 100644 index 00000000..e013ed57 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/address/datasource/LocationDataSource.kt @@ -0,0 +1,7 @@ +package com.threegap.bitnagil.data.address.datasource + +import com.threegap.bitnagil.domain.address.model.CurrentLocation + +interface LocationDataSource { + suspend fun fetchCurrentLocation(): Result +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/address/datasourceImpl/AddressDataSourceImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/address/datasourceImpl/AddressDataSourceImpl.kt new file mode 100644 index 00000000..d80e6ae0 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/address/datasourceImpl/AddressDataSourceImpl.kt @@ -0,0 +1,20 @@ +package com.threegap.bitnagil.data.address.datasourceImpl + +import com.threegap.bitnagil.data.address.datasource.AddressDataSource +import com.threegap.bitnagil.data.address.model.response.Coord2AddressResponse +import com.threegap.bitnagil.data.address.service.AddressService +import javax.inject.Inject + +class AddressDataSourceImpl @Inject constructor( + private val addressService: AddressService, +) : AddressDataSource { + + override suspend fun fetchCurrentAddress(longitude: Double, latitude: Double): Result { + return runCatching { + addressService.fetchCurrentAddress( + longitude = longitude.toString(), + latitude = latitude.toString(), + ) + } + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/address/datasourceImpl/LocationDataSourceImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/address/datasourceImpl/LocationDataSourceImpl.kt new file mode 100644 index 00000000..da74ec0a --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/address/datasourceImpl/LocationDataSourceImpl.kt @@ -0,0 +1,23 @@ +package com.threegap.bitnagil.data.address.datasourceImpl + +import android.annotation.SuppressLint +import com.google.android.gms.location.FusedLocationProviderClient +import com.threegap.bitnagil.data.address.datasource.LocationDataSource +import com.threegap.bitnagil.domain.address.model.CurrentLocation +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +class LocationDataSourceImpl @Inject constructor( + private val fusedLocationClient: FusedLocationProviderClient, +) : LocationDataSource { + + @SuppressLint("MissingPermission") + override suspend fun fetchCurrentLocation(): Result { + return runCatching { + val location = fusedLocationClient.lastLocation.await() + ?: throw Exception("Location not available") + + CurrentLocation(latitude = location.latitude, longitude = location.longitude) + } + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/address/di/LocationModule.kt b/data/src/main/java/com/threegap/bitnagil/data/address/di/LocationModule.kt new file mode 100644 index 00000000..c77e1975 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/address/di/LocationModule.kt @@ -0,0 +1,21 @@ +package com.threegap.bitnagil.data.address.di + +import android.content.Context +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +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 LocationModule { + + @Provides + @Singleton + fun provideFusedLocationProviderClient(@ApplicationContext context: Context): FusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(context) +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/address/model/response/Coord2AddressResponse.kt b/data/src/main/java/com/threegap/bitnagil/data/address/model/response/Coord2AddressResponse.kt new file mode 100644 index 00000000..5eb0bbe4 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/address/model/response/Coord2AddressResponse.kt @@ -0,0 +1,75 @@ +package com.threegap.bitnagil.data.address.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Coord2AddressResponse( + @SerialName("meta") + val meta: Meta, + @SerialName("documents") + val documents: List, +) + +@Serializable +data class Meta( + @SerialName("total_count") + val totalCount: Int, +) + +@Serializable +data class Document( + @SerialName("road_address") + val roadAddress: RoadAddress, + @SerialName("address") + val address: Address, +) + +@Serializable +data class Address( + @SerialName("address_name") + val addressName: String, + @SerialName("region_1depth_name") + val region1depthName: String, + @SerialName("region_2depth_name") + val region2depthName: String, + @SerialName("region_3depth_name") + val region3depthName: String, + @SerialName("mountain_yn") + val mountainYn: String, + @SerialName("main_address_no") + val mainAddressNo: String, + @SerialName("sub_address_no") + val subAddressNo: String, +) + +@Serializable +data class RoadAddress( + @SerialName("address_name") + val addressName: String, + @SerialName("region_1depth_name") + val region1depthName: String, + @SerialName("region_2depth_name") + val region2depthName: String, + @SerialName("region_3depth_name") + val region3depthName: String, + @SerialName("road_name") + val roadName: String, + @SerialName("underground_yn") + val undergroundYn: String, + @SerialName("main_building_no") + val mainBuildingNo: String, + @SerialName("sub_building_no") + val subBuildingNo: String, + @SerialName("building_name") + val buildingName: String, + @SerialName("zone_no") + val zoneNo: String, +) + +fun Coord2AddressResponse.toAddress(): String? { + return this.documents + .firstOrNull() + ?.roadAddress + ?.addressName +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/address/repositoryImpl/AddressRepositoryImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/address/repositoryImpl/AddressRepositoryImpl.kt new file mode 100644 index 00000000..7be79d21 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/address/repositoryImpl/AddressRepositoryImpl.kt @@ -0,0 +1,25 @@ +package com.threegap.bitnagil.data.address.repositoryImpl + +import com.threegap.bitnagil.data.address.datasource.AddressDataSource +import com.threegap.bitnagil.data.address.datasource.LocationDataSource +import com.threegap.bitnagil.data.address.model.response.toAddress +import com.threegap.bitnagil.domain.address.model.CurrentLocation +import com.threegap.bitnagil.domain.address.repository.AddressRepository +import javax.inject.Inject + +class AddressRepositoryImpl @Inject constructor( + private val locationDataSource: LocationDataSource, + private val addressDataSource: AddressDataSource, +) : AddressRepository { + + override suspend fun fetchCurrentLocation(): Result { + return locationDataSource.fetchCurrentLocation() + } + + override suspend fun fetchCurrentAddress(longitude: Double, latitude: Double): Result { + return addressDataSource.fetchCurrentAddress(longitude = longitude, latitude = latitude) + .mapCatching { + it.toAddress() ?: throw Exception("Address not found") + } + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/address/service/AddressService.kt b/data/src/main/java/com/threegap/bitnagil/data/address/service/AddressService.kt new file mode 100644 index 00000000..52dcc6e2 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/address/service/AddressService.kt @@ -0,0 +1,13 @@ +package com.threegap.bitnagil.data.address.service + +import com.threegap.bitnagil.data.address.model.response.Coord2AddressResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface AddressService { + @GET("/v2/local/geo/coord2address.json") + suspend fun fetchCurrentAddress( + @Query("x") longitude: String, + @Query("y") latitude: String, + ): Coord2AddressResponse +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/file/datasource/FileDataSource.kt b/data/src/main/java/com/threegap/bitnagil/data/file/datasource/FileDataSource.kt new file mode 100644 index 00000000..62bf670b --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/file/datasource/FileDataSource.kt @@ -0,0 +1,12 @@ +package com.threegap.bitnagil.data.file.datasource + +import com.threegap.bitnagil.data.file.model.request.FileInfoRequestDto + +interface FileDataSource { + /** + * presigned URL 요청 + * @param fileInfos 파일 정보 리스트 (prefix + fileName) + * @return key: S3 경로, value: presigned URL + */ + suspend fun fetchPresignedUrls(fileInfos: List): Result> +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/file/datasourceImpl/FileDataSourceImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/file/datasourceImpl/FileDataSourceImpl.kt new file mode 100644 index 00000000..b584d017 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/file/datasourceImpl/FileDataSourceImpl.kt @@ -0,0 +1,15 @@ +package com.threegap.bitnagil.data.file.datasourceImpl + +import com.threegap.bitnagil.data.common.safeApiCall +import com.threegap.bitnagil.data.file.datasource.FileDataSource +import com.threegap.bitnagil.data.file.model.request.FileInfoRequestDto +import com.threegap.bitnagil.data.file.service.FileService +import javax.inject.Inject + +class FileDataSourceImpl @Inject constructor( + private val fileService: FileService, +) : FileDataSource { + override suspend fun fetchPresignedUrls(fileInfos: List): Result> { + return safeApiCall { fileService.fetchPresignedUrls(fileInfos) } + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/file/model/request/FileInfoRequestDto.kt b/data/src/main/java/com/threegap/bitnagil/data/file/model/request/FileInfoRequestDto.kt new file mode 100644 index 00000000..abbc4049 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/file/model/request/FileInfoRequestDto.kt @@ -0,0 +1,12 @@ +package com.threegap.bitnagil.data.file.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FileInfoRequestDto( + @SerialName("prefix") + val prefix: String, + @SerialName("fileName") + val fileName: String, +) diff --git a/data/src/main/java/com/threegap/bitnagil/data/file/repositoryImpl/FileRepositoryImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/file/repositoryImpl/FileRepositoryImpl.kt new file mode 100644 index 00000000..dcfb0658 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/file/repositoryImpl/FileRepositoryImpl.kt @@ -0,0 +1,42 @@ +package com.threegap.bitnagil.data.file.repositoryImpl + +import com.threegap.bitnagil.data.file.datasource.FileDataSource +import com.threegap.bitnagil.data.file.model.request.FileInfoRequestDto +import com.threegap.bitnagil.data.file.uploader.ImageUploader +import com.threegap.bitnagil.domain.file.model.ImageFile +import com.threegap.bitnagil.domain.file.repository.FileRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import javax.inject.Inject + +class FileRepositoryImpl @Inject constructor( + private val fileDataSource: FileDataSource, + private val imageUploader: ImageUploader, +) : FileRepository { + override suspend fun uploadImages(imageFiles: List): Result> { + return runCatching { + val fileInfos = imageFiles.map { imageFile -> + FileInfoRequestDto(prefix = imageFile.prefix, fileName = imageFile.name) + } + + val presignedUrlMap: Map = fileDataSource + .fetchPresignedUrls(fileInfos) + .getOrThrow() + + val presignedUrls = presignedUrlMap.values.toList() + + coroutineScope { + imageFiles.zip(presignedUrls).map { (imageFile, presignedUrl) -> + async { + imageUploader + .uploadToPresignedUrl(imageFile, presignedUrl) + .getOrThrow() + } + }.awaitAll() + } + + presignedUrls.map { url -> url.substringBefore("?") } + } + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/file/service/FileService.kt b/data/src/main/java/com/threegap/bitnagil/data/file/service/FileService.kt new file mode 100644 index 00000000..240fe324 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/file/service/FileService.kt @@ -0,0 +1,13 @@ +package com.threegap.bitnagil.data.file.service + +import com.threegap.bitnagil.data.file.model.request.FileInfoRequestDto +import com.threegap.bitnagil.network.model.BaseResponse +import retrofit2.http.Body +import retrofit2.http.POST + +interface FileService { + @POST("/api/v2/files/presigned-urls") + suspend fun fetchPresignedUrls( + @Body fileInfos: List, + ): BaseResponse> +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/file/uploader/ImageUploader.kt b/data/src/main/java/com/threegap/bitnagil/data/file/uploader/ImageUploader.kt new file mode 100644 index 00000000..0a3a5486 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/file/uploader/ImageUploader.kt @@ -0,0 +1,40 @@ +package com.threegap.bitnagil.data.file.uploader + +import android.util.Log +import com.threegap.bitnagil.domain.file.model.ImageFile +import com.threegap.bitnagil.network.NoneAuth +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import javax.inject.Inject + +class ImageUploader @Inject constructor( + @NoneAuth private val okHttpClient: OkHttpClient, +) { + suspend fun uploadToPresignedUrl(imageFile: ImageFile, presignedUrl: String): Result { + return withContext(Dispatchers.IO) { + runCatching { + val requestBody = imageFile.bytes.toRequestBody( + contentType = imageFile.mimeType.toMediaTypeOrNull(), + ) + + val request = Request.Builder() + .url(presignedUrl) + .put(requestBody) + .build() + + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "No error details" + Log.e("ImageUploader", "Upload failed: ${imageFile.name}, code=${response.code}, error=$errorBody") + throw IOException("Upload failed: ${response.code}") + } + } + } + } + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/report/datasource/ReportDataSource.kt b/data/src/main/java/com/threegap/bitnagil/data/report/datasource/ReportDataSource.kt new file mode 100644 index 00000000..2c2170f7 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/report/datasource/ReportDataSource.kt @@ -0,0 +1,7 @@ +package com.threegap.bitnagil.data.report.datasource + +import com.threegap.bitnagil.data.report.model.request.ReportRequestDto + +interface ReportDataSource { + suspend fun submitReport(reportRequestDto: ReportRequestDto): Result +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/report/datasourceImpl/ReportDataSourceImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/report/datasourceImpl/ReportDataSourceImpl.kt new file mode 100644 index 00000000..14305d47 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/report/datasourceImpl/ReportDataSourceImpl.kt @@ -0,0 +1,15 @@ +package com.threegap.bitnagil.data.report.datasourceImpl + +import com.threegap.bitnagil.data.common.safeApiCall +import com.threegap.bitnagil.data.report.datasource.ReportDataSource +import com.threegap.bitnagil.data.report.model.request.ReportRequestDto +import com.threegap.bitnagil.data.report.service.ReportService +import javax.inject.Inject + +class ReportDataSourceImpl @Inject constructor( + private val reportService: ReportService, +) : ReportDataSource { + override suspend fun submitReport(reportRequestDto: ReportRequestDto): Result { + return safeApiCall { reportService.submitReport(reportRequestDto) } + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/report/model/request/ReportRequestDto.kt b/data/src/main/java/com/threegap/bitnagil/data/report/model/request/ReportRequestDto.kt new file mode 100644 index 00000000..3c82651f --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/report/model/request/ReportRequestDto.kt @@ -0,0 +1,36 @@ +package com.threegap.bitnagil.data.report.model.request + +import com.threegap.bitnagil.domain.report.model.Report +import com.threegap.bitnagil.domain.report.model.ReportCategory +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ReportRequestDto( + @SerialName("reportTitle") + val reportTitle: String, + @SerialName("reportContent") + val reportContent: String, + @SerialName("reportCategory") + val reportCategory: String, + @SerialName("reportImageUrls") + val reportImageUrls: List, + @SerialName("reportLocation") + val reportLocation: String, + @SerialName("latitude") + val latitude: Double, + @SerialName("longitude") + val longitude: Double, +) + +fun Report.toDto(): ReportRequestDto { + return ReportRequestDto( + reportTitle = this.title, + reportContent = this.content, + reportCategory = ReportCategory.toString(this.category), + reportImageUrls = this.imageUrls, + reportLocation = this.address, + latitude = this.latitude, + longitude = this.longitude, + ) +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/report/repositoryImpl/ReportRepositoryImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/report/repositoryImpl/ReportRepositoryImpl.kt new file mode 100644 index 00000000..f0261faf --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/report/repositoryImpl/ReportRepositoryImpl.kt @@ -0,0 +1,15 @@ +package com.threegap.bitnagil.data.report.repositoryImpl + +import com.threegap.bitnagil.data.report.datasource.ReportDataSource +import com.threegap.bitnagil.data.report.model.request.toDto +import com.threegap.bitnagil.domain.report.model.Report +import com.threegap.bitnagil.domain.report.repository.ReportRepository +import javax.inject.Inject + +class ReportRepositoryImpl @Inject constructor( + private val reportDataSource: ReportDataSource, +) : ReportRepository { + override suspend fun submitReport(report: Report): Result { + return reportDataSource.submitReport(report.toDto()) + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/report/service/ReportService.kt b/data/src/main/java/com/threegap/bitnagil/data/report/service/ReportService.kt new file mode 100644 index 00000000..27a697f0 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/report/service/ReportService.kt @@ -0,0 +1,13 @@ +package com.threegap.bitnagil.data.report.service + +import com.threegap.bitnagil.data.report.model.request.ReportRequestDto +import com.threegap.bitnagil.network.model.BaseResponse +import retrofit2.http.Body +import retrofit2.http.POST + +interface ReportService { + @POST("/api/v2/reports") + suspend fun submitReport( + @Body reportRequestDto: ReportRequestDto, + ): BaseResponse +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/address/model/CurrentAddress.kt b/domain/src/main/java/com/threegap/bitnagil/domain/address/model/CurrentAddress.kt new file mode 100644 index 00000000..fc710bc7 --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/address/model/CurrentAddress.kt @@ -0,0 +1,7 @@ +package com.threegap.bitnagil.domain.address.model + +data class CurrentAddress( + val latitude: Double, + val longitude: Double, + val roadAddress: String, +) diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/address/model/CurrentLocation.kt b/domain/src/main/java/com/threegap/bitnagil/domain/address/model/CurrentLocation.kt new file mode 100644 index 00000000..e7857b31 --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/address/model/CurrentLocation.kt @@ -0,0 +1,6 @@ +package com.threegap.bitnagil.domain.address.model + +data class CurrentLocation( + val latitude: Double, + val longitude: Double, +) diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/address/repository/AddressRepository.kt b/domain/src/main/java/com/threegap/bitnagil/domain/address/repository/AddressRepository.kt new file mode 100644 index 00000000..06de39da --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/address/repository/AddressRepository.kt @@ -0,0 +1,8 @@ +package com.threegap.bitnagil.domain.address.repository + +import com.threegap.bitnagil.domain.address.model.CurrentLocation + +interface AddressRepository { + suspend fun fetchCurrentLocation(): Result + suspend fun fetchCurrentAddress(longitude: Double, latitude: Double): Result +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/address/usecase/FetchCurrentAddressUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/address/usecase/FetchCurrentAddressUseCase.kt new file mode 100644 index 00000000..f23d2b81 --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/address/usecase/FetchCurrentAddressUseCase.kt @@ -0,0 +1,25 @@ +package com.threegap.bitnagil.domain.address.usecase + +import com.threegap.bitnagil.domain.address.model.CurrentAddress +import com.threegap.bitnagil.domain.address.repository.AddressRepository +import javax.inject.Inject + +class FetchCurrentAddressUseCase @Inject constructor( + private val addressRepository: AddressRepository, +) { + suspend operator fun invoke(): Result { + return addressRepository.fetchCurrentLocation() + .mapCatching { location -> + val roadAddress = addressRepository.fetchCurrentAddress( + longitude = location.longitude, + latitude = location.latitude, + ).getOrThrow() + + CurrentAddress( + latitude = location.latitude, + longitude = location.longitude, + roadAddress = roadAddress, + ) + } + } +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/file/model/ImageFile.kt b/domain/src/main/java/com/threegap/bitnagil/domain/file/model/ImageFile.kt new file mode 100644 index 00000000..a66ee74f --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/file/model/ImageFile.kt @@ -0,0 +1,38 @@ +package com.threegap.bitnagil.domain.file.model + +/** + * 업로드할 이미지 파일을 나타내는 도메인 모델 + * + * @property prefix S3 경로의 접두사 (예: "report", "profile") + * @property name 파일명 (예: "image_1234567890.jpg") + * @property mimeType MIME 타입 (예: "image/jpeg", "image/png") + * @property bytes 이미지 파일의 바이트 데이터 + */ +data class ImageFile( + val prefix: String, + val name: String, + val mimeType: String, + val bytes: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ImageFile + + if (prefix != other.prefix) return false + if (name != other.name) return false + if (mimeType != other.mimeType) return false + if (!bytes.contentEquals(other.bytes)) return false + + return true + } + + override fun hashCode(): Int { + var result = prefix.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + mimeType.hashCode() + result = 31 * result + bytes.contentHashCode() + return result + } +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/file/repository/FileRepository.kt b/domain/src/main/java/com/threegap/bitnagil/domain/file/repository/FileRepository.kt new file mode 100644 index 00000000..c2094f3e --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/file/repository/FileRepository.kt @@ -0,0 +1,7 @@ +package com.threegap.bitnagil.domain.file.repository + +import com.threegap.bitnagil.domain.file.model.ImageFile + +interface FileRepository { + suspend fun uploadImages(imageFiles: List): Result> +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/file/usecase/UploadReportImagesUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/file/usecase/UploadReportImagesUseCase.kt new file mode 100644 index 00000000..b792aa80 --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/file/usecase/UploadReportImagesUseCase.kt @@ -0,0 +1,13 @@ +package com.threegap.bitnagil.domain.file.usecase + +import com.threegap.bitnagil.domain.file.model.ImageFile +import com.threegap.bitnagil.domain.file.repository.FileRepository +import javax.inject.Inject + +class UploadReportImagesUseCase @Inject constructor( + private val fileRepository: FileRepository, +) { + suspend operator fun invoke(imageFiles: List): Result> { + return fileRepository.uploadImages(imageFiles) + } +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/report/model/Report.kt b/domain/src/main/java/com/threegap/bitnagil/domain/report/model/Report.kt new file mode 100644 index 00000000..881025cf --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/report/model/Report.kt @@ -0,0 +1,11 @@ +package com.threegap.bitnagil.domain.report.model + +data class Report( + val title: String, + val content: String, + val category: ReportCategory, + val imageUrls: List, + val address: String, + val latitude: Double, + val longitude: Double, +) diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/report/model/ReportCategory.kt b/domain/src/main/java/com/threegap/bitnagil/domain/report/model/ReportCategory.kt new file mode 100644 index 00000000..51315b3c --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/report/model/ReportCategory.kt @@ -0,0 +1,30 @@ +package com.threegap.bitnagil.domain.report.model + +enum class ReportCategory { + TRANSPORTATION, + LIGHTING, + WATERFACILITY, + AMENITY, + ; + + companion object { + fun fromString(value: String): ReportCategory { + return when (value) { + "TRANSPORTATION" -> TRANSPORTATION + "LIGHTING" -> LIGHTING + "WATERFACILITY" -> WATERFACILITY + "AMENITY" -> AMENITY + else -> throw IllegalArgumentException("Invalid ReportCategory value: $value") + } + } + + fun toString(value: ReportCategory): String { + return when (value) { + TRANSPORTATION -> "TRANSPORTATION" + LIGHTING -> "LIGHTING" + WATERFACILITY -> "WATERFACILITY" + AMENITY -> "AMENITY" + } + } + } +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/report/repository/ReportRepository.kt b/domain/src/main/java/com/threegap/bitnagil/domain/report/repository/ReportRepository.kt new file mode 100644 index 00000000..b6cd018a --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/report/repository/ReportRepository.kt @@ -0,0 +1,7 @@ +package com.threegap.bitnagil.domain.report.repository + +import com.threegap.bitnagil.domain.report.model.Report + +interface ReportRepository { + suspend fun submitReport(report: Report): Result +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/report/usecase/SubmitReportUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/report/usecase/SubmitReportUseCase.kt new file mode 100644 index 00000000..1d0de378 --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/report/usecase/SubmitReportUseCase.kt @@ -0,0 +1,13 @@ +package com.threegap.bitnagil.domain.report.usecase + +import com.threegap.bitnagil.domain.report.model.Report +import com.threegap.bitnagil.domain.report.repository.ReportRepository +import javax.inject.Inject + +class SubmitReportUseCase @Inject constructor( + private val reportRepository: ReportRepository, +) { + suspend operator fun invoke(report: Report): Result { + return reportRepository.submitReport(report) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c094ec84..4384ec3b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,8 @@ javax = "1" kakaoLogin = "2.21.4" lottie-compose = "6.6.0" coil = "3.3.0" +accompanist = "0.37.3" +playServicesLocation = "21.3.0" [libraries] ## Android Gradle Plugin @@ -89,6 +91,7 @@ compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4 ## Kotlin kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-play = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } ## Hilt @@ -127,6 +130,10 @@ material = { group = "com.google.android.material", name = "material", version.r kakao-v2-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakaoLogin" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie-compose" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } + + [bundles] androidx-core = [ "androidx-core-ktx", diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 6f8d0b1b..2e5a2663 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.lottie.compose) implementation(libs.bundles.coil) + implementation(libs.accompanist.permissions) testImplementation(libs.junit) testImplementation(libs.kotlin.coroutines.test) diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/common/file/ImageFileConverter.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/file/ImageFileConverter.kt new file mode 100644 index 00000000..f2748f81 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/file/ImageFileConverter.kt @@ -0,0 +1,64 @@ +package com.threegap.bitnagil.presentation.common.file + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import com.threegap.bitnagil.domain.file.model.ImageFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID + +fun extractFileName(uri: Uri, contentResolver: ContentResolver): String { + contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + val name = cursor.getString(0) + if (!name.isNullOrBlank()) return name + } + } + + val extension = MimeTypeMap.getSingleton() + .getExtensionFromMimeType(contentResolver.getType(uri)) ?: "jpg" + + val uniqueId = UUID.randomUUID().toString().take(8) + return "image_${System.currentTimeMillis()}_$uniqueId.$extension" +} + +suspend fun convertUriToImageFile( + uri: Uri, + prefix: String, + context: Context, +): ImageFile? = withContext(Dispatchers.IO) { + try { + val contentResolver = context.contentResolver + val fileName = extractFileName(uri, contentResolver) + val mimeType = contentResolver.getType(uri) ?: "image/jpeg" + + val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: return@withContext null + + ImageFile( + prefix = prefix, + name = fileName, + mimeType = mimeType, + bytes = bytes, + ) + } catch (e: Exception) { + null + } +} + +fun Context.createCameraImageUri(): Uri { + val cameraDir = File(cacheDir, "camera").apply { if (!exists()) mkdirs() } + val imageFile = File(cameraDir, "camera_${System.currentTimeMillis()}.jpg") + return FileProvider.getUriForFile(this, "$packageName.provider", imageFile) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/common/premission/PermissionHandler.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/premission/PermissionHandler.kt new file mode 100644 index 00000000..55d12d56 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/premission/PermissionHandler.kt @@ -0,0 +1,14 @@ +package com.threegap.bitnagil.presentation.common.premission + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable + +@Stable +interface PermissionHandler { + val isGranted: Boolean + + fun requestPermission() + + @Composable + fun PermissionDialogs() +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/common/premission/RememberPermissionHandler.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/premission/RememberPermissionHandler.kt new file mode 100644 index 00000000..23047bd6 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/premission/RememberPermissionHandler.kt @@ -0,0 +1,92 @@ +package com.threegap.bitnagil.presentation.common.premission + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import com.threegap.bitnagil.designsystem.component.block.BitnagilAlertDialog + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun rememberPermissionHandler( + permission: String, + dialogDescription: String, + onGranted: () -> Unit, +): PermissionHandler { + val context = LocalContext.current + var showSettingsDialog by remember { mutableStateOf(false) } + var permissionRequestCount by rememberSaveable { mutableIntStateOf(0) } + + val permissionState = rememberPermissionState( + permission = permission, + onPermissionResult = { isGranted -> + if (isGranted) { + permissionRequestCount = 0 + onGranted() + } else { + permissionRequestCount++ + } + }, + ) + + val handler = remember(context) { + object : PermissionHandler { + override val isGranted: Boolean + get() = permissionState.status.isGranted + + override fun requestPermission() { + when { + permissionState.status.isGranted -> { + onGranted() + } + + permissionState.status.shouldShowRationale -> { + permissionState.launchPermissionRequest() + } + + else -> { + if (permissionRequestCount == 0) { + permissionState.launchPermissionRequest() + } else { + showSettingsDialog = true + } + } + } + } + + @Composable + override fun PermissionDialogs() { + if (showSettingsDialog) { + BitnagilAlertDialog( + title = "권한 설정이 필요합니다.", + description = dialogDescription, + dismissButtonText = "닫기", + confirmButtonText = "설정으로 이동", + onConfirm = { + showSettingsDialog = false + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ) + context.startActivity(intent) + }, + onDismiss = { showSettingsDialog = false }, + ) + } + } + } + } + + return handler +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/ReportScreen.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/ReportScreen.kt new file mode 100644 index 00000000..cec60de3 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/ReportScreen.kt @@ -0,0 +1,366 @@ +package com.threegap.bitnagil.presentation.report + +import android.Manifest +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextButton +import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextButtonColor +import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextField +import com.threegap.bitnagil.designsystem.component.block.BitnagilTopBar +import com.threegap.bitnagil.presentation.common.file.createCameraImageUri +import com.threegap.bitnagil.presentation.common.premission.rememberPermissionHandler +import com.threegap.bitnagil.presentation.report.component.AddPhotoButton +import com.threegap.bitnagil.presentation.report.component.CurrentLocationInput +import com.threegap.bitnagil.presentation.report.component.ImageSourceBottomSheet +import com.threegap.bitnagil.presentation.report.component.PhotoItem +import com.threegap.bitnagil.presentation.report.component.ReportCategoryBottomSheet +import com.threegap.bitnagil.presentation.report.component.ReportCategorySelector +import com.threegap.bitnagil.presentation.report.component.ReportField +import com.threegap.bitnagil.presentation.report.component.template.CompleteReportContent +import com.threegap.bitnagil.presentation.report.component.template.SubmittingReportContent +import com.threegap.bitnagil.presentation.report.model.ReportSideEffect +import com.threegap.bitnagil.presentation.report.model.ReportState +import com.threegap.bitnagil.presentation.report.model.ReportState.Companion.MAX_IMAGE_COUNT +import com.threegap.bitnagil.presentation.report.model.SubmitState +import com.threegap.bitnagil.presentation.report.model.uiTitle +import kotlinx.coroutines.delay +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@Composable +fun ReportScreenContainer( + navigateToBack: () -> Unit, + viewModel: ReportViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val uiState by viewModel.collectAsState() + val contentFocusRequester = remember { FocusRequester() } + + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is ReportSideEffect.NavigateToBack -> navigateToBack() + is ReportSideEffect.FocusOnContent -> { + delay(100) + contentFocusRequester.requestFocus() + } + } + } + + var pendingCameraPhotoUri by remember { mutableStateOf(null) } + + val pickMultipleMediaLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(MAX_IMAGE_COUNT), + onResult = viewModel::addImages, + ) + + val takePictureLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture(), + onResult = { success -> + if (success) { + pendingCameraPhotoUri?.let { uri -> + viewModel.addImages(listOf(uri)) + } + } + pendingCameraPhotoUri = null + }, + ) + + val cameraPermissionHandler = rememberPermissionHandler( + permission = Manifest.permission.CAMERA, + dialogDescription = "카메라 권한이 비활성화됐어요.\n설정에서 허용해 주세요.", + onGranted = { + val imageUri = context.createCameraImageUri() + pendingCameraPhotoUri = imageUri + takePictureLauncher.launch(imageUri) + }, + ) + + val locationPermissionHandler = rememberPermissionHandler( + permission = Manifest.permission.ACCESS_FINE_LOCATION, + dialogDescription = "위치 권한이 비활성화됐어요.\n설정에서 허용해 주세요.", + onGranted = viewModel::fetchCurrentAddress, + ) + + cameraPermissionHandler.PermissionDialogs() + locationPermissionHandler.PermissionDialogs() + + if (uiState.imageSourceBottomSheetVisible) { + ImageSourceBottomSheet( + onCameraClick = { + if (uiState.canAddMoreImages) cameraPermissionHandler.requestPermission() + }, + onAlbumClick = { + if (uiState.canAddMoreImages) { + pickMultipleMediaLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + } + }, + onDismiss = viewModel::hideImageSourceBottomSheet, + ) + } + + if (uiState.reportCategoryBottomSheetVisible) { + ReportCategoryBottomSheet( + selectedCategory = uiState.selectedCategory, + onSelected = viewModel::selectReportCategory, + onDismiss = viewModel::hideReportCategoryBottomSheet, + ) + } + + AnimatedContent( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + targetState = uiState.submitState, + label = "ReportSlideAnimation", + transitionSpec = { + ( + slideIntoContainer( + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + towards = AnimatedContentTransitionScope.SlideDirection.Start, + ) togetherWith slideOutOfContainer( + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + towards = AnimatedContentTransitionScope.SlideDirection.Start, + ) + ) + .using(SizeTransform(clip = true)) + }, + ) { submitState -> + when (submitState) { + SubmitState.IDLE -> { + ReportScreen( + uiState = uiState, + contentFocusRequester = contentFocusRequester, + onReportTitleChange = viewModel::updateReportTitle, + onReportContentChange = viewModel::updateReportContent, + onShowImageSourceBottomSheet = viewModel::showImageSourceBottomSheet, + onShowReportCategoryBottomSheet = viewModel::showReportCategoryBottomSheet, + onRemoveImage = viewModel::removeImage, + onGetCurrentLocationClick = locationPermissionHandler::requestPermission, + onSubmitClick = viewModel::submitReportWithImages, + onBackClick = viewModel::navigateToBack, + ) + } + SubmitState.SUBMITTING -> SubmittingReportContent() + SubmitState.COMPLETE -> { + CompleteReportContent( + uiState = uiState, + onConfirmClick = viewModel::navigateToBack, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReportScreen( + uiState: ReportState, + contentFocusRequester: FocusRequester, + onReportTitleChange: (String) -> Unit, + onReportContentChange: (String) -> Unit, + onShowImageSourceBottomSheet: () -> Unit, + onShowReportCategoryBottomSheet: () -> Unit, + onRemoveImage: (Uri) -> Unit, + onGetCurrentLocationClick: () -> Unit, + onSubmitClick: () -> Unit, + onBackClick: () -> Unit, +) { + val scrollState = rememberScrollState() + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.ime), + ) { + BitnagilTopBar( + title = "제보하기", + showBackButton = true, + onBackClick = onBackClick, + ) + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(state = scrollState) + .padding(top = 32.dp) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(28.dp), + ) { + ReportField(title = "사진 첨부") { + Row( + verticalAlignment = Alignment.Bottom, + ) { + AddPhotoButton( + onClick = onShowImageSourceBottomSheet, + imageCount = uiState.reportImages.size, + maxImageCount = MAX_IMAGE_COUNT, + ) + + LazyRow( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = PaddingValues(horizontal = 14.dp), + ) { + items(uiState.reportImages) { uri -> + PhotoItem( + uri = uri, + onRemove = onRemoveImage, + ) + } + } + } + } + + ReportField(title = "제목") { + BitnagilTextField( + value = uiState.reportTitle, + onValueChange = onReportTitleChange, + singleLine = true, + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + onShowReportCategoryBottomSheet() + }, + ), + placeholder = { + Text( + text = "제보 제목을 작성해주세요.", + style = BitnagilTheme.typography.body2Medium, + color = BitnagilTheme.colors.coolGray80, + ) + }, + ) + } + + ReportField(title = "카테고리") { + ReportCategorySelector( + title = uiState.selectedCategory?.uiTitle, + onClick = { + focusManager.clearFocus() + onShowReportCategoryBottomSheet() + }, + ) + } + + ReportField(title = "상세 제보 내용") { + BitnagilTextField( + value = uiState.reportContent, + onValueChange = onReportContentChange, + modifier = Modifier + .height(88.dp) + .focusRequester(contentFocusRequester), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + }, + ), + placeholder = { + Text( + text = "어떤 위험인지 간단히 설명해주세요.(100자 내외)", + style = BitnagilTheme.typography.body2Medium, + color = BitnagilTheme.colors.coolGray80, + ) + }, + ) + + Text( + text = "${uiState.reportContent.length} / 150", + style = BitnagilTheme.typography.caption1Medium, + color = BitnagilTheme.colors.coolGray80, + textAlign = TextAlign.End, + modifier = Modifier.fillMaxWidth(), + ) + } + + ReportField(title = "신고 위치") { + CurrentLocationInput( + currentLocation = uiState.currentAddress, + onClick = onGetCurrentLocationClick, + ) + } + } + + BitnagilTextButton( + text = "제보하기", + onClick = onSubmitClick, + colors = BitnagilTextButtonColor.default( + disabledBackgroundColor = BitnagilTheme.colors.coolGray98, + disabledTextColor = BitnagilTheme.colors.coolGray90, + ), + enabled = uiState.isSubmittable, + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + ReportScreen( + uiState = ReportState.Init, + contentFocusRequester = remember { FocusRequester() }, + onReportTitleChange = {}, + onReportContentChange = {}, + onRemoveImage = {}, + onShowImageSourceBottomSheet = {}, + onShowReportCategoryBottomSheet = {}, + onGetCurrentLocationClick = {}, + onSubmitClick = {}, + onBackClick = {}, + ) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/ReportViewModel.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/ReportViewModel.kt new file mode 100644 index 00000000..761dff11 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/ReportViewModel.kt @@ -0,0 +1,184 @@ +package com.threegap.bitnagil.presentation.report + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import com.threegap.bitnagil.domain.address.usecase.FetchCurrentAddressUseCase +import com.threegap.bitnagil.domain.file.usecase.UploadReportImagesUseCase +import com.threegap.bitnagil.domain.report.model.Report +import com.threegap.bitnagil.domain.report.model.ReportCategory +import com.threegap.bitnagil.domain.report.usecase.SubmitReportUseCase +import com.threegap.bitnagil.presentation.common.file.convertUriToImageFile +import com.threegap.bitnagil.presentation.report.model.ReportSideEffect +import com.threegap.bitnagil.presentation.report.model.ReportState +import com.threegap.bitnagil.presentation.report.model.ReportState.Companion.MAX_IMAGE_COUNT +import com.threegap.bitnagil.presentation.report.model.SubmitState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +class ReportViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val fetchCurrentAddressUseCase: FetchCurrentAddressUseCase, + private val uploadReportImagesUseCase: UploadReportImagesUseCase, + private val submitReportUseCase: SubmitReportUseCase, +) : ViewModel(), ContainerHost { + + override val container: Container = container(initialState = ReportState.Init) + + fun updateReportTitle(title: String) { + intent { + reduce { state.copy(reportTitle = title) } + } + } + + fun updateReportContent(content: String) { + intent { + reduce { state.copy(reportContent = content) } + } + } + + fun showImageSourceBottomSheet() { + intent { + reduce { state.copy(imageSourceBottomSheetVisible = true) } + } + } + + fun hideImageSourceBottomSheet() { + intent { + reduce { state.copy(imageSourceBottomSheetVisible = false) } + } + } + + fun showReportCategoryBottomSheet() { + intent { + reduce { state.copy(reportCategoryBottomSheetVisible = true) } + } + } + + fun hideReportCategoryBottomSheet() { + intent { + reduce { state.copy(reportCategoryBottomSheetVisible = false) } + postSideEffect(ReportSideEffect.FocusOnContent) + } + } + + fun selectReportCategory(category: ReportCategory) { + intent { + reduce { state.copy(selectedCategory = category) } + } + } + + fun fetchCurrentAddress() { + intent { + fetchCurrentAddressUseCase().fold( + onSuccess = { + reduce { + state.copy( + currentLatitude = it.latitude, + currentLongitude = it.longitude, + currentAddress = it.roadAddress, + ) + } + }, + onFailure = {}, + ) + } + } + + fun addImages(uris: List) { + intent { + val currentImages = state.reportImages + val availableSlots = MAX_IMAGE_COUNT - currentImages.size + + if (availableSlots > 0) { + val imagesToAdd = uris.take(availableSlots) + reduce { state.copy(reportImages = currentImages + imagesToAdd) } + } + } + } + + fun removeImage(uri: Uri) { + intent { + reduce { state.copy(reportImages = state.reportImages - uri) } + } + } + + fun navigateToBack() { + intent { + postSideEffect(ReportSideEffect.NavigateToBack) + } + } + + fun submitReportWithImages() { + intent { + val category = state.selectedCategory ?: return@intent + val address = state.currentAddress ?: return@intent + val latitude = state.currentLatitude ?: return@intent + val longitude = state.currentLongitude ?: return@intent + + reduce { state.copy(submitState = SubmitState.SUBMITTING) } + + coroutineScope { + val minDelayJob = async { + delay(1000L) + } + + val processingJob = async { + val imageFiles = state.reportImages + .map { uri -> + async { convertUriToImageFile(uri, "report", context) } + } + .awaitAll() + .filterNotNull() + + if (imageFiles.size < state.reportImages.size) { + return@async Result.failure(Exception("Image conversion failed")) + } + + val uploadedPaths = uploadReportImagesUseCase(imageFiles) + .getOrElse { return@async Result.failure(it) } + + val submitResult = submitReportUseCase( + Report( + title = state.reportTitle, + content = state.reportContent, + category = category, + address = address, + latitude = latitude, + longitude = longitude, + imageUrls = uploadedPaths, + ), + ) + + submitResult.map { uploadedPaths } + } + + minDelayJob.await() + val result = processingJob.await() + + result.fold( + onSuccess = { paths -> + reduce { + state.copy( + submitState = SubmitState.COMPLETE, + uploadedImagePaths = paths, + ) + } + }, + onFailure = { error -> + reduce { state.copy(submitState = SubmitState.IDLE) } + }, + ) + } + } + } +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/AddPhotoButton.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/AddPhotoButton.kt new file mode 100644 index 00000000..7636f45e --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/AddPhotoButton.kt @@ -0,0 +1,72 @@ +package com.threegap.bitnagil.presentation.report.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.R +import com.threegap.bitnagil.designsystem.component.atom.BitnagilIcon +import com.threegap.bitnagil.designsystem.modifier.clickableWithoutRipple + +@Composable +fun AddPhotoButton( + onClick: () -> Unit, + imageCount: Int, + maxImageCount: Int, + modifier: Modifier = Modifier, +) { + val enabled = imageCount < maxImageCount + val imageCountColor = + if (imageCount == 0) BitnagilTheme.colors.coolGray70 else BitnagilTheme.colors.black + + Column( + modifier = modifier + .background( + color = BitnagilTheme.colors.coolGray99, + shape = RoundedCornerShape(8.dp), + ) + .size(64.dp) + .clickableWithoutRipple( + onClick = onClick, + enabled = enabled, + ), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BitnagilIcon( + id = R.drawable.ic_camera, + tint = null, + ) + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(color = imageCountColor)) { + append("$imageCount") + } + append("/$maxImageCount") + }, + color = BitnagilTheme.colors.coolGray70, + style = BitnagilTheme.typography.caption1Medium, + ) + } +} + +@Preview +@Composable +private fun CameraButtonPreview() { + AddPhotoButton( + onClick = {}, + imageCount = 0, + maxImageCount = 3, + ) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/CompleteReportCard.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/CompleteReportCard.kt new file mode 100644 index 00000000..8b29abfc --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/CompleteReportCard.kt @@ -0,0 +1,149 @@ +package com.threegap.bitnagil.presentation.report.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.domain.report.model.ReportCategory +import com.threegap.bitnagil.presentation.report.model.uiTitle + +@Composable +fun CompleteReportCard( + title: String, + category: ReportCategory?, + address: String?, + content: String, + images: List, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + Column( + modifier = modifier + .background( + color = BitnagilTheme.colors.coolGray98, + shape = RoundedCornerShape(12.dp), + ) + .padding(20.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = "제보 요약정보", + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.body2SemiBold, + ) + + CompleteReportCardItem( + title = "제목", + ) { + Text( + text = title, + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.body1Medium, + ) + } + + CompleteReportCardItem( + title = "카테고리", + ) { + Text( + text = category?.uiTitle ?: "카테고리 없음", + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.body1Medium, + ) + } + + CompleteReportCardItem( + title = "신고 위치", + ) { + Text( + text = address ?: "주소 없음", + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.body1Medium, + ) + } + + CompleteReportCardItem( + title = "제보 내용", + ) { + Text( + text = content, + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.body1Medium, + textAlign = TextAlign.End, + ) + } + + CompleteReportCardItem( + title = "제보 사진", + ) { + LazyRow( + horizontalArrangement = Arrangement.End, + ) { + items(images) { image -> + AsyncImage( + model = image, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(start = 12.dp) + .size(48.dp) + .clip(RoundedCornerShape(8.dp)), + ) + } + } + } + } +} + +@Composable +private fun CompleteReportCardItem( + title: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + color = BitnagilTheme.colors.coolGray30, + style = BitnagilTheme.typography.body1Medium, + modifier = Modifier.padding(end = 12.dp), + ) + content() + } +} + +@Preview +@Composable +private fun Preview() { + CompleteReportCard( + title = "어쩌구", + category = ReportCategory.WATERFACILITY, + address = "서울특별시 강남구 역삼동", + content = "어쩌구 저쩌구", + images = listOf(), + ) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/CurrentLocationInput.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/CurrentLocationInput.kt new file mode 100644 index 00000000..4018576f --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/CurrentLocationInput.kt @@ -0,0 +1,77 @@ +package com.threegap.bitnagil.presentation.report.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.R +import com.threegap.bitnagil.designsystem.component.atom.BitnagilIcon +import com.threegap.bitnagil.designsystem.modifier.clickableWithoutRipple + +@Composable +fun CurrentLocationInput( + currentLocation: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.height(52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = currentLocation ?: "현재 위치 검색", + color = if (currentLocation == null) BitnagilTheme.colors.coolGray80 else BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.body2Medium, + modifier = Modifier + .background( + color = BitnagilTheme.colors.coolGray99, + shape = RoundedCornerShape(14.dp), + ) + .fillMaxHeight() + .padding(vertical = 16.dp, horizontal = 20.dp) + .weight(1f), + ) + + Box( + modifier = modifier + .background( + color = BitnagilTheme.colors.orange500, + shape = RoundedCornerShape(12.dp), + ) + .fillMaxHeight() + .padding(14.dp) + .clickableWithoutRipple( + onClick = { onClick() }, + ), + contentAlignment = Alignment.Center, + ) { + BitnagilIcon( + id = R.drawable.ic_location, + tint = null, + modifier = Modifier.size(24.dp), + ) + } + } +} + +@Preview +@Composable +private fun Preview() { + CurrentLocationInput( + currentLocation = null, + onClick = {}, + ) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ImageSourceBottomSheet.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ImageSourceBottomSheet.kt new file mode 100644 index 00000000..52229094 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ImageSourceBottomSheet.kt @@ -0,0 +1,120 @@ +package com.threegap.bitnagil.presentation.report.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.R +import com.threegap.bitnagil.designsystem.component.atom.BitnagilIcon +import com.threegap.bitnagil.designsystem.modifier.clickableWithoutRipple +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImageSourceBottomSheet( + onCameraClick: () -> Unit, + onAlbumClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + + fun hideSheetAndExecute(action: () -> Unit) { + coroutineScope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) { + onDismiss() + action() + } + } + } + + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismiss, + containerColor = BitnagilTheme.colors.white, + contentColor = BitnagilTheme.colors.white, + modifier = modifier, + ) { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) { + ImageSourceOptionItem( + iconId = R.drawable.ic_camera_gray, + description = "카메라로 촬영하기", + modifier = Modifier.clickableWithoutRipple( + onClick = { hideSheetAndExecute(onCameraClick) }, + ), + ) + + HorizontalDivider( + color = BitnagilTheme.colors.coolGray97, + modifier = Modifier.padding(vertical = 8.dp), + ) + + ImageSourceOptionItem( + iconId = R.drawable.ic_outside_gray, + description = "앨범에서 선택하기", + modifier = Modifier.clickableWithoutRipple( + onClick = { hideSheetAndExecute(onAlbumClick) }, + ), + ) + } + } +} + +@Composable +private fun ImageSourceOptionItem( + @DrawableRes iconId: Int, + description: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + BitnagilIcon(id = iconId, tint = null) + + Text( + text = description, + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.body1Medium, + ) + } +} + +@Preview +@Composable +private fun ImageSourceBottomSheetPreview() { + ImageSourceBottomSheet( + onCameraClick = {}, + onAlbumClick = {}, + onDismiss = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + ImageSourceOptionItem( + iconId = R.drawable.ic_camera_gray, + description = "카메라로 촬영하기", + ) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/PhotoItem.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/PhotoItem.kt new file mode 100644 index 00000000..44b0f581 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/PhotoItem.kt @@ -0,0 +1,61 @@ +package com.threegap.bitnagil.presentation.report.component + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import coil3.compose.AsyncImage +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.R +import com.threegap.bitnagil.designsystem.component.atom.BitnagilIconButton + +@Composable +fun PhotoItem( + uri: Uri, + onRemove: (Uri) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.size(64.dp), + ) { + AsyncImage( + model = uri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + ) + + BitnagilIconButton( + id = R.drawable.ic_btn_close_sm, + tint = null, + onClick = { onRemove(uri) }, + modifier = Modifier + .size(24.dp) + .align(Alignment.TopEnd) + .offset(x = 10.dp, y = (-10).dp), + ) + } +} + +@Preview +@Composable +private fun Preview() { + PhotoItem( + uri = "".toUri(), + modifier = Modifier.background(color = BitnagilTheme.colors.orange50), + onRemove = {}, + ) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ReportCategoryBottomSheet.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ReportCategoryBottomSheet.kt new file mode 100644 index 00000000..8a4ea0d1 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ReportCategoryBottomSheet.kt @@ -0,0 +1,146 @@ +package com.threegap.bitnagil.presentation.report.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.R +import com.threegap.bitnagil.designsystem.component.atom.BitnagilIcon +import com.threegap.bitnagil.designsystem.modifier.clickableWithoutRipple +import com.threegap.bitnagil.domain.report.model.ReportCategory +import com.threegap.bitnagil.presentation.report.model.iconRes +import com.threegap.bitnagil.presentation.report.model.uiDescription +import com.threegap.bitnagil.presentation.report.model.uiTitle +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportCategoryBottomSheet( + selectedCategory: ReportCategory?, + onDismiss: () -> Unit, + onSelected: (ReportCategory) -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismiss, + containerColor = BitnagilTheme.colors.white, + contentColor = BitnagilTheme.colors.white, + modifier = modifier, + ) { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 28.dp), + ) { + val categories = ReportCategory.entries + categories.forEachIndexed { index, category -> + ReportCategoryItem( + icon = category.iconRes, + title = category.uiTitle, + description = category.uiDescription, + isSelected = selectedCategory == category, + onClick = { + onSelected(category) + coroutineScope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) onDismiss() + } + }, + ) + + if (index < categories.lastIndex) { + HorizontalDivider( + color = BitnagilTheme.colors.coolGray97, + ) + } + } + } + } +} + +@Composable +private fun ReportCategoryItem( + title: String, + description: String, + @DrawableRes icon: Int, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .clickableWithoutRipple { onClick() } + .padding(vertical = 14.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + BitnagilIcon(id = icon, tint = null) + + Spacer(modifier = Modifier.width(14.dp)) + + Column { + Text( + text = title, + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.body1Medium, + ) + + Text( + text = description, + color = BitnagilTheme.colors.coolGray70, + style = BitnagilTheme.typography.body2Medium, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + if (isSelected) { + BitnagilIcon( + id = R.drawable.ic_check_md, + tint = BitnagilTheme.colors.orange500, + ) + } + } +} + +@Preview +@Composable +private fun ReportCategoryBottomSheetPreview() { + ReportCategoryBottomSheet( + selectedCategory = ReportCategory.WATERFACILITY, + onDismiss = {}, + onSelected = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun ReportCategoryItemPreview() { + ReportCategoryItem( + title = "교통 시설", + description = "어쩌구", + icon = R.drawable.ic_car, + isSelected = true, + onClick = {}, + ) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ReportCategorySelector.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ReportCategorySelector.kt new file mode 100644 index 00000000..daeaa1cb --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ReportCategorySelector.kt @@ -0,0 +1,60 @@ +package com.threegap.bitnagil.presentation.report.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.R +import com.threegap.bitnagil.designsystem.component.atom.BitnagilIcon +import com.threegap.bitnagil.designsystem.modifier.clickableWithoutRipple + +@Composable +fun ReportCategorySelector( + title: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .background( + color = BitnagilTheme.colors.coolGray99, + shape = RoundedCornerShape(12.dp), + ) + .fillMaxWidth() + .height(56.dp) + .clickableWithoutRipple { onClick() } + .padding(vertical = 14.dp, horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title ?: "카테고리 선택", + color = if (title == null) BitnagilTheme.colors.coolGray80 else BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.body2Medium, + ) + + BitnagilIcon( + id = R.drawable.icon_down_arrow_black, + tint = BitnagilTheme.colors.coolGray10, + ) + } +} + +@Preview +@Composable +private fun Preview() { + ReportCategorySelector( + title = "카테고리 선택", + onClick = {}, + ) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ReportField.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ReportField.kt new file mode 100644 index 00000000..d7321e5b --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/ReportField.kt @@ -0,0 +1,47 @@ +package com.threegap.bitnagil.presentation.report.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme + +@Composable +fun ReportField( + title: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.body2SemiBold, + ) + Text( + text = "*", + color = BitnagilTheme.colors.error10, + style = BitnagilTheme.typography.body1SemiBold, + ) + } + content() + } +} + +@Preview +@Composable +private fun Preview() { + ReportField(title = "제보 카테고리") {} +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/template/CompleteReportContent.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/template/CompleteReportContent.kt new file mode 100644 index 00000000..8a819ebb --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/template/CompleteReportContent.kt @@ -0,0 +1,114 @@ +package com.threegap.bitnagil.presentation.report.component.template + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.R +import com.threegap.bitnagil.designsystem.component.atom.BitnagilIcon +import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextButton +import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextButtonColor +import com.threegap.bitnagil.presentation.report.component.CompleteReportCard +import com.threegap.bitnagil.presentation.report.model.ReportState + +@Composable +fun CompleteReportContent( + uiState: ReportState, + onConfirmClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(state = scrollState) + .padding(top = 80.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BitnagilIcon( + id = R.drawable.ic_check_circle_orange, + tint = null, + modifier = Modifier + .padding(bottom = 12.dp) + .size(40.dp), + ) + + Text( + text = "제보가 완료되었습니다.", + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.title2Bold, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Text( + text = "빛나길에서 접수 후 완료되면\n신고를 진행합니다.", + color = BitnagilTheme.colors.coolGray40, + style = BitnagilTheme.typography.body1Medium, + textAlign = TextAlign.Center, + ) + + Box( + contentAlignment = Alignment.TopCenter, + ) { + Image( + painter = painterResource(R.drawable.onboarding_character), + contentDescription = null, + modifier = Modifier + .padding(top = 34.dp) + .size(134.dp, 148.dp), + ) + + CompleteReportCard( + modifier = Modifier + .offset(y = 115.dp) + .padding(horizontal = 18.dp), + title = uiState.reportTitle, + category = uiState.selectedCategory, + address = uiState.currentAddress, + content = uiState.reportContent, + images = uiState.uploadedImagePaths, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + BitnagilTextButton( + text = "확인", + onClick = onConfirmClick, + colors = BitnagilTextButtonColor.default( + disabledBackgroundColor = BitnagilTheme.colors.coolGray98, + disabledTextColor = BitnagilTheme.colors.coolGray90, + ), + enabled = true, + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + CompleteReportContent( + uiState = ReportState.Init, + onConfirmClick = {}, + ) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/template/SubmittingReportContent.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/template/SubmittingReportContent.kt new file mode 100644 index 00000000..84b75644 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/component/template/SubmittingReportContent.kt @@ -0,0 +1,64 @@ +package com.threegap.bitnagil.presentation.report.component.template + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.R +import com.threegap.bitnagil.designsystem.component.atom.BitnagilIcon + +@Composable +fun SubmittingReportContent( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(top = 80.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BitnagilIcon( + id = R.drawable.ic_loading, + tint = null, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Text( + text = "제보중...", + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.title2Bold, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Text( + text = "포모가 열심\n제보하고 있어요", + color = BitnagilTheme.colors.coolGray40, + style = BitnagilTheme.typography.body1Medium, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(72.dp)) + + Image( + painter = painterResource(R.drawable.img_pomo_loading), + contentDescription = null, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + SubmittingReportContent() +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/ReportCategoryExtension.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/ReportCategoryExtension.kt new file mode 100644 index 00000000..de494241 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/ReportCategoryExtension.kt @@ -0,0 +1,28 @@ +package com.threegap.bitnagil.presentation.report.model + +import com.threegap.bitnagil.designsystem.R +import com.threegap.bitnagil.domain.report.model.ReportCategory + +val ReportCategory.uiTitle: String + get() = when (this) { + ReportCategory.TRANSPORTATION -> "교통 시설" + ReportCategory.LIGHTING -> "조명 시설" + ReportCategory.WATERFACILITY -> "상하수도 시설" + ReportCategory.AMENITY -> "편의 시설" + } + +val ReportCategory.uiDescription: String + get() = when (this) { + ReportCategory.TRANSPORTATION -> "신호등 고장, 표지판 파손, 횡단보도 등" + ReportCategory.LIGHTING -> "가로등, 보안등 파손 등" + ReportCategory.WATERFACILITY -> "맨홀 뚜껑 손상 등" + ReportCategory.AMENITY -> "벤치 파손, 휴지통 넘침 등" + } + +val ReportCategory.iconRes: Int + get() = when (this) { + ReportCategory.TRANSPORTATION -> R.drawable.ic_car + ReportCategory.LIGHTING -> R.drawable.ic_light + ReportCategory.WATERFACILITY -> R.drawable.ic_water + ReportCategory.AMENITY -> R.drawable.ic_hammer + } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/ReportSideEffect.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/ReportSideEffect.kt new file mode 100644 index 00000000..c3374c47 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/ReportSideEffect.kt @@ -0,0 +1,6 @@ +package com.threegap.bitnagil.presentation.report.model + +sealed interface ReportSideEffect { + data object NavigateToBack : ReportSideEffect + data object FocusOnContent : ReportSideEffect +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/ReportState.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/ReportState.kt new file mode 100644 index 00000000..4676d8b0 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/ReportState.kt @@ -0,0 +1,48 @@ +package com.threegap.bitnagil.presentation.report.model + +import android.net.Uri +import com.threegap.bitnagil.domain.report.model.ReportCategory + +data class ReportState( + val reportImages: List, + val reportTitle: String, + val reportContent: String, + val selectedCategory: ReportCategory?, + val imageSourceBottomSheetVisible: Boolean, + val reportCategoryBottomSheetVisible: Boolean, + val currentAddress: String?, + val currentLatitude: Double?, + val currentLongitude: Double?, + val uploadedImagePaths: List, + val submitState: SubmitState, +) { + val canAddMoreImages: Boolean + get() = reportImages.size < MAX_IMAGE_COUNT + + val isSubmittable: Boolean + get() = reportImages.isNotEmpty() && + reportTitle.isNotEmpty() && + reportContent.isNotEmpty() && + selectedCategory != null && + currentAddress != null && + currentLatitude != null && + currentLongitude != null + + companion object { + const val MAX_IMAGE_COUNT = 3 + + val Init = ReportState( + reportImages = emptyList(), + reportTitle = "", + reportContent = "", + selectedCategory = null, + imageSourceBottomSheetVisible = false, + reportCategoryBottomSheetVisible = false, + currentAddress = null, + currentLatitude = null, + currentLongitude = null, + uploadedImagePaths = emptyList(), + submitState = SubmitState.IDLE, + ) + } +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/SubmitState.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/SubmitState.kt new file mode 100644 index 00000000..fe0212bd --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/report/model/SubmitState.kt @@ -0,0 +1,3 @@ +package com.threegap.bitnagil.presentation.report.model + +enum class SubmitState { IDLE, SUBMITTING, COMPLETE } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/setting/SettingScreen.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/setting/SettingScreen.kt index 6794af98..342b36f1 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/setting/SettingScreen.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/setting/SettingScreen.kt @@ -24,12 +24,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.component.block.BitnagilAlertDialog import com.threegap.bitnagil.designsystem.component.block.BitnagilOptionButton import com.threegap.bitnagil.designsystem.component.block.BitnagilTopBar import com.threegap.bitnagil.designsystem.modifier.clickableWithoutRipple import com.threegap.bitnagil.presentation.common.flow.collectAsEffect import com.threegap.bitnagil.presentation.setting.component.atom.settingtitle.SettingTitle -import com.threegap.bitnagil.presentation.setting.component.block.LogoutConfirmDialog import com.threegap.bitnagil.presentation.setting.model.mvi.SettingIntent import com.threegap.bitnagil.presentation.setting.model.mvi.SettingSideEffect import com.threegap.bitnagil.presentation.setting.model.mvi.SettingState @@ -53,7 +53,11 @@ fun SettingScreenContainer( } if (state.logoutConfirmDialogVisible) { - LogoutConfirmDialog( + BitnagilAlertDialog( + title = "로그아웃 할까요?", + description = "이 기기에서 계정이 로그아웃되고, 다시\n로그인해야 서비스를 계속 이용할 수 있어요.", + dismissButtonText = "취소", + confirmButtonText = "로그아웃", onDismiss = viewModel::hideConfirmDialog, onConfirm = viewModel::logout, ) diff --git a/presentation/src/main/res/xml/file_paths.xml b/presentation/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..d686a1ea --- /dev/null +++ b/presentation/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + +