From 40a2e3706d131d2711b640b430dc3d342dc23306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=98=81=EB=AF=BC?= Date: Sun, 15 Mar 2026 23:18:36 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=A3=BC=EC=86=8C=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 7 + app/src/main/AndroidManifest.xml | 2 + .../data/datasource/network/KakaoLocalApi.kt | 20 ++ .../network/request/post/CreatePostRequest.kt | 3 + .../network/response/KakaoSearchResponse.kt | 30 +++ .../data/model/post/DailyCalendarElement.kt | 2 + .../com/no5ing/bbibbi/data/model/post/Post.kt | 1 + .../com/no5ing/bbibbi/di/NetworkModule.kt | 17 ++ .../feature/state/main/home/HomePageState.kt | 1 + .../location_picker/LocationPickerPage.kt | 218 ++++++++++++++++++ .../main/mission_upload/MissionUploadPage.kt | 48 +++- .../view/main/post_upload/PostUploadPage.kt | 49 +++- .../post_upload/PostUploadPageImagePreview.kt | 35 +++ .../post_upload/PostUploadPageUploadBar.kt | 17 +- .../view/main/post_view/PostViewContent.kt | 34 +++ .../view/main/post_view/PostViewPage.kt | 62 +++-- .../view_controller/NavigationDestination.kt | 1 + .../main/LocationPickerPageController.kt | 36 +++ .../main/MissionUploadPageController.kt | 19 ++ .../main/PostUploadPageController.kt | 34 +++ .../location/LocationSearchViewModel.kt | 48 ++++ .../view_model/post/CreatePostViewModel.kt | 3 + .../navigation/graph/MainNavGraph.kt | 11 + app/src/main/res/drawable/location_button.xml | 13 ++ app/src/main/res/drawable/location_icon.xml | 9 + app/src/main/res/drawable/search_icon.xml | 9 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 29 files changed, 704 insertions(+), 34 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 app/src/main/java/com/no5ing/bbibbi/data/datasource/network/KakaoLocalApi.kt create mode 100644 app/src/main/java/com/no5ing/bbibbi/data/datasource/network/response/KakaoSearchResponse.kt create mode 100644 app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/location_picker/LocationPickerPage.kt create mode 100644 app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/LocationPickerPageController.kt create mode 100644 app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/location/LocationSearchViewModel.kt create mode 100644 app/src/main/res/drawable/location_button.xml create mode 100644 app/src/main/res/drawable/location_icon.xml create mode 100644 app/src/main/res/drawable/search_icon.xml diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..cbd3cfd1 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(xargs grep:*)" + ] + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b6bf3517..c949cd13 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,8 @@ + + diff --git a/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/KakaoLocalApi.kt b/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/KakaoLocalApi.kt new file mode 100644 index 00000000..8caa911d --- /dev/null +++ b/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/KakaoLocalApi.kt @@ -0,0 +1,20 @@ +package com.no5ing.bbibbi.data.datasource.network + +import com.no5ing.bbibbi.data.datasource.network.response.KakaoSearchResponse +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Query + +interface KakaoLocalApi { + + @GET("v2/local/search/keyword.json") + suspend fun searchKeyword( + @Header("Authorization") authorization: String, + @Query("query") query: String, + @Query("x") x: String? = null, + @Query("y") y: String? = null, + @Query("radius") radius: Int? = null, + @Query("page") page: Int? = null, + @Query("size") size: Int? = null, + ): KakaoSearchResponse +} diff --git a/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/request/post/CreatePostRequest.kt b/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/request/post/CreatePostRequest.kt index b4b157df..10d7687d 100644 --- a/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/request/post/CreatePostRequest.kt +++ b/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/request/post/CreatePostRequest.kt @@ -9,4 +9,7 @@ data class CreatePostRequest( val imageUrl: String, val content: String, val uploadTime: String, + val latitude: Double? = null, + val longitude: Double? = null, + val address: String? = null, ) : Parcelable, BaseModel() diff --git a/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/response/KakaoSearchResponse.kt b/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/response/KakaoSearchResponse.kt new file mode 100644 index 00000000..6e443dbe --- /dev/null +++ b/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/response/KakaoSearchResponse.kt @@ -0,0 +1,30 @@ +package com.no5ing.bbibbi.data.datasource.network.response + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonIgnoreProperties(ignoreUnknown = true) +data class KakaoSearchResponse( + val meta: KakaoSearchMeta, + val documents: List, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class KakaoSearchMeta( + @JsonProperty("total_count") val totalCount: Int, + @JsonProperty("pageable_count") val pageableCount: Int, + @JsonProperty("is_end") val isEnd: Boolean, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class KakaoSearchDocument( + val id: String = "", + @JsonProperty("place_name") val placeName: String = "", + @JsonProperty("address_name") val addressName: String = "", + @JsonProperty("road_address_name") val roadAddressName: String = "", + val x: String = "", + val y: String = "", + val phone: String = "", + val distance: String = "", + @JsonProperty("category_name") val categoryName: String = "", +) diff --git a/app/src/main/java/com/no5ing/bbibbi/data/model/post/DailyCalendarElement.kt b/app/src/main/java/com/no5ing/bbibbi/data/model/post/DailyCalendarElement.kt index 5c193f52..f0564a72 100644 --- a/app/src/main/java/com/no5ing/bbibbi/data/model/post/DailyCalendarElement.kt +++ b/app/src/main/java/com/no5ing/bbibbi/data/model/post/DailyCalendarElement.kt @@ -19,6 +19,7 @@ data class DailyCalendarElement( val emojiCount: Int, val allFamilyMembersUploaded: Boolean, val createdAt: ZonedDateTime, + val address: String?, ) : Parcelable, BaseModel() { fun toPost() = Post( postId = postId, @@ -30,5 +31,6 @@ data class DailyCalendarElement( imageUrl = postImgUrl, content = postContent, createdAt = createdAt, + address = address, ) } \ No newline at end of file diff --git a/app/src/main/java/com/no5ing/bbibbi/data/model/post/Post.kt b/app/src/main/java/com/no5ing/bbibbi/data/model/post/Post.kt index 927a5488..216ee38f 100644 --- a/app/src/main/java/com/no5ing/bbibbi/data/model/post/Post.kt +++ b/app/src/main/java/com/no5ing/bbibbi/data/model/post/Post.kt @@ -15,6 +15,7 @@ data class Post( val emojiCount: Int, val imageUrl: String, val content: String, + val address: String?, val createdAt: ZonedDateTime, ) : Parcelable, BaseModel() diff --git a/app/src/main/java/com/no5ing/bbibbi/di/NetworkModule.kt b/app/src/main/java/com/no5ing/bbibbi/di/NetworkModule.kt index ad7e8ddb..adbc122c 100644 --- a/app/src/main/java/com/no5ing/bbibbi/di/NetworkModule.kt +++ b/app/src/main/java/com/no5ing/bbibbi/di/NetworkModule.kt @@ -7,6 +7,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.kotlinModule import com.google.gson.Gson import com.no5ing.bbibbi.BuildConfig +import com.no5ing.bbibbi.data.datasource.network.KakaoLocalApi import com.no5ing.bbibbi.data.datasource.network.RestAPI import com.no5ing.bbibbi.data.model.auth.AuthResult import com.skydoves.sandwich.SandwichInitializer @@ -215,6 +216,22 @@ object NetworkModule { .build() } + @Provides + @Singleton + fun provideKakaoLocalApi(): KakaoLocalApi { + return Retrofit.Builder() + .baseUrl("https://dapi.kakao.com/") + .addConverterFactory( + JacksonConverterFactory.create( + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModule(kotlinModule()) + ) + ) + .build() + .create(KakaoLocalApi::class.java) + } + @Provides @Singleton fun provideRestFamilyApi(retrofit: Retrofit): RestAPI.FamilyApi { diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/state/main/home/HomePageState.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/state/main/home/HomePageState.kt index bcb80400..4b367dd0 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/state/main/home/HomePageState.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/state/main/home/HomePageState.kt @@ -30,6 +30,7 @@ fun rememberHomePageState( createdAt = ZonedDateTime.now(), missionId = null, type = PostType.SURVIVAL, + address = null, ) ) }, diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/location_picker/LocationPickerPage.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/location_picker/LocationPickerPage.kt new file mode 100644 index 00000000..7f6adcfa --- /dev/null +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/location_picker/LocationPickerPage.kt @@ -0,0 +1,218 @@ +package com.no5ing.bbibbi.presentation.feature.view.main.location_picker + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.no5ing.bbibbi.R +import com.no5ing.bbibbi.presentation.component.BBiBBiSurface +import com.no5ing.bbibbi.presentation.component.DisposableTopBar +import com.no5ing.bbibbi.presentation.feature.view_model.location.LocationSearchViewModel +import com.no5ing.bbibbi.presentation.theme.bbibbiScheme +import com.no5ing.bbibbi.presentation.theme.bbibbiTypo + +@Composable +fun LocationPickerPage( + onDispose: () -> Unit = {}, + onConfirmLocation: (latitude: Double, longitude: Double, address: String) -> Unit = { _, _, _ -> }, + currentLatitude: Double? = null, + currentLongitude: Double? = null, + locationSearchViewModel: LocationSearchViewModel = hiltViewModel(), +) { + var query by remember { mutableStateOf("") } + val searchResults by locationSearchViewModel.searchResults.collectAsState() + val isLoading by locationSearchViewModel.isLoading.collectAsState() + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + if (currentLatitude != null && currentLongitude != null) { + locationSearchViewModel.search("", currentLatitude, currentLongitude) + } + } + + BBiBBiSurface( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + DisposableTopBar( + onDispose = onDispose, + title = stringResource(id = R.string.location_picker_title), + ) + + // Search bar + TextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { + Text( + text = stringResource(id = R.string.location_search_hint), + color = MaterialTheme.bbibbiScheme.icon, + ) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.search_icon), + contentDescription = null, + tint = MaterialTheme.bbibbiScheme.icon, + modifier = Modifier.size(20.dp), + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + keyboardController?.hide() + locationSearchViewModel.search(query, currentLatitude, currentLongitude) + } + ), + shape = RoundedCornerShape(12.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.bbibbiScheme.backgroundHover, + unfocusedContainerColor = MaterialTheme.bbibbiScheme.backgroundHover, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedTextColor = MaterialTheme.bbibbiScheme.textPrimary, + unfocusedTextColor = MaterialTheme.bbibbiScheme.textPrimary, + cursorColor = MaterialTheme.bbibbiScheme.mainYellow, + ), + ) + + // Results + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.bbibbiScheme.mainYellow, + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + } else if (searchResults.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "아직 위치가 없어요.\n검색으로 위치를 추가할 수 있어요.", + color = MaterialTheme.bbibbiScheme.gray500, + style = MaterialTheme.bbibbiTypo.bodyOneRegular, + textAlign = TextAlign.Center, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(searchResults) { document -> + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + val lat = document.y.toDoubleOrNull() ?: return@clickable + val lng = document.x.toDoubleOrNull() ?: return@clickable + val address = document.placeName + onConfirmLocation(lat, lng, address) + } + .padding(horizontal = 20.dp, vertical = 14.dp), + ) { + Text( + text = document.placeName, + color = MaterialTheme.bbibbiScheme.textPrimary, + style = MaterialTheme.bbibbiTypo.bodyOneBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(4.dp)) + val distanceText = when { + document.distance.isNotEmpty() -> formatDistance(document.distance) + currentLatitude != null && currentLongitude != null -> { + val docLat = document.y.toDoubleOrNull() + val docLng = document.x.toDoubleOrNull() + if (docLat != null && docLng != null) { + formatDistance(calculateDistance(currentLatitude, currentLongitude, docLat, docLng).toString()) + } else null + } + else -> null + } + Text( + text = distanceText ?: document.roadAddressName.ifEmpty { document.addressName }, + color = MaterialTheme.bbibbiScheme.textSecondary, + style = MaterialTheme.bbibbiTypo.bodyTwoRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } +} + +private fun calculateDistance(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Int { + val r = 6371000.0 // Earth radius in meters + val dLat = Math.toRadians(lat2 - lat1) + val dLng = Math.toRadians(lng2 - lng1) + val a = kotlin.math.sin(dLat / 2) * kotlin.math.sin(dLat / 2) + + kotlin.math.cos(Math.toRadians(lat1)) * kotlin.math.cos(Math.toRadians(lat2)) * + kotlin.math.sin(dLng / 2) * kotlin.math.sin(dLng / 2) + val c = 2 * kotlin.math.atan2(kotlin.math.sqrt(a), kotlin.math.sqrt(1 - a)) + return (r * c).toInt() +} + +private fun formatDistance(distanceStr: String): String { + val meters = distanceStr.toIntOrNull() ?: return distanceStr + return if (meters >= 1000) { + String.format("%.1fkm", meters / 1000.0) + } else { + "${meters}m" + } +} diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/mission_upload/MissionUploadPage.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/mission_upload/MissionUploadPage.kt index a1635b93..d44d4879 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/mission_upload/MissionUploadPage.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/mission_upload/MissionUploadPage.kt @@ -45,6 +45,9 @@ import com.no5ing.bbibbi.presentation.feature.view.main.post_upload.PostUploadPa import com.no5ing.bbibbi.presentation.feature.view.main.post_upload.PostUploadPageUploadBar import com.no5ing.bbibbi.presentation.feature.view_model.mission.GetTodayMissionViewModel import com.no5ing.bbibbi.presentation.feature.view_model.post.CreatePostViewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState import com.no5ing.bbibbi.util.LocalMixpanelProvider import com.no5ing.bbibbi.util.LocalSnackbarHostState import com.no5ing.bbibbi.util.codePointLength @@ -52,11 +55,16 @@ import com.no5ing.bbibbi.util.getErrorMessage import kotlinx.coroutines.launch +@OptIn(ExperimentalPermissionsApi::class) @Composable fun MissionUploadPage( onDispose: () -> Unit, + onNavigateToLocationPicker: () -> Unit = {}, todayMissionViewModel: GetTodayMissionViewModel = hiltViewModel(), imageUrl: State, + locationLatitude: State = remember { mutableStateOf(null) }, + locationLongitude: State = remember { mutableStateOf(null) }, + locationAddress: State = remember { mutableStateOf(null) }, imageText: MutableState = remember { mutableStateOf("") }, @@ -65,6 +73,25 @@ fun MissionUploadPage( }, createPostViewModel: CreatePostViewModel = hiltViewModel(), ) { + var pendingLocationNavigation by remember { mutableStateOf(false) } + val locationPermissionState = rememberPermissionState( + permission = android.Manifest.permission.ACCESS_FINE_LOCATION + ) { isGranted -> + if (isGranted) { + onNavigateToLocationPicker() + } + } + LaunchedEffect(pendingLocationNavigation) { + if (pendingLocationNavigation) { + pendingLocationNavigation = false + if (locationPermissionState.status.isGranted) { + onNavigateToLocationPicker() + } else { + locationPermissionState.launchPermissionRequest() + } + } + } + LaunchedEffect(Unit) { if (todayMissionViewModel.uiState.value.isIdle()) { todayMissionViewModel.invoke(Arguments()) @@ -120,6 +147,7 @@ fun MissionUploadPage( PostUploadPageImagePreview( previewImgUrl = imageUrl.value, imageTextState = imageText, + addressState = locationAddress, onTapImageTextButton = { mixPanel.track("Click_PhotoText") textOverlayShown.value = true @@ -128,16 +156,22 @@ fun MissionUploadPage( Spacer(modifier = Modifier.height(48.dp)) PostUploadPageUploadBar( isIdle = uploadResult.value.isIdle(), + showLocationButton = true, + onClickLocation = { + pendingLocationNavigation = true + }, onClickUpload = { mixPanel.track("Click_UploadPhoto") + val args = mutableMapOf( + "imageUri" to imageUrl.value.toString(), + "content" to imageText.value, + "type" to "MISSION" + ) + locationLatitude.value?.let { args["latitude"] = it.toString() } + locationLongitude.value?.let { args["longitude"] = it.toString() } + locationAddress.value?.let { args["address"] = it } createPostViewModel.invoke( - Arguments( - arguments = mapOf( - "imageUri" to imageUrl.value.toString(), - "content" to imageText.value, - "type" to "MISSION" - ) - ) + Arguments(arguments = args) ) }, onClickSave = { diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPage.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPage.kt index 3154bf88..0fb9b778 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPage.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPage.kt @@ -44,6 +44,9 @@ import com.no5ing.bbibbi.presentation.component.showSnackBarWithDismiss import com.no5ing.bbibbi.presentation.component.snackBarCamera import com.no5ing.bbibbi.presentation.component.snackBarWarning import com.no5ing.bbibbi.presentation.feature.view_model.post.CreatePostViewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState import com.no5ing.bbibbi.util.LocalMixpanelProvider import com.no5ing.bbibbi.util.LocalSnackbarHostState import com.no5ing.bbibbi.util.codePointLength @@ -52,11 +55,16 @@ import com.no5ing.bbibbi.util.getErrorMessage import kotlinx.coroutines.launch +@OptIn(ExperimentalPermissionsApi::class) @Composable fun PostUploadPage( onDispose: () -> Unit, + onNavigateToLocationPicker: () -> Unit = {}, isUnsaveMode: Boolean = false, imageUrl: State, + locationLatitude: State = remember { mutableStateOf(null) }, + locationLongitude: State = remember { mutableStateOf(null) }, + locationAddress: State = remember { mutableStateOf(null) }, imageText: MutableState = remember { mutableStateOf("") }, @@ -70,6 +78,25 @@ fun PostUploadPage( onDispose() } + var pendingLocationNavigation by remember { mutableStateOf(false) } + val locationPermissionState = rememberPermissionState( + permission = android.Manifest.permission.ACCESS_FINE_LOCATION + ) { isGranted -> + if (isGranted) { + onNavigateToLocationPicker() + } + } + LaunchedEffect(pendingLocationNavigation) { + if (pendingLocationNavigation) { + pendingLocationNavigation = false + if (locationPermissionState.status.isGranted) { + onNavigateToLocationPicker() + } else { + locationPermissionState.launchPermissionRequest() + } + } + } + val coroutineScope = rememberCoroutineScope() val context = LocalContext.current val snackBarHost = LocalSnackbarHostState.current @@ -121,6 +148,7 @@ fun PostUploadPage( PostUploadPageImagePreview( previewImgUrl = imageUrl.value, imageTextState = imageText, + addressState = locationAddress, onTapImageTextButton = { mixPanel.track("Click_PhotoText") textOverlayShown.value = true @@ -129,15 +157,21 @@ fun PostUploadPage( Spacer(modifier = Modifier.height(48.dp)) PostUploadPageUploadBar( isIdle = uploadResult.value.isIdle(), + onClickLocation = { + pendingLocationNavigation = true + }, + showLocationButton = true, onClickUpload = { mixPanel.track("Click_UploadPhoto") + val args = mutableMapOf( + "imageUri" to imageUrl.value.toString(), + "content" to imageText.value + ) + locationLatitude.value?.let { args["latitude"] = it.toString() } + locationLongitude.value?.let { args["longitude"] = it.toString() } + locationAddress.value?.let { args["address"] = it } createPostViewModel.invoke( - Arguments( - arguments = mapOf( - "imageUri" to imageUrl.value.toString(), - "content" to imageText.value - ) - ) + Arguments(arguments = args) ) }, onClickSave = { @@ -249,7 +283,8 @@ fun PostUploadPagePreview() { ) Spacer(modifier = Modifier.height(48.dp)) PostUploadPageUploadBar( - isIdle = true + isIdle = true, + showLocationButton = true, ) } AnimatedVisibility( diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPageImagePreview.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPageImagePreview.kt index 4cbb06dd..01982cd7 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPageImagePreview.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPageImagePreview.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter import com.no5ing.bbibbi.R @@ -37,10 +38,12 @@ import com.no5ing.bbibbi.util.toCodePointList fun PostUploadPageImagePreview( previewImgUrl: Uri?, imageTextState: State = mutableStateOf(""), + addressState: State = mutableStateOf(null), supportText: Boolean = true, onTapImageTextButton: () -> Unit = {}, ) { val imageText by imageTextState + val address by addressState Box( modifier = Modifier.fillMaxWidth() ) { @@ -62,6 +65,38 @@ fun PostUploadPageImagePreview( } } + if (!address.isNullOrEmpty()) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(start = 20.dp, top = 20.dp) + .background( + color = Color.Black.copy(alpha = 0.3f), + RoundedCornerShape(100.dp) + ) + .padding(horizontal = 10.dp, vertical = 8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.location_icon), + contentDescription = null, + tint = MaterialTheme.bbibbiScheme.mainYellow, + modifier = Modifier.size(16.dp) + ) + Text( + text = address!!, + color = MaterialTheme.bbibbiScheme.mainYellow, + style = MaterialTheme.bbibbiTypo.bodyTwoBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + if (supportText) { Box( modifier = Modifier diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPageUploadBar.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPageUploadBar.kt index f6b30655..f1607e5a 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPageUploadBar.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_upload/PostUploadPageUploadBar.kt @@ -21,8 +21,10 @@ import com.no5ing.bbibbi.presentation.component.button.CTAButton fun PostUploadPageUploadBar( isIdle: Boolean, isSaveIdle: Boolean = true, + showLocationButton: Boolean = false, onClickUpload: () -> Unit = {}, onClickSave: () -> Unit = {}, + onClickLocation: () -> Unit = {}, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -31,7 +33,20 @@ fun PostUploadPageUploadBar( Alignment.CenterHorizontally ), ) { - Box(modifier = Modifier.size(48.dp)) + if (showLocationButton) { + Image( + painter = painterResource(R.drawable.location_button), + contentDescription = null, // 필수 param + modifier = Modifier + .size(48.dp) + .clickable { + onClickLocation() + } + ) + } else { + Box(modifier = Modifier.size(48.dp)) + } + CTAButton( text = stringResource(id = R.string.upload_image), contentPadding = PaddingValues(horizontal = 60.dp, vertical = 15.dp), diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_view/PostViewContent.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_view/PostViewContent.kt index bfc33b74..7681cc57 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_view/PostViewContent.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_view/PostViewContent.kt @@ -10,8 +10,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -25,6 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -128,6 +131,37 @@ fun PostViewContent( contentScale = ContentScale.Crop ) } + if (post.address != null) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(start = 20.dp, top = 20.dp) + .background( + color = Color.Black.copy(alpha = 0.3f), + RoundedCornerShape(100.dp) + ) + .padding(horizontal = 10.dp, vertical = 8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.location_icon), + contentDescription = null, + tint = MaterialTheme.bbibbiScheme.mainYellow, + modifier = Modifier.size(16.dp) + ) + Text( + text = post.address, + color = MaterialTheme.bbibbiScheme.mainYellow, + style = MaterialTheme.bbibbiTypo.bodyTwoBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } if (missionText != null) { Box( modifier = Modifier diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_view/PostViewPage.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_view/PostViewPage.kt index 3f7a7c1b..080fec7f 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_view/PostViewPage.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/post_view/PostViewPage.kt @@ -28,10 +28,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -85,20 +85,20 @@ fun PostViewPage( val memberId = LocalSessionState.current.memberId var isPagerReady by remember { mutableStateOf(false) } + var siblingPostsLoaded by remember { mutableStateOf(false) } val postState by postViewPageState.uiState.collectAsState() val siblingPostState by familyPostsViewModel.uiState.collectAsState() - val pagerState = key(siblingPostState) { - rememberPagerState( - initialPage = if (siblingPostState.isReady()) siblingPostState.data - .indexOfFirst { it.post.postId == postId } else 0, - pageCount = { - if (siblingPostState.isReady()) siblingPostState.data.size else 1 - } - ) - } + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { + if (siblingPostState.isReady()) siblingPostState.data.size else 1 + } + ) val adView = getAdView() + // Load sibling posts once when postState becomes ready LaunchedEffect(postState) { - if (postState.isReady()) { + if (!siblingPostsLoaded && postState.isReady()) { + siblingPostsLoaded = true val currentPost = postState.data.post familyPostsViewModel.invoke( Arguments( @@ -109,8 +109,10 @@ fun PostViewPage( ) } } - LaunchedEffect(postState, postCommentDialogState.value) { - if (!postCommentDialogState.value && postState.isReady()) { + // Refresh sibling posts only when comment dialog actually closes + var wasCommentDialogOpen by remember { mutableStateOf(false) } + LaunchedEffect(postCommentDialogState.value) { + if (wasCommentDialogOpen && !postCommentDialogState.value && postState.isReady()) { val currentPost = postState.data.post familyPostsViewModel.invoke( Arguments( @@ -120,28 +122,50 @@ fun PostViewPage( ) ) } + wasCommentDialogOpen = postCommentDialogState.value } + // When sibling posts load, scroll pager to the correct post LaunchedEffect(siblingPostState) { if (siblingPostState.isReady()) { + val targetPage = siblingPostState.data + .indexOfFirst { it.post.postId == postId } + .coerceAtLeast(0) + pagerState.scrollToPage(targetPage) isPagerReady = true } } - LaunchedEffect(postState, pagerState.currentPage) { + // Load reactions for initial post + LaunchedEffect(postState) { if (postState.isReady()) { - val currentPostId = - (if (siblingPostState.isReady()) siblingPostState.data.getOrNull(pagerState.currentPage)?.post?.postId - else postState.data.post.postId) - ?: return@LaunchedEffect familyPostReactionBarViewModel.invoke( Arguments( arguments = mapOf( - "postId" to currentPostId, + "postId" to postState.data.post.postId, "memberId" to memberId ) ) ) } } + // Reload reactions when user swipes to a different page + LaunchedEffect(isPagerReady) { + if (isPagerReady) { + snapshotFlow { pagerState.currentPage } + .collect { page -> + val currentPostId = + siblingPostState.data.getOrNull(page)?.post?.postId + ?: return@collect + familyPostReactionBarViewModel.invoke( + Arguments( + arguments = mapOf( + "postId" to currentPostId, + "memberId" to memberId + ) + ) + ) + } + } + } BBiBBiSurface(modifier = Modifier.fillMaxSize()) { Box { AnimatedVisibility( diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/NavigationDestination.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/NavigationDestination.kt index 739aa095..e63edc8c 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/NavigationDestination.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/NavigationDestination.kt @@ -73,6 +73,7 @@ abstract class NavigationDestination( internal const val cameraViewRoute = "common/camera" internal const val uploadMissionPageRoute = "main/upload-mission" internal const val uploadMissionPreviewPageRoute = "main/upload-mission-preview" + internal const val locationPickerRoute = "post/location-picker" @OptIn(ExperimentalComposeUiApi::class) diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/LocationPickerPageController.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/LocationPickerPageController.kt new file mode 100644 index 00000000..66413337 --- /dev/null +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/LocationPickerPageController.kt @@ -0,0 +1,36 @@ +package com.no5ing.bbibbi.presentation.feature.view_controller.main + +import androidx.compose.runtime.Composable +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import com.no5ing.bbibbi.presentation.feature.view.main.location_picker.LocationPickerPage +import com.no5ing.bbibbi.presentation.feature.view_controller.NavigationDestination + +object LocationPickerPageController : NavigationDestination( + route = locationPickerRoute, +) { + const val LOCATION_LAT_KEY = "locationLat" + const val LOCATION_LNG_KEY = "locationLng" + const val LOCATION_ADDRESS_KEY = "locationAddress" + + @Composable + override fun Render(navController: NavHostController, backStackEntry: NavBackStackEntry) { + LocationPickerPage( + onDispose = { + navController.popBackStack() + }, + onConfirmLocation = { lat, lng, address -> + navController.previousBackStackEntry?.savedStateHandle?.apply { + set(LOCATION_LAT_KEY, lat) + set(LOCATION_LNG_KEY, lng) + set(LOCATION_ADDRESS_KEY, address) + } + navController.popBackStack() + }, + ) + } + + fun NavHostController.goLocationPickerPage() { + navigate(LocationPickerPageController) + } +} diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/MissionUploadPageController.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/MissionUploadPageController.kt index d33d02b0..4d3fe477 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/MissionUploadPageController.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/MissionUploadPageController.kt @@ -7,6 +7,10 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import com.no5ing.bbibbi.presentation.feature.view.main.mission_upload.MissionUploadPage import com.no5ing.bbibbi.presentation.feature.view_controller.NavigationDestination +import com.no5ing.bbibbi.presentation.feature.view_controller.main.LocationPickerPageController.LOCATION_ADDRESS_KEY +import com.no5ing.bbibbi.presentation.feature.view_controller.main.LocationPickerPageController.LOCATION_LAT_KEY +import com.no5ing.bbibbi.presentation.feature.view_controller.main.LocationPickerPageController.LOCATION_LNG_KEY +import com.no5ing.bbibbi.presentation.feature.view_controller.main.LocationPickerPageController.goLocationPickerPage object MissionUploadPageController : NavigationDestination( route = uploadMissionPreviewPageRoute, @@ -16,11 +20,26 @@ object MissionUploadPageController : NavigationDestination( val imageCaptureState = backStackEntry.savedStateHandle .getLiveData("imageUrl") .observeAsState() + val locationLat = backStackEntry.savedStateHandle + .getLiveData(LOCATION_LAT_KEY) + .observeAsState() + val locationLng = backStackEntry.savedStateHandle + .getLiveData(LOCATION_LNG_KEY) + .observeAsState() + val locationAddress = backStackEntry.savedStateHandle + .getLiveData(LOCATION_ADDRESS_KEY) + .observeAsState() MissionUploadPage( imageUrl = imageCaptureState, + locationLatitude = locationLat, + locationLongitude = locationLng, + locationAddress = locationAddress, onDispose = { navController.popBackStack() }, + onNavigateToLocationPicker = { + navController.goLocationPickerPage() + }, ) } diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/PostUploadPageController.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/PostUploadPageController.kt index dbe4f1eb..097e232c 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/PostUploadPageController.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/PostUploadPageController.kt @@ -10,6 +10,10 @@ import androidx.navigation.NavHostController import androidx.navigation.navArgument import com.no5ing.bbibbi.presentation.feature.view.main.post_upload.PostUploadPage import com.no5ing.bbibbi.presentation.feature.view_controller.NavigationDestination +import com.no5ing.bbibbi.presentation.feature.view_controller.main.LocationPickerPageController.LOCATION_ADDRESS_KEY +import com.no5ing.bbibbi.presentation.feature.view_controller.main.LocationPickerPageController.LOCATION_LAT_KEY +import com.no5ing.bbibbi.presentation.feature.view_controller.main.LocationPickerPageController.LOCATION_LNG_KEY +import com.no5ing.bbibbi.presentation.feature.view_controller.main.LocationPickerPageController.goLocationPickerPage object PostUploadPageController : NavigationDestination( route = postUploadRoute, @@ -19,11 +23,26 @@ object PostUploadPageController : NavigationDestination( val imageCaptureState = backStackEntry.savedStateHandle .getLiveData("imageUrl") .observeAsState() + val locationLat = backStackEntry.savedStateHandle + .getLiveData(LOCATION_LAT_KEY) + .observeAsState() + val locationLng = backStackEntry.savedStateHandle + .getLiveData(LOCATION_LNG_KEY) + .observeAsState() + val locationAddress = backStackEntry.savedStateHandle + .getLiveData(LOCATION_ADDRESS_KEY) + .observeAsState() PostUploadPage( imageUrl = imageCaptureState, + locationLatitude = locationLat, + locationLongitude = locationLng, + locationAddress = locationAddress, onDispose = { navController.popBackStack() }, + onNavigateToLocationPicker = { + navController.goLocationPickerPage() + }, ) } @@ -40,12 +59,27 @@ object PostReUploadPageController : NavigationDestination( override fun Render(navController: NavHostController, backStackEntry: NavBackStackEntry) { val imageCaptureState = backStackEntry.arguments?.getString("imageUrl") val uriState = remember { mutableStateOf(Uri.parse(imageCaptureState)) } + val locationLat = backStackEntry.savedStateHandle + .getLiveData(LOCATION_LAT_KEY) + .observeAsState() + val locationLng = backStackEntry.savedStateHandle + .getLiveData(LOCATION_LNG_KEY) + .observeAsState() + val locationAddress = backStackEntry.savedStateHandle + .getLiveData(LOCATION_ADDRESS_KEY) + .observeAsState() PostUploadPage( imageUrl = uriState, isUnsaveMode = true, + locationLatitude = locationLat, + locationLongitude = locationLng, + locationAddress = locationAddress, onDispose = { navController.popBackStack() }, + onNavigateToLocationPicker = { + navController.goLocationPickerPage() + }, ) } diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/location/LocationSearchViewModel.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/location/LocationSearchViewModel.kt new file mode 100644 index 00000000..44811810 --- /dev/null +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/location/LocationSearchViewModel.kt @@ -0,0 +1,48 @@ +package com.no5ing.bbibbi.presentation.feature.view_model.location + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.no5ing.bbibbi.BuildConfig +import com.no5ing.bbibbi.data.datasource.network.KakaoLocalApi +import com.no5ing.bbibbi.data.datasource.network.response.KakaoSearchDocument +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class LocationSearchViewModel @Inject constructor( + private val kakaoLocalApi: KakaoLocalApi, +) : ViewModel() { + + private val _searchResults = MutableStateFlow>(emptyList()) + val searchResults: StateFlow> = _searchResults + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + fun search(query: String, latitude: Double?, longitude: Double?) { + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + try { + val response = kakaoLocalApi.searchKeyword( + authorization = "KakaoAK ${BuildConfig.kakaoRestApiKey}", + query = query, + x = longitude?.toString(), + y = latitude?.toString(), + radius = if (latitude != null && longitude != null) 20000 else null, + size = 15, + ) + _searchResults.value = response.documents + } catch (e: Exception) { + Timber.e(e, "Kakao location search failed") + _searchResults.value = emptyList() + } finally { + _isLoading.value = false + } + } + } +} diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/CreatePostViewModel.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/CreatePostViewModel.kt index 54d26bac..98405697 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/CreatePostViewModel.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/CreatePostViewModel.kt @@ -67,6 +67,9 @@ class CreatePostViewModel @Inject constructor( imageUrl = imageUploadResult, content = content, uploadTime = getZonedDateTimeString(), + latitude = arguments.get("latitude")?.toDoubleOrNull(), + longitude = arguments.get("longitude")?.toDoubleOrNull(), + address = arguments.get("address"), ), type = arguments.get("type") ).wrapToAPIResponse() diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/navigation/graph/MainNavGraph.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/navigation/graph/MainNavGraph.kt index e81eb43a..de3a461e 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/navigation/graph/MainNavGraph.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/navigation/graph/MainNavGraph.kt @@ -20,6 +20,7 @@ import com.no5ing.bbibbi.presentation.feature.view_controller.main.FamilyStudioP import com.no5ing.bbibbi.presentation.feature.view_controller.main.FamilyStudioUploadPageController import com.no5ing.bbibbi.presentation.feature.view_controller.main.HomePageController import com.no5ing.bbibbi.presentation.feature.view_controller.main.ImagePreviewPageController +import com.no5ing.bbibbi.presentation.feature.view_controller.main.LocationPickerPageController import com.no5ing.bbibbi.presentation.feature.view_controller.main.MissionUploadPageController import com.no5ing.bbibbi.presentation.feature.view_controller.main.NotificationPageController import com.no5ing.bbibbi.presentation.feature.view_controller.main.PostReUploadPageController @@ -205,5 +206,15 @@ fun NavGraphBuilder.mainGraph( controller = navController, destination = QuitPageController, ) + composable( + controller = navController, + destination = LocationPickerPageController, + enterTransition = { + fullHorizontalSlideInToLeft() + }, + popExitTransition = { + fullHorizontalSlideOutToRight() + } + ) } } \ No newline at end of file diff --git a/app/src/main/res/drawable/location_button.xml b/app/src/main/res/drawable/location_button.xml new file mode 100644 index 00000000..dae6d109 --- /dev/null +++ b/app/src/main/res/drawable/location_button.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/location_icon.xml b/app/src/main/res/drawable/location_icon.xml new file mode 100644 index 00000000..b096c57b --- /dev/null +++ b/app/src/main/res/drawable/location_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/search_icon.xml b/app/src/main/res/drawable/search_icon.xml new file mode 100644 index 00000000..5ff45745 --- /dev/null +++ b/app/src/main/res/drawable/search_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 4c9db3f4..1b99fe47 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -226,4 +226,7 @@ Survival Mission + + Search Location + Search \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e67c5953..15bc19e3 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -226,4 +226,7 @@ 生存 ミッション + + 場所検索 + 検索 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 78a38637..5e0d7840 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -230,4 +230,7 @@ 생존 미션 + + 위치 검색 + 검색 \ No newline at end of file From db7ff70ae033aa529dc6edadc8da204dfc9fe2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=98=81=EB=AF=BC?= Date: Mon, 16 Mar 2026 01:44:01 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=82=AC=EC=A7=84=EA=B4=80=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 2 +- .../bbibbi/data/datasource/network/RestAPI.kt | 10 +- .../bbibbi/data/model/post/AIPostType.kt | 23 ++++ .../repository/post/GetAIPostsRepository.kt | 1 + .../main/family_studio/FamilyStudioPage.kt | 127 +++++++++++------- .../FamilyStudioUploadPage.kt | 15 ++- .../feature/view/main/home/HomePage.kt | 2 +- .../feature/view/main/home/HomePageContent.kt | 99 ++++++++------ .../main/FamilyStudioPageController.kt | 12 +- .../main/FamilyStudioUploadPageController.kt | 8 +- .../main/HomePageController.kt | 4 +- .../post/CreateFamilyStudioPostViewModel.kt | 3 +- .../post/GetAiImageCountViewModel.kt | 4 +- .../post/GetAiImageTypesViewModel.kt | 29 ++++ 14 files changed, 228 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/com/no5ing/bbibbi/data/model/post/AIPostType.kt create mode 100644 app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/GetAiImageTypesViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b6d66af0..ee4ccc7a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,7 +47,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 11015 - versionName = "1.4.1" + versionName = "1.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/RestAPI.kt b/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/RestAPI.kt index 77d68b9f..09074a35 100644 --- a/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/RestAPI.kt +++ b/app/src/main/java/com/no5ing/bbibbi/data/datasource/network/RestAPI.kt @@ -36,6 +36,7 @@ import com.no5ing.bbibbi.data.model.notification.NotificationModel import com.no5ing.bbibbi.data.model.post.AIImageCount import com.no5ing.bbibbi.data.model.post.AIImageResponse import com.no5ing.bbibbi.data.model.post.AIPost +import com.no5ing.bbibbi.data.model.post.AIPostType import com.no5ing.bbibbi.data.model.post.CalendarBanner import com.no5ing.bbibbi.data.model.post.CalendarElement import com.no5ing.bbibbi.data.model.post.DailyCalendarElement @@ -204,6 +205,7 @@ interface RestAPI { suspend fun createAiPost( @Body body: CreatePostRequest, @Query("type") type: String? = null, + @Query("aiPostType") aiPostType: String? = null, ): ApiResponse @POST("v1/posts/image-upload-request") @@ -334,10 +336,16 @@ interface RestAPI { @Query("size") size: Int?, @Query("memberId") memberId: String?, @Query("sort") sort: String? = "DESC", + @Query("aiPostType") aiPostType: String? = null, ): ApiResponse> @GET("v1/posts/ai-images/count") - suspend fun getAiImagePostCount(): ApiResponse + suspend fun getAiImagePostCount( + @Query("aiPostType") aiPostType: String? = null, + ): ApiResponse + + @GET("v1/posts/ai-images/types") + suspend fun getAiImageTypes(): ApiResponse> } /** diff --git a/app/src/main/java/com/no5ing/bbibbi/data/model/post/AIPostType.kt b/app/src/main/java/com/no5ing/bbibbi/data/model/post/AIPostType.kt new file mode 100644 index 00000000..49792aac --- /dev/null +++ b/app/src/main/java/com/no5ing/bbibbi/data/model/post/AIPostType.kt @@ -0,0 +1,23 @@ +package com.no5ing.bbibbi.data.model.post + +import com.no5ing.bbibbi.data.model.BaseModel + +data class AIPostType( + val aiPostType: String, + val imageUrl: String, + val startDate: String, + val name: String?, + val endDate: String, + val postCount: Int, +) : BaseModel() { + fun getTypeName(): String { + if (name != null) { + return name + } + return when (aiPostType.lowercase()) { + "chuseok_2025" -> "추석" + "christmas_2025" -> "크리스마스" + else -> "알 수 없는 유형" + } + } +} diff --git a/app/src/main/java/com/no5ing/bbibbi/data/repository/post/GetAIPostsRepository.kt b/app/src/main/java/com/no5ing/bbibbi/data/repository/post/GetAIPostsRepository.kt index 50e78c06..c322a874 100644 --- a/app/src/main/java/com/no5ing/bbibbi/data/repository/post/GetAIPostsRepository.kt +++ b/app/src/main/java/com/no5ing/bbibbi/data/repository/post/GetAIPostsRepository.kt @@ -63,6 +63,7 @@ class GetAIPostPagingSource @Inject constructor( memberId = null, page = loadParams.key ?: 1, size = loadParams.loadSize, + aiPostType = arguments.get("aiPostType"), ).mapSuccess { Pagination( currentPage = currentPage, diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/family_studio/FamilyStudioPage.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/family_studio/FamilyStudioPage.kt index 2f56bd60..aef147c6 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/family_studio/FamilyStudioPage.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/family_studio/FamilyStudioPage.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.no5ing.bbibbi.R +import coil.compose.AsyncImage import com.no5ing.bbibbi.data.model.post.AIPost import com.no5ing.bbibbi.data.repository.Arguments import com.no5ing.bbibbi.presentation.component.AIPhotoInfoBaloon @@ -45,39 +46,46 @@ import com.no5ing.bbibbi.presentation.component.button.CustomCTAButton import com.no5ing.bbibbi.presentation.feature.view.common.CustomAlertDialog import com.no5ing.bbibbi.presentation.feature.view_model.post.GetAIPostsViewModel import com.no5ing.bbibbi.presentation.feature.view_model.post.GetAiImageCountViewModel +import com.no5ing.bbibbi.presentation.feature.view_model.post.GetAiImageTypesViewModel import com.no5ing.bbibbi.presentation.theme.bbibbiScheme import com.no5ing.bbibbi.presentation.theme.bbibbiTypo import com.no5ing.bbibbi.util.LocalSessionState +import com.no5ing.bbibbi.util.asyncImagePainter import java.time.LocalDate @Composable fun FamilyStudioPage( + aiPostType: String = "", onDispose: () -> Unit = {}, onTapCreateImage: () -> Unit = {}, onTapAiPost: (AIPost) -> Unit = {}, postsViewModel: GetAIPostsViewModel = hiltViewModel(), aiImageCountViewModel: GetAiImageCountViewModel = hiltViewModel(), + aiImageTypesViewModel: GetAiImageTypesViewModel = hiltViewModel(), isTermDialogEnabled: State = mutableStateOf(true), onDisagreeTerm: () -> Unit = {}, onAgreeTerm: () -> Unit = {}, onClickTerm: () -> Unit = {}, ) { val aiImageState = aiImageCountViewModel.uiState.collectAsState() + val typesState = aiImageTypesViewModel.uiState.collectAsState() LaunchedEffect(Unit) { + val postArgs = Arguments( + arguments = if (aiPostType.isNotEmpty()) mapOf("aiPostType" to aiPostType.uppercase()) else emptyMap() + ) if (postsViewModel.isInitialize()) { - postsViewModel.invoke(Arguments()) + postsViewModel.invoke(postArgs) } else { postsViewModel.refresh() } - aiImageCountViewModel.invoke(Arguments()) - } - val photoCount = if(aiImageState.value.isReady()) { - aiImageState.value.data.familyAiImageCount - } else { - 0 + aiImageCountViewModel.invoke(postArgs) + aiImageTypesViewModel.invoke(Arguments()) } + val matchedType = if (typesState.value.isReady()) { + typesState.value.data.results.find { it.aiPostType == aiPostType } + } else null CustomAlertDialog( title = "이미지사용약관", description = "AI 이미지 기능을 사용하려면\n약관에 대한 동의가 필요해요", @@ -101,11 +109,8 @@ fun FamilyStudioPage( onDispose = onDispose, title = "가족 사진관" ) - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .verticalScroll(state = scrollState) - ) { + if (matchedType != null) { + val dateRange = formatDateRange(matchedType.startDate, matchedType.endDate) Column( modifier = Modifier.padding(vertical = 20.dp, horizontal = 20.dp) ) { @@ -122,14 +127,14 @@ fun FamilyStudioPage( .padding(vertical = 2.dp, horizontal = 6.dp) ) { Text( - text = "추석", + text = matchedType.getTypeName(), color = MaterialTheme.bbibbiScheme.backgroundPrimary, style = MaterialTheme.bbibbiTypo.bodyTwoBold, ) } Box(modifier = Modifier.width(6.dp)) Text( - text = "9/29~10/27", + text = dateRange, color = MaterialTheme.bbibbiScheme.textPrimary, style = MaterialTheme.bbibbiTypo.headTwoBold, ) @@ -137,63 +142,65 @@ fun FamilyStudioPage( AIPhotoInfoBaloon() } Text( - text = "${photoCount}개의 추억", + text = "${matchedType.postCount}개의 추억", color = MaterialTheme.bbibbiScheme.textPrimary, style = MaterialTheme.bbibbiTypo.bodyOneRegular, ) - } Box(modifier = Modifier.height(16.dp)) - Image( - painter = painterResource(id = R.drawable.family_studio_banner), + AsyncImage( + model = asyncImagePainter(source = matchedType.imageUrl), contentDescription = null, modifier = Modifier.fillMaxWidth(), - contentScale = ContentScale.FillWidth + contentScale = ContentScale.FillWidth, ) } - } FamilyStudioPageFeed( postItemsState = postsViewModel.uiState, onTapContent = onTapAiPost, onPullToRefresh = { - aiImageCountViewModel.invoke(Arguments()) + val refreshArgs = Arguments( + arguments = if (aiPostType.isNotEmpty()) mapOf("aiPostType" to aiPostType.uppercase()) else emptyMap() + ) + aiImageCountViewModel.invoke(refreshArgs) } ) } - Box( - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 15.dp) - .navigationBarsPadding() - .align(Alignment.BottomCenter) - ) { - - CustomCTAButton( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(vertical = 18.dp), - onClick = onTapCreateImage, - isActive = aiImageState.value.isReady() && aiImageState.value.data.hasAvailableImage() + if (isWithinDateRange(matchedType?.startDate, matchedType?.endDate)) { + Box( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 15.dp) + .navigationBarsPadding() + .align(Alignment.BottomCenter) ) { - Text( - text = "이미지 만들기", - color = MaterialTheme.bbibbiScheme.backgroundPrimary, - style = MaterialTheme.bbibbiTypo.bodyOneBold, - ) - Image( - painter = painterResource(id = R.drawable.ai), - contentDescription = null, - modifier = Modifier.size(22.dp), - contentScale = ContentScale.FillWidth - ) - if (aiImageState.value.isReady()) { - val count = aiImageState.value.data - Spacer(modifier = Modifier.width(3.dp)) + CustomCTAButton( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = 18.dp), + onClick = onTapCreateImage, + isActive = aiImageState.value.isReady() && aiImageState.value.data.hasAvailableImage() + ) { Text( - text = "(${count.availableAiImageCount}/3)", + text = "이미지 만들기", color = MaterialTheme.bbibbiScheme.backgroundPrimary, - style = MaterialTheme.bbibbiTypo.bodyTwoRegular, + style = MaterialTheme.bbibbiTypo.bodyOneBold, ) + Image( + painter = painterResource(id = R.drawable.ai), + contentDescription = null, + modifier = Modifier.size(22.dp), + contentScale = ContentScale.FillWidth + ) + if (aiImageState.value.isReady()) { + val count = aiImageState.value.data + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = "(${count.availableAiImageCount}/3)", + color = MaterialTheme.bbibbiScheme.backgroundPrimary, + style = MaterialTheme.bbibbiTypo.bodyTwoRegular, + ) + } } } } @@ -204,6 +211,28 @@ fun FamilyStudioPage( } } +private fun isWithinDateRange(startDate: String?, endDate: String?): Boolean { + if (startDate == null || endDate == null) return false + return try { + val today = LocalDate.now() + val start = LocalDate.parse(startDate) + val end = LocalDate.parse(endDate) + !today.isBefore(start) && !today.isAfter(end) + } catch (e: Exception) { + false + } +} + +private fun formatDateRange(startDate: String, endDate: String): String { + return try { + val start = LocalDate.parse(startDate) + val end = LocalDate.parse(endDate) + "${start.monthValue}/${start.dayOfMonth}~${end.monthValue}/${end.dayOfMonth}" + } catch (e: Exception) { + "$startDate~$endDate" + } +} + @Preview( showBackground = true, name = "FamilyStudioPage", diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/family_studio_upload/FamilyStudioUploadPage.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/family_studio_upload/FamilyStudioUploadPage.kt index 1abb0b55..7a446cfb 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/family_studio_upload/FamilyStudioUploadPage.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/family_studio_upload/FamilyStudioUploadPage.kt @@ -65,6 +65,7 @@ import java.util.UUID @Composable fun FamilyStudioUploadPage( + aiPostType: String = "", onDispose: () -> Unit, imageUrl: State, convertAIImageViewModel: ConvertAIImageViewModel = hiltViewModel(), @@ -141,14 +142,14 @@ fun FamilyStudioUploadPage( isSaveIdle = convertResult.value.isReady(), onClickUpload = { mixPanel.track("Click_UploadPhoto") - createPostViewModel.invoke( - Arguments( - arguments = mapOf( - "imageUrl" to convertResult.value.data.imageUrl, - "type" to "AI_IMAGE" - ) - ) + val args = mutableMapOf( + "imageUrl" to convertResult.value.data.imageUrl, + "type" to "AI_IMAGE", ) + if (aiPostType.isNotEmpty()) { + args["aiPostType"] = aiPostType + } + createPostViewModel.invoke(Arguments(arguments = args)) }, onClickSave = { coroutineScope.launch { diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/home/HomePage.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/home/HomePage.kt index c36ff26b..3190f31d 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/home/HomePage.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/home/HomePage.kt @@ -53,7 +53,7 @@ fun HomePage( onTapViewPost: (LocalDate) -> Unit = {}, onTapPick: (MainPageTopBarModel) -> Unit = {}, onTapNight: () -> Unit = {}, - onTapFamilyStudio: () -> Unit = {}, + onTapFamilyStudio: (String) -> Unit = {}, ) { val postViewType by postViewTypeState val mainPageState = mainPageViewModel.uiState.collectAsState() diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/home/HomePageContent.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/home/HomePageContent.kt index 15e46118..adde22c8 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/home/HomePageContent.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view/main/home/HomePageContent.kt @@ -2,6 +2,7 @@ package com.no5ing.bbibbi.presentation.feature.view.main.home import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image +import coil.compose.AsyncImage import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -61,7 +62,8 @@ import com.no5ing.bbibbi.presentation.component.AIPhotoInfoBaloon import com.no5ing.bbibbi.presentation.component.BannerAd import com.no5ing.bbibbi.presentation.component.VerticalGrid import com.no5ing.bbibbi.presentation.feature.view.common.PostTypeSwitchButton -import com.no5ing.bbibbi.presentation.feature.view_model.post.GetAiImageCountViewModel +import com.no5ing.bbibbi.presentation.feature.view_model.post.GetAiImageTypesViewModel +import com.no5ing.bbibbi.util.asyncImagePainter import com.no5ing.bbibbi.presentation.theme.bbibbiScheme import com.no5ing.bbibbi.presentation.theme.bbibbiTypo import com.no5ing.bbibbi.util.gapBetweenNow @@ -79,7 +81,7 @@ fun HomePageContent( onTapProfile: (String) -> Unit = {}, onTapPick: (MainPageTopBarModel) -> Unit = {}, onTapInvite: () -> Unit = {}, - onTapAi: () -> Unit = {}, + onTapAi: (String) -> Unit = {}, onRefresh: () -> Unit = {}, ) { val warningState = remember { @@ -305,64 +307,77 @@ fun MissionFeedTab( @Composable fun AIImageTab( - onTap: () -> Unit, - aiImageCountViewModel: GetAiImageCountViewModel = hiltViewModel(), + onTap: (String) -> Unit, + aiImageTypesViewModel: GetAiImageTypesViewModel = hiltViewModel(), ) { - val aiImageState = aiImageCountViewModel.uiState.collectAsState() + val typesState = aiImageTypesViewModel.uiState.collectAsState() LaunchedEffect(Unit) { - aiImageCountViewModel.invoke(Arguments()) - } - val photoCount = if(aiImageState.value.isReady()) { - aiImageState.value.data.familyAiImageCount - } else { - 0 + aiImageTypesViewModel.invoke(Arguments()) } + if (!typesState.value.isReady()) return + val types = typesState.value.data.results + if (types.isEmpty()) return + Column( - modifier = Modifier.padding(vertical = 20.dp, horizontal = 20.dp) + modifier = Modifier + .padding(vertical = 20.dp, horizontal = 20.dp) ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { + types.forEach { aiType -> + val dateRange = formatDateRange(aiType.startDate, aiType.endDate) Row( + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { - Box(modifier = Modifier - .background(MaterialTheme.bbibbiScheme.icon, RoundedCornerShape(100.dp)) - .padding(vertical = 2.dp, horizontal = 6.dp) + Row( + verticalAlignment = Alignment.CenterVertically, ) { + Box(modifier = Modifier + .background(MaterialTheme.bbibbiScheme.icon, RoundedCornerShape(100.dp)) + .padding(vertical = 2.dp, horizontal = 6.dp) + ) { + Text( + text = aiType.getTypeName(), + color = MaterialTheme.bbibbiScheme.backgroundPrimary, + style = MaterialTheme.bbibbiTypo.bodyTwoBold, + ) + } + Box(modifier = Modifier.width(6.dp)) Text( - text = "추석", - color = MaterialTheme.bbibbiScheme.backgroundPrimary, - style = MaterialTheme.bbibbiTypo.bodyTwoBold, + text = dateRange, + color = MaterialTheme.bbibbiScheme.textPrimary, + style = MaterialTheme.bbibbiTypo.headTwoBold, ) + Spacer(modifier = Modifier.width(4.dp)) + AIPhotoInfoBaloon() } - Box(modifier = Modifier.width(6.dp)) Text( - text = "9/29~10/27", + text = "${aiType.postCount}개의 추억", color = MaterialTheme.bbibbiScheme.textPrimary, - style = MaterialTheme.bbibbiTypo.headTwoBold, + style = MaterialTheme.bbibbiTypo.bodyOneRegular, ) - Spacer(modifier = Modifier.width(4.dp)) - AIPhotoInfoBaloon() } - Text( - text = "${photoCount}개의 추억", - color = MaterialTheme.bbibbiScheme.textPrimary, - style = MaterialTheme.bbibbiTypo.bodyOneRegular, + Box(modifier = Modifier.height(16.dp)) + AsyncImage( + model = asyncImagePainter(source = aiType.imageUrl), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clickable { onTap(aiType.aiPostType) }, + contentScale = ContentScale.FillWidth, ) - + Box(modifier = Modifier.height(24.dp)) } - Box(modifier = Modifier.height(16.dp)) - Image( - painter = painterResource(id = R.drawable.family_studio_banner), - contentDescription = null, - modifier = Modifier.fillMaxWidth().clickable { - onTap() - }, - contentScale = ContentScale.FillWidth - ) + } +} + +private fun formatDateRange(startDate: String, endDate: String): String { + return try { + val start = LocalDate.parse(startDate) + val end = LocalDate.parse(endDate) + "${start.monthValue}/${start.dayOfMonth}~${end.monthValue}/${end.dayOfMonth}" + } catch (e: Exception) { + "$startDate~$endDate" } } diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/FamilyStudioPageController.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/FamilyStudioPageController.kt index fd13afbb..c26cba03 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/FamilyStudioPageController.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/FamilyStudioPageController.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.remember import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController +import androidx.navigation.navArgument import com.no5ing.bbibbi.BuildConfig import com.no5ing.bbibbi.presentation.feature.view.main.family_studio.FamilyStudioPage import com.no5ing.bbibbi.presentation.feature.view_controller.NavigationDestination @@ -17,23 +18,26 @@ import com.no5ing.bbibbi.presentation.feature.view_controller.main.WebViewPageCo import com.no5ing.bbibbi.presentation.feature.view_model.post.GetAiImageCountViewModel object FamilyStudioPageController: NavigationDestination( - route = mainFamilyStudioPageRoute + route = mainFamilyStudioPageRoute, + arguments = listOf(navArgument("aiPostType") {}), ) { @Composable override fun Render(navController: NavHostController, backStackEntry: NavBackStackEntry) { + val aiPostType = backStackEntry.arguments?.getString("aiPostType") ?: "" val aiImageCountViewModel = hiltViewModel() val dialogState = remember { mutableStateOf(false) } if (aiImageCountViewModel.shouldShowTermDialog()) { dialogState.value = true } FamilyStudioPage( + aiPostType = aiPostType, isTermDialogEnabled = dialogState, aiImageCountViewModel = aiImageCountViewModel, onDispose = { navController.popBackStack() }, onTapCreateImage = { - navController.goFamilyStudioUploadPage() + navController.goFamilyStudioUploadPage(aiPostType) navController.goFamilyStudioCameraPage() }, onTapAiPost = { @@ -52,7 +56,7 @@ object FamilyStudioPageController: NavigationDestination( ) } - fun NavHostController.goFamilyStudioPage() { - navigate(FamilyStudioPageController) + fun NavHostController.goFamilyStudioPage(aiPostType: String) { + navigate(FamilyStudioPageController, params = listOf("aiPostType" to aiPostType)) } } \ No newline at end of file diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/FamilyStudioUploadPageController.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/FamilyStudioUploadPageController.kt index f12bc335..894b092a 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/FamilyStudioUploadPageController.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/FamilyStudioUploadPageController.kt @@ -5,18 +5,22 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.livedata.observeAsState import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController +import androidx.navigation.navArgument import com.no5ing.bbibbi.presentation.feature.view.main.family_studio_upload.FamilyStudioUploadPage import com.no5ing.bbibbi.presentation.feature.view_controller.NavigationDestination object FamilyStudioUploadPageController : NavigationDestination( route = mainFamilyStudioUploadPageRoute, + arguments = listOf(navArgument("aiPostType") {}), ) { @Composable override fun Render(navController: NavHostController, backStackEntry: NavBackStackEntry) { + val aiPostType = backStackEntry.arguments?.getString("aiPostType") ?: "" val imageCaptureState = backStackEntry.savedStateHandle .getLiveData("imageUrl") .observeAsState() FamilyStudioUploadPage( + aiPostType = aiPostType, imageUrl = imageCaptureState, onDispose = { navController.popBackStack() @@ -24,7 +28,7 @@ object FamilyStudioUploadPageController : NavigationDestination( ) } - fun NavHostController.goFamilyStudioUploadPage() { - navigate(FamilyStudioUploadPageController) + fun NavHostController.goFamilyStudioUploadPage(aiPostType: String) { + navigate(FamilyStudioUploadPageController, params = listOf("aiPostType" to aiPostType)) } } diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/HomePageController.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/HomePageController.kt index caf0327a..859471a6 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/HomePageController.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_controller/main/HomePageController.kt @@ -215,8 +215,8 @@ object HomePageController : NavigationDestination( onTapNight = { isNightTimeDialogVisible = true }, - onTapFamilyStudio = { - navController.goFamilyStudioPage() + onTapFamilyStudio = { aiPostType -> + navController.goFamilyStudioPage(aiPostType) } ) } diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/CreateFamilyStudioPostViewModel.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/CreateFamilyStudioPostViewModel.kt index 9b80525f..429f3003 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/CreateFamilyStudioPostViewModel.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/CreateFamilyStudioPostViewModel.kt @@ -35,7 +35,8 @@ class CreateFamilyStudioPostViewModel @Inject constructor( content = "", uploadTime = getZonedDateTimeString(), ), - type = arguments.get("type") + type = arguments.get("type"), + aiPostType = arguments.get("aiPostType")?.uppercase(), ).wrapToAPIResponse() setState(postResult) } diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/GetAiImageCountViewModel.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/GetAiImageCountViewModel.kt index b785dede..6061b290 100644 --- a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/GetAiImageCountViewModel.kt +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/GetAiImageCountViewModel.kt @@ -29,7 +29,9 @@ class GetAiImageCountViewModel @Inject constructor( override fun invoke(arguments: Arguments) { withMutexScope(Dispatchers.IO) { - val result = restAPI.getPostApi().getAiImagePostCount() + val result = restAPI.getPostApi().getAiImagePostCount( + aiPostType = arguments.get("aiPostType"), + ) setState(result.wrapToAPIResponse()) } } diff --git a/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/GetAiImageTypesViewModel.kt b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/GetAiImageTypesViewModel.kt new file mode 100644 index 00000000..614868d4 --- /dev/null +++ b/app/src/main/java/com/no5ing/bbibbi/presentation/feature/view_model/post/GetAiImageTypesViewModel.kt @@ -0,0 +1,29 @@ +package com.no5ing.bbibbi.presentation.feature.view_model.post + +import com.no5ing.bbibbi.data.datasource.network.RestAPI +import com.no5ing.bbibbi.data.datasource.network.response.ArrayResponse +import com.no5ing.bbibbi.data.model.APIResponse +import com.no5ing.bbibbi.data.model.APIResponse.Companion.wrapToAPIResponse +import com.no5ing.bbibbi.data.model.post.AIPostType +import com.no5ing.bbibbi.data.repository.Arguments +import com.no5ing.bbibbi.presentation.feature.view_model.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import javax.inject.Inject + +@HiltViewModel +class GetAiImageTypesViewModel @Inject constructor( + private val restAPI: RestAPI, +) : BaseViewModel>>() { + + override fun initState(): APIResponse> { + return APIResponse.idle() + } + + override fun invoke(arguments: Arguments) { + withMutexScope(Dispatchers.IO) { + val result = restAPI.getPostApi().getAiImageTypes() + setState(result.wrapToAPIResponse()) + } + } +} From 1f9144aa90a1335fd19f795c3e7c7e322a0c52a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=98=81=EB=AF=BC?= Date: Sat, 28 Mar 2026 18:34:56 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/no5ing/bbibbi/data/model/post/AIPostType.kt | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/no5ing/bbibbi/data/model/post/AIPostType.kt b/app/src/main/java/com/no5ing/bbibbi/data/model/post/AIPostType.kt index 49792aac..f89ad100 100644 --- a/app/src/main/java/com/no5ing/bbibbi/data/model/post/AIPostType.kt +++ b/app/src/main/java/com/no5ing/bbibbi/data/model/post/AIPostType.kt @@ -6,18 +6,11 @@ data class AIPostType( val aiPostType: String, val imageUrl: String, val startDate: String, - val name: String?, + val aiPostTheme: String, val endDate: String, val postCount: Int, ) : BaseModel() { fun getTypeName(): String { - if (name != null) { - return name - } - return when (aiPostType.lowercase()) { - "chuseok_2025" -> "추석" - "christmas_2025" -> "크리스마스" - else -> "알 수 없는 유형" - } + return aiPostType } }