diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/AuthService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/AuthService.kt index 3968755..74ddc89 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/datasource/AuthService.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/AuthService.kt @@ -2,6 +2,8 @@ package kr.ac.anu.mumu.data.datasource import kr.ac.anu.mumu.data.model.LoginRequest import kr.ac.anu.mumu.data.model.LoginResponse +import kr.ac.anu.mumu.data.model.TokenRefreshRequest +import retrofit2.Call import retrofit2.Response import retrofit2.http.Body import retrofit2.http.POST @@ -9,4 +11,7 @@ import retrofit2.http.POST interface AuthService { @POST("/api/auth/login") suspend fun login(@Body request: LoginRequest): Response + + @POST("/api/auth/refresh") + fun refresh(@Body request: TokenRefreshRequest): Call } diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt index 79b8188..8a68335 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt @@ -1,5 +1,6 @@ package kr.ac.anu.mumu.data.datasource +import com.google.gson.JsonObject import kr.ac.anu.mumu.data.model.BaseResponse import kr.ac.anu.mumu.data.model.BookmarkDto import kr.ac.anu.mumu.data.model.CommentDto @@ -8,10 +9,13 @@ import kr.ac.anu.mumu.data.model.CommunityPostDto import kr.ac.anu.mumu.data.model.CommunityRequestDto import kr.ac.anu.mumu.data.model.LikeDto import kr.ac.anu.mumu.data.model.PaginatedData +import kr.ac.anu.mumu.data.model.UserProfileDto import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query @@ -35,6 +39,17 @@ interface CommunityService { @Body request: CommunityRequestDto ): Response> + @PUT("/api/community/{communityId}") + suspend fun updatePost( + @Path("communityId") communityId: Long, + @Body request: CommunityRequestDto + ): Response> + + @DELETE("/api/community/{communityId}") + suspend fun deletePost( + @Path("communityId") communityId: Long + ): Response> + @POST("/api/likes/community/{communityId}") suspend fun toggleLike( @Path("communityId") communityId: Long @@ -55,4 +70,20 @@ interface CommunityService { @Path("communityId") communityId: Long, @Body request: CommentRequestDto ): Response> + + @PUT("/api/community/{communityId}/comments/{commentId}") + suspend fun updateComment( + @Path("communityId") communityId: Long, + @Path("commentId") commentId: Long, + @Body request: CommentRequestDto + ): Response> + + @DELETE("/api/community/{communityId}/comments/{commentId}") + suspend fun deleteComment( + @Path("communityId") communityId: Long, + @Path("commentId") commentId: Long + ): Response> + + @GET("/api/users/profile") + suspend fun getMyProfile(): Response> } diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/DiaryService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/DiaryService.kt new file mode 100644 index 0000000..b8a3b75 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/DiaryService.kt @@ -0,0 +1,55 @@ +package kr.ac.anu.mumu.data.datasource + +import kr.ac.anu.mumu.data.model.BaseResponse +import kr.ac.anu.mumu.data.model.DiaryCalendarDto +import kr.ac.anu.mumu.data.model.DiaryDetailDto +import kr.ac.anu.mumu.data.model.DiaryListDto +import kr.ac.anu.mumu.data.model.DiaryRequestDto +import kr.ac.anu.mumu.data.model.PaginatedData +import kr.ac.anu.mumu.data.model.UploadResponseDto +import okhttp3.MultipartBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +interface DiaryService { + @Multipart + @POST("/api/upload/diary") + suspend fun uploadDiaryImage(@Part file: MultipartBody.Part): Response> + + @GET("/api/diaries/calendar") + suspend fun getCalendar( + @Query("petId") petId: Long, + @Query("year") year: Int, + @Query("month") month: Int + ): Response> + + @GET("/api/diaries") + suspend fun getDiaries( + @Query("petId") petId: Long, + @Query("page") page: Int = 0, + @Query("size") size: Int = 20 + ): Response>> + + @GET("/api/diaries/{diaryId}") + suspend fun getDiary(@Path("diaryId") diaryId: Long): Response> + + @POST("/api/diaries") + suspend fun createDiary(@Body request: DiaryRequestDto): Response> + + @PUT("/api/diaries/{diaryId}") + suspend fun updateDiary( + @Path("diaryId") diaryId: Long, + @Body request: DiaryRequestDto + ): Response> + + @DELETE("/api/diaries/{diaryId}") + suspend fun deleteDiary(@Path("diaryId") diaryId: Long): Response +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/HospitalService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/HospitalService.kt new file mode 100644 index 0000000..7a87642 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/HospitalService.kt @@ -0,0 +1,37 @@ +package kr.ac.anu.mumu.data.datasource + +import kr.ac.anu.mumu.data.model.BaseResponse +import kr.ac.anu.mumu.data.model.HospitalDetailDto +import kr.ac.anu.mumu.data.model.HospitalListDto +import kr.ac.anu.mumu.data.model.HospitalPriceDto +import kr.ac.anu.mumu.data.model.HospitalReviewDto +import kr.ac.anu.mumu.data.model.PaginatedData +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface HospitalService { + @GET("/api/hospitals") + suspend fun search( + @Query("lat") lat: Double, + @Query("lng") lng: Double, + @Query("radius") radius: Double = 20.0, + @Query("keyword") keyword: String? = null, + @Query("page") page: Int = 0, + @Query("size") size: Int = 20 + ): Response>> + + @GET("/api/hospitals/{hospitalId}") + suspend fun getDetail(@Path("hospitalId") hospitalId: Long): Response> + + @GET("/api/hospitals/{hospitalId}/prices") + suspend fun getPrices(@Path("hospitalId") hospitalId: Long): Response>> + + @GET("/api/hospitals/{hospitalId}/reviews") + suspend fun getReviews( + @Path("hospitalId") hospitalId: Long, + @Query("page") page: Int = 0, + @Query("size") size: Int = 10 + ): Response>> +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/PetService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/PetService.kt index 8ac62a7..63de9b2 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/datasource/PetService.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/PetService.kt @@ -2,10 +2,28 @@ package kr.ac.anu.mumu.data.datasource import kr.ac.anu.mumu.data.model.BaseResponse import kr.ac.anu.mumu.data.model.PetDto +import kr.ac.anu.mumu.data.model.PetRequestDto import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path interface PetService { @GET("/api/pets") suspend fun getMyPets(): Response>> + + @POST("/api/pets") + suspend fun createPet(@Body request: PetRequestDto): Response> + + @PUT("/api/pets/{petId}") + suspend fun updatePet( + @Path("petId") petId: Long, + @Body request: PetRequestDto + ): Response> + + @DELETE("/api/pets/{petId}") + suspend fun deletePet(@Path("petId") petId: Long): Response> } diff --git a/app/src/main/java/kr/ac/anu/mumu/data/local/SessionAuthenticator.kt b/app/src/main/java/kr/ac/anu/mumu/data/local/SessionAuthenticator.kt new file mode 100644 index 0000000..139a432 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/local/SessionAuthenticator.kt @@ -0,0 +1,55 @@ +package kr.ac.anu.mumu.data.local + +import kr.ac.anu.mumu.data.datasource.AuthService +import kr.ac.anu.mumu.data.model.TokenRefreshRequest +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject + +class SessionAuthenticator @Inject constructor( + private val sessionManager: SessionManager, + private val authService: AuthService +) : Authenticator { + + override fun authenticate(route: Route?, response: Response): Request? { + val previousToken = response.request.header("Authorization")?.removePrefix("Bearer ") ?: return null + if (responseCount(response) >= 2) return null + + synchronized(this) { + val currentToken = sessionManager.accessToken ?: return null + if (currentToken != previousToken) { + return response.request.newBuilder().header("Authorization", "Bearer $currentToken").build() + } + + val refreshToken = sessionManager.refreshToken ?: return null + val result = try { + authService.refresh(TokenRefreshRequest(refreshToken)).execute() + } catch (_: Exception) { + return null + } + val body = result.body() + if (!result.isSuccessful || body?.success != true) { + if (result.code() in 400..403) sessionManager.clearTokens() + return null + } + if (sessionManager.refreshToken != refreshToken) return null + val tokens = body.data + sessionManager.saveTokens(tokens.accessToken, tokens.refreshToken, tokens.tokenType) + return response.request.newBuilder() + .header("Authorization", "Bearer ${tokens.accessToken}") + .build() + } + } + + private fun responseCount(response: Response): Int { + var current: Response? = response + var count = 0 + while (current != null) { + count++ + current = current.priorResponse + } + return count + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/local/SessionManager.kt b/app/src/main/java/kr/ac/anu/mumu/data/local/SessionManager.kt index 522e1d5..397c26f 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/local/SessionManager.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/local/SessionManager.kt @@ -14,6 +14,18 @@ class SessionManager @Inject constructor( val accessToken: String? get() = preferences.getString(KEY_ACCESS_TOKEN, null) + val refreshToken: String? + get() = preferences.getString(KEY_REFRESH_TOKEN, null) + + val selectedPetId: Long? + get() = preferences.getLong(KEY_SELECTED_PET_ID, -1L).takeIf { it > 0L } + + fun selectPet(petId: Long?) { + preferences.edit().apply { + if (petId == null) remove(KEY_SELECTED_PET_ID) else putLong(KEY_SELECTED_PET_ID, petId) + }.apply() + } + fun saveTokens(accessToken: String, refreshToken: String, tokenType: String) { preferences.edit() .putString(KEY_ACCESS_TOKEN, accessToken) @@ -22,10 +34,18 @@ class SessionManager @Inject constructor( .apply() } + fun clearTokens() { + preferences.edit() + .remove(KEY_ACCESS_TOKEN).remove(KEY_REFRESH_TOKEN).remove(KEY_TOKEN_TYPE) + .remove(KEY_SELECTED_PET_ID) + .apply() + } + private companion object { const val PREFERENCES_NAME = "mumu_session" const val KEY_ACCESS_TOKEN = "access_token" const val KEY_REFRESH_TOKEN = "refresh_token" const val KEY_TOKEN_TYPE = "token_type" + const val KEY_SELECTED_PET_ID = "selected_pet_id" } } diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/AnalysisDtos.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/AnalysisDtos.kt index 146c834..adb4cce 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/model/AnalysisDtos.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/AnalysisDtos.kt @@ -17,5 +17,28 @@ data class AnalysisDetailDto( data class PetDto( val petId: Long, - val name: String? + val name: String?, + val species: String? = null, + val breed: String? = null, + val gender: String? = null, + val birthDate: String? = null, + val weight: Double? = null, + val neutered: Boolean = false, + val allergies: String? = null, + val chronicDiseases: String? = null, + val medications: String? = null, + val profileImageUrl: String? = null +) + +data class PetRequestDto( + val name: String, + val species: String, + val breed: String?, + val gender: String?, + val birthDate: String?, + val weight: Double?, + val neutered: Boolean, + val allergies: String?, + val chronicDiseases: String?, + val medications: String? ) diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/AuthDtos.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/AuthDtos.kt index 7b07c19..4de598f 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/model/AuthDtos.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/AuthDtos.kt @@ -13,6 +13,8 @@ data class LoginResponse( val data: TokenDto ) +data class TokenRefreshRequest(val refreshToken: String) + data class TokenDto( val accessToken: String, val refreshToken: String, diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt index 2712a47..a8c2bdc 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt @@ -51,3 +51,10 @@ data class BookmarkDto( val bookmarked: Boolean, val bookmarkCount: Int ) + +data class UserProfileDto( + val userId: Long, + val loginId: String, + val name: String, + val phone: String +) diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/DiaryDtos.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/DiaryDtos.kt new file mode 100644 index 0000000..ae37242 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/DiaryDtos.kt @@ -0,0 +1,48 @@ +package kr.ac.anu.mumu.data.model + +data class UploadResponseDto(val key: String?, val url: String?) + +data class DiaryRequestDto( + val petId: Long, + val mood: String, + val title: String, + val content: String, + val diaryDate: String, + val imageKeys: List? = null, + val behaviorAnalysisId: Long? = null, + val soundAnalysisId: Long? = null, + val foodSafetyAnalysisId: Long? = null +) + +data class DiaryAnalysisSummaryDto( + val analysisId: Long, + val type: String, + val resultLabel: String? +) + +data class DiaryListDto( + val diaryId: Long, + val mood: String, + val title: String, + val contentPreview: String?, + val diaryDate: String, + val thumbnailUrl: String? +) + +data class DiaryDetailDto( + val diaryId: Long, + val petId: Long, + val mood: String, + val title: String, + val content: String, + val diaryDate: String, + val imageUrls: List?, + val analysisSummary: DiaryAnalysisSummaryDto? +) + +data class DiaryCalendarDto( + val petId: Long, + val year: Int, + val month: Int, + val writtenDates: List +) diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/HospitalDtos.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/HospitalDtos.kt new file mode 100644 index 0000000..6b80f46 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/HospitalDtos.kt @@ -0,0 +1,45 @@ +package kr.ac.anu.mumu.data.model + +data class HospitalListDto( + val hospitalId: Long, + val name: String, + val address: String?, + val phone: String?, + val latitude: Double?, + val longitude: Double?, + val distanceKm: Double?, + val averageRating: Double?, + val totalReviews: Int?, + val thumbnailUrl: String? +) + +data class HospitalDetailDto( + val hospitalId: Long, + val name: String, + val address: String?, + val phone: String?, + val website: String?, + val openingHours: Any?, + val specialties: List?, + val averageRating: Double?, + val totalReviews: Int?, + val imageUrls: List? +) + +data class HospitalPriceDto( + val hospitalPriceId: Long, + val treatmentType: String, + val species: String?, + val minPrice: Double?, + val maxPrice: Double?, + val averagePrice: Double?, + val description: String? +) + +data class HospitalReviewDto( + val reviewId: Long, + val userId: Long, + val rating: Int, + val content: String?, + val createdAt: String? +) diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/PetSelection.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/PetSelection.kt new file mode 100644 index 0000000..7117943 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/PetSelection.kt @@ -0,0 +1,4 @@ +package kr.ac.anu.mumu.data.model + +fun List.selectedPetId(preferredId: Long?): Long? = + firstOrNull { it.petId == preferredId }?.petId ?: firstOrNull()?.petId diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/AnalysisRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/AnalysisRepositoryImpl.kt index 52805ad..3b6c035 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/repository/AnalysisRepositoryImpl.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/AnalysisRepositoryImpl.kt @@ -9,7 +9,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kr.ac.anu.mumu.data.datasource.AnalysisService import kr.ac.anu.mumu.data.datasource.PetService +import kr.ac.anu.mumu.data.local.SessionManager import kr.ac.anu.mumu.data.model.AnalysisDetailDto +import kr.ac.anu.mumu.data.model.selectedPetId import kr.ac.anu.mumu.domain.model.BehaviorAnalysisResult import kr.ac.anu.mumu.domain.repository.AnalysisRepository import okhttp3.MediaType @@ -24,7 +26,8 @@ import kotlin.math.roundToInt class AnalysisRepositoryImpl @Inject constructor( @ApplicationContext private val context: Context, private val analysisService: AnalysisService, - private val petService: PetService + private val petService: PetService, + private val sessionManager: SessionManager ) : AnalysisRepository { override suspend fun analyzeBehavior(videoUri: String): Result { @@ -58,7 +61,8 @@ class AnalysisRepositoryImpl @Inject constructor( if (!response.isSuccessful || body?.success != true) { error(body?.message ?: "반려동물 정보를 불러오지 못했습니다. (${response.code()})") } - return body.data?.firstOrNull()?.petId + val pets = body.data.orEmpty() + return pets.selectedPetId(sessionManager.selectedPetId) ?: error("분석할 반려동물을 먼저 등록해 주세요.") } diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt index b2d5bb7..212490f 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt @@ -49,6 +49,24 @@ class CommunityRepositoryImpl @Inject constructor( response.requireData("게시글을 등록하지 못했습니다.").toDomain() } + override suspend fun updatePost( + postId: Long, + category: String, + title: String, + content: String, + hashtags: List, + petId: Long? + ): Result = runCatching { + communityService.updatePost( + postId, + CommunityRequestDto(category, title, content, hashtags, petId) + ).requireData("게시글을 수정하지 못했습니다.").toDomain() + } + + override suspend fun deletePost(postId: Long): Result = runCatching { + communityService.deletePost(postId).requireSuccess("게시글을 삭제하지 못했습니다.") + } + override suspend fun toggleLike(postId: Long): Result> = runCatching { val data = communityService.toggleLike(postId).requireData("좋아요 처리에 실패했습니다.") data.liked to data.likeCount @@ -74,9 +92,28 @@ class CommunityRepositoryImpl @Inject constructor( .toDomain() } + override suspend fun updateComment( + postId: Long, + commentId: Long, + content: String + ): Result = runCatching { + communityService.updateComment(postId, commentId, CommentRequestDto(content)) + .requireData("댓글을 수정하지 못했습니다.") + .toDomain() + } + + override suspend fun deleteComment(postId: Long, commentId: Long): Result = runCatching { + communityService.deleteComment(postId, commentId).requireSuccess("댓글을 삭제하지 못했습니다.") + } + + override suspend fun getCurrentUserId(): Result = runCatching { + communityService.getMyProfile().requireData("사용자 정보를 불러오지 못했습니다.").userId + } + private fun CommunityPostDto.toDomain() = CommunityPost( id = communityId, userId = userId, + petId = petId, title = title, content = content, category = category, @@ -106,4 +143,13 @@ class CommunityRepositoryImpl @Inject constructor( } return body.data ?: error("서버 응답이 비어 있습니다.") } + + private fun retrofit2.Response>.requireSuccess( + fallbackMessage: String + ) { + val body = body() + if (!isSuccessful || body?.success != true) { + error(body?.message ?: "$fallbackMessage (${code()})") + } + } } diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/DiaryRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/DiaryRepositoryImpl.kt new file mode 100644 index 0000000..601bc42 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/DiaryRepositoryImpl.kt @@ -0,0 +1,93 @@ +package kr.ac.anu.mumu.data.repository + +import android.content.Context +import android.net.Uri +import androidx.annotation.WorkerThread +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kr.ac.anu.mumu.data.datasource.DiaryService +import kr.ac.anu.mumu.data.model.BaseResponse +import kr.ac.anu.mumu.data.model.DiaryCalendarDto +import kr.ac.anu.mumu.data.model.DiaryDetailDto +import kr.ac.anu.mumu.data.model.DiaryListDto +import kr.ac.anu.mumu.data.model.DiaryRequestDto +import kr.ac.anu.mumu.data.model.PaginatedData +import kr.ac.anu.mumu.domain.repository.DiaryRepository +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.Response +import java.io.ByteArrayOutputStream +import javax.inject.Inject + +class DiaryRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val service: DiaryService +) : DiaryRepository { + override suspend fun uploadDiaryImage(uri: Uri): Result = withContext(Dispatchers.IO) { + runCatching { + val type = context.contentResolver.getType(uri) ?: error("사진 형식을 확인할 수 없습니다.") + if (type !in setOf("image/jpeg", "image/png", "image/webp")) { + error("JPG, PNG, WebP 사진만 첨부할 수 있습니다.") + } + val bytes = readLimitedImage(uri) + val extension = when (type) { + "image/png" -> "png" + "image/webp" -> "webp" + else -> "jpg" + } + val part = MultipartBody.Part.createFormData( + "file", + "diary-photo.$extension", + bytes.toRequestBody(type.toMediaType()) + ) + service.uploadDiaryImage(part).requireData("사진을 업로드하지 못했습니다.").key + ?.takeIf { it.isNotBlank() } ?: error("서버가 사진 키를 반환하지 않았습니다.") + } + } + + @WorkerThread + private fun readLimitedImage(uri: Uri): ByteArray { + val input = context.contentResolver.openInputStream(uri) ?: error("선택한 사진을 열 수 없습니다.") + return input.use { source -> + val output = ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (true) { + val count = source.read(buffer) + if (count < 0) break + if (output.size() + count > 10 * 1024 * 1024) error("사진은 10MB 이하만 첨부할 수 있습니다.") + output.write(buffer, 0, count) + } + output.toByteArray() + } + } + + override suspend fun getCalendar(petId: Long, year: Int, month: Int): Result = runCatching { + service.getCalendar(petId, year, month).requireData("일기 달력을 불러오지 못했습니다.") + } + + override suspend fun getDiaries(petId: Long, page: Int): Result> = runCatching { + service.getDiaries(petId, page).requireData("일기를 불러오지 못했습니다.") + } + + override suspend fun getDiary(diaryId: Long): Result = runCatching { + service.getDiary(diaryId).requireData("일기를 불러오지 못했습니다.") + } + + override suspend fun saveDiary(diaryId: Long?, request: DiaryRequestDto): Result = runCatching { + val response = if (diaryId == null) service.createDiary(request) else service.updateDiary(diaryId, request) + response.requireData("일기를 저장하지 못했습니다.") + } + + override suspend fun deleteDiary(diaryId: Long): Result = runCatching { + val response = service.deleteDiary(diaryId) + if (!response.isSuccessful) error("일기를 삭제하지 못했습니다. (${response.code()})") + } + + private fun Response>.requireData(message: String): T { + val body = body() + if (!isSuccessful || body?.success != true) error(body?.message ?: "$message (${code()})") + return body.data ?: error("서버 응답이 비어 있습니다.") + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/HistoryRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/HistoryRepositoryImpl.kt index 1714d56..f4c5245 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/repository/HistoryRepositoryImpl.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/HistoryRepositoryImpl.kt @@ -3,8 +3,10 @@ package kr.ac.anu.mumu.data.repository import kr.ac.anu.mumu.data.datasource.AnalysisService import kr.ac.anu.mumu.data.datasource.HistoryService import kr.ac.anu.mumu.data.datasource.PetService +import kr.ac.anu.mumu.data.local.SessionManager import kr.ac.anu.mumu.data.mapper.toDomain import kr.ac.anu.mumu.data.model.AnalysisHistoryDto +import kr.ac.anu.mumu.data.model.selectedPetId import kr.ac.anu.mumu.domain.model.AnalysisHistory import kr.ac.anu.mumu.domain.repository.HistoryRepository import javax.inject.Inject @@ -12,7 +14,8 @@ import javax.inject.Inject class HistoryRepositoryImpl @Inject constructor( private val historyService: HistoryService, private val analysisService: AnalysisService, - private val petService: PetService + private val petService: PetService, + private val sessionManager: SessionManager ) : HistoryRepository { override suspend fun getBehaviorHistory(): Result> = runCatching { @@ -49,7 +52,8 @@ class HistoryRepositoryImpl @Inject constructor( if (!response.isSuccessful || body?.success != true) { error(body?.message ?: "반려동물 정보를 불러오지 못했습니다. (${response.code()})") } - return body.data?.firstOrNull()?.petId + val pets = body.data.orEmpty() + return pets.selectedPetId(sessionManager.selectedPetId) ?: error("분석 내역을 확인할 반려동물을 먼저 등록해 주세요.") } } diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/HospitalRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/HospitalRepositoryImpl.kt new file mode 100644 index 0000000..96e0ba8 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/HospitalRepositoryImpl.kt @@ -0,0 +1,41 @@ +package kr.ac.anu.mumu.data.repository + +import kr.ac.anu.mumu.data.datasource.HospitalService +import kr.ac.anu.mumu.data.model.BaseResponse +import kr.ac.anu.mumu.data.model.HospitalDetailDto +import kr.ac.anu.mumu.data.model.HospitalListDto +import kr.ac.anu.mumu.data.model.HospitalPriceDto +import kr.ac.anu.mumu.data.model.HospitalReviewDto +import kr.ac.anu.mumu.data.model.PaginatedData +import kr.ac.anu.mumu.domain.repository.HospitalRepository +import retrofit2.Response +import javax.inject.Inject + +class HospitalRepositoryImpl @Inject constructor(private val service: HospitalService) : HospitalRepository { + override suspend fun search(keyword: String?, page: Int): Result> = runCatching { + service.search(ANDONG_LAT, ANDONG_LNG, keyword = keyword, page = page).requireData("병원을 찾지 못했습니다.") + } + + override suspend fun getDetail(hospitalId: Long): Result = runCatching { + service.getDetail(hospitalId).requireData("병원 정보를 불러오지 못했습니다.") + } + + override suspend fun getPrices(hospitalId: Long): Result> = runCatching { + service.getPrices(hospitalId).requireData("진료비를 불러오지 못했습니다.") + } + + override suspend fun getReviews(hospitalId: Long): Result> = runCatching { + service.getReviews(hospitalId).requireData("리뷰를 불러오지 못했습니다.").content + } + + private fun Response>.requireData(message: String): T { + val body = body() + if (!isSuccessful || body?.success != true) error(body?.message ?: "$message (${code()})") + return body.data ?: error("서버 응답이 비어 있습니다.") + } + + private companion object { + const val ANDONG_LAT = 36.568188 + const val ANDONG_LNG = 128.730261 + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/LoginRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/LoginRepositoryImpl.kt index 8ebbc0c..d5ff012 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/repository/LoginRepositoryImpl.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/LoginRepositoryImpl.kt @@ -20,6 +20,7 @@ class LoginRepositoryImpl @Inject constructor( val result = response.body()!! if (result.success) { + sessionManager.selectPet(null) sessionManager.saveTokens( accessToken = result.data.accessToken, refreshToken = result.data.refreshToken, diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/PetRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/PetRepositoryImpl.kt new file mode 100644 index 0000000..b88a6e5 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/PetRepositoryImpl.kt @@ -0,0 +1,36 @@ +package kr.ac.anu.mumu.data.repository + +import kr.ac.anu.mumu.data.datasource.PetService +import kr.ac.anu.mumu.data.model.BaseResponse +import kr.ac.anu.mumu.data.model.PetDto +import kr.ac.anu.mumu.data.model.PetRequestDto +import kr.ac.anu.mumu.domain.repository.PetRepository +import retrofit2.Response +import javax.inject.Inject + +class PetRepositoryImpl @Inject constructor( + private val service: PetService +) : PetRepository { + override suspend fun getPets(): Result> = runCatching { + service.getMyPets().requireData("반려동물을 불러오지 못했습니다.") + } + + override suspend fun savePet(petId: Long?, request: PetRequestDto): Result = runCatching { + val response = if (petId == null) service.createPet(request) else service.updatePet(petId, request) + response.requireData("반려동물을 저장하지 못했습니다.") + } + + override suspend fun deletePet(petId: Long): Result = runCatching { + val response = service.deletePet(petId) + val body = response.body() + if (!response.isSuccessful || body?.success != true) { + error(body?.message ?: "반려동물을 삭제하지 못했습니다. (${response.code()})") + } + } + + private fun Response>.requireData(message: String): T { + val body = body() + if (!isSuccessful || body?.success != true) error(body?.message ?: "$message (${code()})") + return body.data ?: error("서버 응답이 비어 있습니다.") + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt b/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt index 17f8cb4..85d5c6d 100644 --- a/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt +++ b/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt @@ -7,14 +7,18 @@ import dagger.hilt.components.SingletonComponent import kr.ac.anu.mumu.data.datasource.AnalysisService import kr.ac.anu.mumu.data.datasource.AuthService import kr.ac.anu.mumu.data.datasource.CommunityService +import kr.ac.anu.mumu.data.datasource.DiaryService import kr.ac.anu.mumu.data.datasource.HistoryService +import kr.ac.anu.mumu.data.datasource.HospitalService import kr.ac.anu.mumu.data.datasource.JoinService import kr.ac.anu.mumu.data.datasource.PetService +import kr.ac.anu.mumu.data.local.SessionAuthenticator import kr.ac.anu.mumu.data.local.SessionManager import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit +import javax.inject.Named import javax.inject.Singleton @Module @@ -22,8 +26,9 @@ import javax.inject.Singleton object NetworkModule { @Provides @Singleton - fun provideOkHttpClient(sessionManager: SessionManager): OkHttpClient { + fun provideOkHttpClient(sessionManager: SessionManager, authenticator: SessionAuthenticator): OkHttpClient { return OkHttpClient.Builder() + .authenticator(authenticator) .addInterceptor { chain -> val token = sessionManager.accessToken val request = if (token.isNullOrBlank()) { @@ -41,6 +46,16 @@ object NetworkModule { .build() } + @Provides + @Named("unauthenticated") + fun provideUnauthenticatedRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl("http://13.125.155.32:8080/") + .client(OkHttpClient.Builder().build()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + @Provides @Singleton fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { @@ -53,7 +68,7 @@ object NetworkModule { @Provides @Singleton - fun provideAuthService(retrofit: Retrofit): AuthService { + fun provideAuthService(@Named("unauthenticated") retrofit: Retrofit): AuthService { return retrofit.create(AuthService::class.java) } @@ -75,12 +90,20 @@ object NetworkModule { return retrofit.create(PetService::class.java) } + @Provides + @Singleton + fun provideDiaryService(retrofit: Retrofit): DiaryService = retrofit.create(DiaryService::class.java) + @Provides @Singleton fun provideHistoryService(retrofit: Retrofit): HistoryService { return retrofit.create(HistoryService::class.java) } + @Provides + @Singleton + fun provideHospitalService(retrofit: Retrofit): HospitalService = retrofit.create(HospitalService::class.java) + @Provides @Singleton fun provideCommunityService(retrofit: Retrofit): CommunityService { diff --git a/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt b/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt index 9b84967..ea67df3 100644 --- a/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt +++ b/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt @@ -6,20 +6,38 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kr.ac.anu.mumu.data.repository.AnalysisRepositoryImpl import kr.ac.anu.mumu.data.repository.CommunityRepositoryImpl +import kr.ac.anu.mumu.data.repository.DiaryRepositoryImpl import kr.ac.anu.mumu.data.repository.HistoryRepositoryImpl +import kr.ac.anu.mumu.data.repository.HospitalRepositoryImpl import kr.ac.anu.mumu.data.repository.JoinRepositoryImpl import kr.ac.anu.mumu.data.repository.LoginRepositoryImpl +import kr.ac.anu.mumu.data.repository.PetRepositoryImpl import kr.ac.anu.mumu.domain.repository.AnalysisRepository import kr.ac.anu.mumu.domain.repository.CommunityRepository +import kr.ac.anu.mumu.domain.repository.DiaryRepository import kr.ac.anu.mumu.domain.repository.HistoryRepository +import kr.ac.anu.mumu.domain.repository.HospitalRepository import kr.ac.anu.mumu.domain.repository.JoinRepository import kr.ac.anu.mumu.domain.repository.LoginRepository +import kr.ac.anu.mumu.domain.repository.PetRepository import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { + @Binds + @Singleton + abstract fun bindHospitalRepository(hospitalRepositoryImpl: HospitalRepositoryImpl): HospitalRepository + + @Binds + @Singleton + abstract fun bindDiaryRepository(diaryRepositoryImpl: DiaryRepositoryImpl): DiaryRepository + + @Binds + @Singleton + abstract fun bindPetRepository(petRepositoryImpl: PetRepositoryImpl): PetRepository + @Binds @Singleton abstract fun bindAnalysisRepository( diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt b/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt index 9d5e27b..5e7237f 100644 --- a/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt +++ b/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt @@ -3,6 +3,7 @@ package kr.ac.anu.mumu.domain.model data class CommunityPost( val id: Long, val userId: Long, + val petId: Long?, val title: String, val content: String, val category: String, diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt index 30851ea..a10c1a4 100644 --- a/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt +++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt @@ -13,8 +13,24 @@ interface CommunityRepository { content: String, hashtags: List ): Result + suspend fun updatePost( + postId: Long, + category: String, + title: String, + content: String, + hashtags: List, + petId: Long? + ): Result + suspend fun deletePost(postId: Long): Result suspend fun toggleLike(postId: Long): Result> suspend fun toggleBookmark(postId: Long): Result> suspend fun getComments(postId: Long): Result> suspend fun createComment(postId: Long, content: String): Result + suspend fun updateComment( + postId: Long, + commentId: Long, + content: String + ): Result + suspend fun deleteComment(postId: Long, commentId: Long): Result + suspend fun getCurrentUserId(): Result } diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/DiaryRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/DiaryRepository.kt new file mode 100644 index 0000000..dd12ab2 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/DiaryRepository.kt @@ -0,0 +1,17 @@ +package kr.ac.anu.mumu.domain.repository + +import android.net.Uri +import kr.ac.anu.mumu.data.model.DiaryCalendarDto +import kr.ac.anu.mumu.data.model.DiaryDetailDto +import kr.ac.anu.mumu.data.model.DiaryListDto +import kr.ac.anu.mumu.data.model.DiaryRequestDto +import kr.ac.anu.mumu.data.model.PaginatedData + +interface DiaryRepository { + suspend fun uploadDiaryImage(uri: Uri): Result + suspend fun getCalendar(petId: Long, year: Int, month: Int): Result + suspend fun getDiaries(petId: Long, page: Int): Result> + suspend fun getDiary(diaryId: Long): Result + suspend fun saveDiary(diaryId: Long?, request: DiaryRequestDto): Result + suspend fun deleteDiary(diaryId: Long): Result +} diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/HospitalRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/HospitalRepository.kt new file mode 100644 index 0000000..8047912 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/HospitalRepository.kt @@ -0,0 +1,14 @@ +package kr.ac.anu.mumu.domain.repository + +import kr.ac.anu.mumu.data.model.HospitalDetailDto +import kr.ac.anu.mumu.data.model.HospitalListDto +import kr.ac.anu.mumu.data.model.HospitalPriceDto +import kr.ac.anu.mumu.data.model.HospitalReviewDto +import kr.ac.anu.mumu.data.model.PaginatedData + +interface HospitalRepository { + suspend fun search(keyword: String?, page: Int): Result> + suspend fun getDetail(hospitalId: Long): Result + suspend fun getPrices(hospitalId: Long): Result> + suspend fun getReviews(hospitalId: Long): Result> +} diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/PetRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/PetRepository.kt new file mode 100644 index 0000000..79d196b --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/PetRepository.kt @@ -0,0 +1,10 @@ +package kr.ac.anu.mumu.domain.repository + +import kr.ac.anu.mumu.data.model.PetDto +import kr.ac.anu.mumu.data.model.PetRequestDto + +interface PetRepository { + suspend fun getPets(): Result> + suspend fun savePet(petId: Long?, request: PetRequestDto): Result + suspend fun deletePet(petId: Long): Result +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisLoadingFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisLoadingFragment.kt index 7523974..1b265c0 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisLoadingFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisLoadingFragment.kt @@ -39,7 +39,10 @@ class AnalysisLoadingFragment : Fragment() { viewModel.analyze(videoUri) binding.btnWriteDiary.setOnClickListener { - findNavController().navigate(R.id.diaryFragment) + findNavController().navigate( + R.id.diaryFragment, + Bundle().apply { putBoolean("compose", true) } + ) } binding.btnLater.setOnClickListener { findNavController().popBackStack(R.id.homeFragment, false) diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt index c09bdfa..a94b992 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt @@ -4,16 +4,21 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import coil3.load import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import kr.ac.anu.mumu.R import kr.ac.anu.mumu.databinding.FragmentCommunityDetailBinding +import kr.ac.anu.mumu.domain.model.CommunityComment import kr.ac.anu.mumu.presentation.main.adapter.CommunityCommentAdapter @AndroidEntryPoint @@ -22,7 +27,17 @@ class CommunityDetailFragment : Fragment() { private var _binding: FragmentCommunityDetailBinding? = null private val binding get() = _binding!! private val viewModel: CommunityDetailViewModel by viewModels() - private val commentAdapter = CommunityCommentAdapter() + private val commentAdapter = CommunityCommentAdapter( + onEdit = ::startEditingComment, + onDelete = ::confirmDeleteComment + ) + private var editingCommentId: Long? = null + private var hasResumed = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + editingCommentId = savedInstanceState?.getLong(KEY_EDITING_COMMENT_ID)?.takeIf { it > 0L } + } override fun onCreateView( inflater: LayoutInflater, @@ -42,23 +57,50 @@ class CommunityDetailFragment : Fragment() { isNestedScrollingEnabled = false } binding.btnRetry.setOnClickListener { viewModel.load() } + binding.tvCommentEmpty.setOnClickListener { + if ((viewModel.uiState.value as? CommunityDetailUiState.Success)?.commentError != null) { + viewModel.load() + } + } binding.btnLike.setOnClickListener { viewModel.toggleLike() } binding.btnBookmark.setOnClickListener { viewModel.toggleBookmark() } + binding.btnEditPost.setOnClickListener { + findNavController().navigate( + R.id.action_communityDetailFragment_to_communityWriteFragment, + Bundle().apply { putLong("postId", viewModel.postId) } + ) + } + binding.btnDeletePost.setOnClickListener { confirmDeletePost() } + binding.btnCommentCancel.setOnClickListener { clearCommentEditor() } + if (editingCommentId != null) { + binding.btnComment.text = "수정" + binding.btnCommentCancel.visibility = View.VISIBLE + } binding.btnComment.setOnClickListener { val content = binding.etComment.text?.toString().orEmpty() - if (content.isNotBlank()) { - viewModel.addComment(content) - binding.etComment.text?.clear() - } + editingCommentId?.let { commentId -> + viewModel.updateComment(commentId, content) + } ?: viewModel.addComment(content) } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect(::render) + launch { viewModel.uiState.collect(::render) } + launch { viewModel.events.collect(::handleEvent) } } } } + override fun onResume() { + super.onResume() + if (hasResumed) viewModel.load() else hasResumed = true + } + + override fun onSaveInstanceState(outState: Bundle) { + editingCommentId?.let { outState.putLong(KEY_EDITING_COMMENT_ID, it) } + super.onSaveInstanceState(outState) + } + private fun render(state: CommunityDetailUiState) { binding.progressDetail.visibility = if (state is CommunityDetailUiState.Loading) View.VISIBLE else View.GONE binding.layoutError.visibility = if (state is CommunityDetailUiState.Error) View.VISIBLE else View.GONE @@ -79,14 +121,76 @@ class CommunityDetailFragment : Fragment() { binding.tvHashtags.visibility = if (post.hashtags.isEmpty()) View.GONE else View.VISIBLE binding.btnLike.text = if (state.liked) "♥ ${post.likeCount}" else "♡ ${post.likeCount}" binding.btnBookmark.text = if (state.bookmarked) "북마크됨" else "북마크 ${post.bookmarkCount}" + binding.layoutPostManage.visibility = if (state.isPostOwner) View.VISIBLE else View.GONE + binding.tvProfileNotice.visibility = if (state.profileError) View.VISIBLE else View.GONE + binding.tvProfileNotice.setOnClickListener { viewModel.load() } + binding.btnEditPost.isEnabled = !state.isProcessing + binding.btnDeletePost.isEnabled = !state.isProcessing + binding.btnLike.isEnabled = !state.isProcessing + binding.btnBookmark.isEnabled = !state.isProcessing + binding.btnComment.isEnabled = !state.isProcessing + binding.btnCommentCancel.isEnabled = !state.isProcessing if (post.thumbnailUrl.isNullOrBlank()) { binding.ivThumbnail.visibility = View.GONE } else { binding.ivThumbnail.visibility = View.VISIBLE binding.ivThumbnail.load(post.thumbnailUrl) } + commentAdapter.setCurrentUserId(state.currentUserId) commentAdapter.submitList(state.comments) binding.tvCommentEmpty.visibility = if (state.comments.isEmpty()) View.VISIBLE else View.GONE + binding.tvCommentEmpty.text = state.commentError?.let { "$it 탭해서 다시 시도해 주세요." } + ?: "아직 댓글이 없어요. 첫 댓글을 남겨보세요!" + } + } + } + + private fun startEditingComment(comment: CommunityComment) { + editingCommentId = comment.id + binding.etComment.setText(comment.content) + binding.etComment.setSelection(comment.content.length) + binding.btnComment.text = "수정" + binding.btnCommentCancel.visibility = View.VISIBLE + binding.etComment.requestFocus() + } + + private fun clearCommentEditor() { + editingCommentId = null + binding.etComment.text?.clear() + binding.btnComment.text = "등록" + binding.btnCommentCancel.visibility = View.GONE + } + + private fun confirmDeletePost() { + AlertDialog.Builder(requireContext()) + .setTitle("게시글을 삭제할까요?") + .setMessage("삭제한 게시글은 다시 복구할 수 없어요.") + .setNegativeButton("취소", null) + .setPositiveButton("삭제") { _, _ -> viewModel.deletePost() } + .show() + } + + private fun confirmDeleteComment(comment: CommunityComment) { + AlertDialog.Builder(requireContext()) + .setTitle("댓글을 삭제할까요?") + .setMessage("삭제한 댓글은 다시 복구할 수 없어요.") + .setNegativeButton("취소", null) + .setPositiveButton("삭제") { _, _ -> + if (editingCommentId == comment.id) clearCommentEditor() + viewModel.deleteComment(comment.id) + } + .show() + } + + private fun handleEvent(event: CommunityDetailEvent) { + when (event) { + CommunityDetailEvent.PostDeleted -> { + Toast.makeText(requireContext(), "게시글을 삭제했습니다.", Toast.LENGTH_SHORT).show() + findNavController().popBackStack() + } + CommunityDetailEvent.CommentSaved -> clearCommentEditor() + is CommunityDetailEvent.Message -> { + Toast.makeText(requireContext(), event.text, Toast.LENGTH_SHORT).show() } } } @@ -95,6 +199,10 @@ class CommunityDetailFragment : Fragment() { super.onDestroyView() _binding = null } + + private companion object { + const val KEY_EDITING_COMMENT_ID = "editing_comment_id" + } } private fun String.toCategoryLabel(): String = when (uppercase()) { diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailViewModel.kt index e4e3189..11f51ff 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailViewModel.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailViewModel.kt @@ -4,8 +4,11 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kr.ac.anu.mumu.domain.model.CommunityComment @@ -18,21 +21,35 @@ sealed interface CommunityDetailUiState { data class Success( val post: CommunityPost, val comments: List, + val commentError: String? = null, + val currentUserId: Long? = null, + val profileError: Boolean = false, val liked: Boolean = false, - val bookmarked: Boolean = false - ) : CommunityDetailUiState + val bookmarked: Boolean = false, + val isProcessing: Boolean = false + ) : CommunityDetailUiState { + val isPostOwner: Boolean get() = currentUserId != null && currentUserId == post.userId + } data class Error(val message: String) : CommunityDetailUiState } +sealed interface CommunityDetailEvent { + data object PostDeleted : CommunityDetailEvent + data class Message(val text: String) : CommunityDetailEvent + data object CommentSaved : CommunityDetailEvent +} + @HiltViewModel class CommunityDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: CommunityRepository ) : ViewModel() { - private val postId: Long = checkNotNull(savedStateHandle["postId"]) + val postId: Long = checkNotNull(savedStateHandle["postId"]) private val _uiState = MutableStateFlow(CommunityDetailUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() init { load() @@ -43,8 +60,16 @@ class CommunityDetailViewModel @Inject constructor( _uiState.value = CommunityDetailUiState.Loading repository.getPost(postId) .onSuccess { post -> - val comments = repository.getComments(postId).getOrDefault(emptyList()) - _uiState.value = CommunityDetailUiState.Success(post, comments) + val commentResult = repository.getComments(postId) + val profileResult = repository.getCurrentUserId() + _uiState.value = CommunityDetailUiState.Success( + post = post, + comments = commentResult.getOrDefault(emptyList()), + commentError = commentResult.exceptionOrNull()?.message + ?: if (commentResult.isFailure) "댓글을 불러오지 못했습니다." else null, + currentUserId = profileResult.getOrNull(), + profileError = profileResult.isFailure + ) } .onFailure { error -> _uiState.value = CommunityDetailUiState.Error( @@ -54,40 +79,105 @@ class CommunityDetailViewModel @Inject constructor( } } - fun toggleLike() { - val current = _uiState.value as? CommunityDetailUiState.Success ?: return - viewModelScope.launch { - repository.toggleLike(postId).onSuccess { (liked, count) -> - _uiState.value = current.copy( - post = current.post.copy(likeCount = count), - liked = liked + fun toggleLike() = mutate( + action = { repository.toggleLike(postId) }, + update = { current, (liked, count) -> + current.copy(post = current.post.copy(likeCount = count), liked = liked) + }, + fallbackMessage = "좋아요 처리에 실패했습니다." + ) + + fun toggleBookmark() = mutate( + action = { repository.toggleBookmark(postId) }, + update = { current, (bookmarked, count) -> + current.copy(post = current.post.copy(bookmarkCount = count), bookmarked = bookmarked) + }, + fallbackMessage = "북마크 처리에 실패했습니다." + ) + + fun addComment(content: String) { + CommunityInputValidator.validateComment(content)?.let { message -> + _events.tryEmit(CommunityDetailEvent.Message(message)) + return + } + mutate( + action = { repository.createComment(postId, content.trim()) }, + update = { current, comment -> + current.copy( + post = current.post.copy(commentCount = current.post.commentCount + 1), + comments = current.comments + comment ) - } + }, + fallbackMessage = "댓글을 등록하지 못했습니다.", + successEvent = CommunityDetailEvent.CommentSaved + ) + } + + fun updateComment(commentId: Long, content: String) { + CommunityInputValidator.validateComment(content)?.let { message -> + _events.tryEmit(CommunityDetailEvent.Message(message)) + return } + mutate( + action = { repository.updateComment(postId, commentId, content.trim()) }, + update = { current, updated -> + current.copy(comments = current.comments.map { if (it.id == commentId) updated else it }) + }, + fallbackMessage = "댓글을 수정하지 못했습니다.", + successEvent = CommunityDetailEvent.CommentSaved + ) } - fun toggleBookmark() { + fun deleteComment(commentId: Long) { + mutate( + action = { repository.deleteComment(postId, commentId) }, + update = { current, _ -> + current.copy( + post = current.post.copy( + commentCount = (current.post.commentCount - 1).coerceAtLeast(0) + ), + comments = current.comments.filterNot { it.id == commentId } + ) + }, + fallbackMessage = "댓글을 삭제하지 못했습니다." + ) + } + + fun deletePost() { val current = _uiState.value as? CommunityDetailUiState.Success ?: return + if (current.isProcessing || !current.isPostOwner) return viewModelScope.launch { - repository.toggleBookmark(postId).onSuccess { (bookmarked, count) -> - _uiState.value = current.copy( - post = current.post.copy(bookmarkCount = count), - bookmarked = bookmarked - ) - } + _uiState.value = current.copy(isProcessing = true) + repository.deletePost(postId) + .onSuccess { _events.emit(CommunityDetailEvent.PostDeleted) } + .onFailure { error -> + _uiState.value = current + _events.emit( + CommunityDetailEvent.Message(error.message ?: "게시글을 삭제하지 못했습니다.") + ) + } } } - fun addComment(content: String) { + private fun mutate( + action: suspend () -> Result, + update: (CommunityDetailUiState.Success, T) -> CommunityDetailUiState.Success, + fallbackMessage: String, + successEvent: CommunityDetailEvent? = null + ) { val current = _uiState.value as? CommunityDetailUiState.Success ?: return - if (content.isBlank()) return + if (current.isProcessing) return viewModelScope.launch { - repository.createComment(postId, content.trim()).onSuccess { comment -> - _uiState.value = current.copy( - post = current.post.copy(commentCount = current.post.commentCount + 1), - comments = current.comments + comment - ) - } + _uiState.value = current.copy(isProcessing = true) + action() + .onSuccess { result -> + _uiState.value = update(current, result).copy(isProcessing = false) + successEvent?.let { _events.emit(it) } + } + .onFailure { error -> + _uiState.value = current + _events.emit(CommunityDetailEvent.Message(error.message ?: fallbackMessage)) + } } } } diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityFragment.kt index 9ef6b07..ee4fb8b 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityFragment.kt @@ -5,6 +5,9 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels @@ -49,6 +52,7 @@ class CommunityFragment : Fragment() { layoutManager = LinearLayoutManager(requireContext()) isNestedScrollingEnabled = false } + configureQuickMenus() binding.btnMorePosts.setOnClickListener { navigateToPostList() } binding.tvPopularPostsState.setOnClickListener { viewModel.loadBestPosts() } binding.btnMorePets.setOnClickListener { @@ -70,6 +74,23 @@ class CommunityFragment : Fragment() { } } + private fun configureQuickMenus() { + val menus = buildList { + for (rowIndex in 0 until binding.layoutQuickMenu.childCount) { + val row = binding.layoutQuickMenu.getChildAt(rowIndex) as? LinearLayout ?: continue + for (itemIndex in 0 until row.childCount) add(row.getChildAt(itemIndex) as LinearLayout) + } + } + val labels = listOf("자유", "질문", "정보", "자랑", "후기", "강아지", "고양이", "건강", "산책", "일상") + menus.zip(labels).forEach { (menu, label) -> + (menu.getChildAt(0) as? ImageView)?.contentDescription = "$label 게시글 보기" + (menu.getChildAt(1) as? TextView)?.text = label + menu.isClickable = true + menu.isFocusable = true + menu.setOnClickListener { navigateToPostList(label) } + } + } + override fun onResume() { super.onResume() viewModel.loadBestPosts() diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityInputValidator.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityInputValidator.kt new file mode 100644 index 0000000..f14c040 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityInputValidator.kt @@ -0,0 +1,17 @@ +package kr.ac.anu.mumu.presentation.main + +object CommunityInputValidator { + const val MAX_TITLE_LENGTH = 200 + + fun validatePost(title: String, content: String): String? = when { + title.isBlank() -> "제목을 입력해 주세요." + title.length > MAX_TITLE_LENGTH -> "제목은 ${MAX_TITLE_LENGTH}자 이하로 입력해 주세요." + content.isBlank() -> "내용을 입력해 주세요." + else -> null + } + + fun validateComment(content: String): String? = when { + content.isBlank() -> "댓글 내용을 입력해 주세요." + else -> null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityPostListFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityPostListFragment.kt index 2c73ca9..a966fb7 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityPostListFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityPostListFragment.kt @@ -26,6 +26,7 @@ class CommunityPostListFragment : Fragment() { private val binding get() = _binding!! private val viewModel: PostViewModel by viewModels() private var allPosts: List = emptyList() + private var hasResumed = false private val postAdapter = CommunityFeedAdapter { post -> findNavController().navigate( R.id.communityDetailFragment, @@ -60,6 +61,11 @@ class CommunityPostListFragment : Fragment() { } } + override fun onResume() { + super.onResume() + if (hasResumed) viewModel.loadPosts() else hasResumed = true + } + private fun render(state: PostUiState) { binding.progressPosts.visibility = if (state is PostUiState.Loading) View.VISIBLE else View.GONE binding.layoutError.visibility = if (state is PostUiState.Error) View.VISIBLE else View.GONE @@ -90,7 +96,8 @@ class CommunityPostListFragment : Fragment() { allPosts.filter { post -> post.title.contains(keyword, ignoreCase = true) || post.content.contains(keyword, ignoreCase = true) || - post.hashtags.any { it.contains(keyword.removePrefix("#"), ignoreCase = true) } + post.hashtags.any { it.contains(keyword.removePrefix("#"), ignoreCase = true) } || + post.category.matchesCategory(keyword) } } postAdapter.submitList(filtered) @@ -102,6 +109,18 @@ class CommunityPostListFragment : Fragment() { } } + private fun String.matchesCategory(keyword: String): Boolean { + val label = when (uppercase()) { + "FREE" -> "자유" + "QUESTION" -> "질문" + "INFO" -> "정보" + "BRAG" -> "자랑" + "REVIEW" -> "후기" + else -> this + } + return label.equals(keyword, ignoreCase = true) + } + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteFragment.kt index 5fee536..92adc38 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteFragment.kt @@ -5,6 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter +import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -23,6 +24,7 @@ class CommunityWriteFragment : Fragment() { private val binding get() = _binding!! private val viewModel: CommunityWriteViewModel by viewModels() private val categories = listOf("FREE", "QUESTION", "INFO", "BRAG", "REVIEW") + private var hasPopulatedForm = false override fun onCreateView( inflater: LayoutInflater, @@ -35,13 +37,28 @@ class CommunityWriteFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + hasPopulatedForm = savedInstanceState?.getBoolean(KEY_FORM_POPULATED) ?: false binding.spinnerCategory.adapter = ArrayAdapter( requireContext(), android.R.layout.simple_spinner_dropdown_item, listOf("자유", "질문", "정보", "자랑", "후기") ) + binding.tvWriteTitle.text = if (viewModel.isEditMode) "이야기를 수정할까요?" else "어떤 이야기를 나눌까요?" + binding.tvWriteDescription.text = if (viewModel.isEditMode) { + "바꾸고 싶은 내용을 다듬은 뒤 저장해 주세요." + } else { + "반려생활의 질문과 순간을 자유롭게 남겨보세요." + } + binding.btnSubmit.text = if (viewModel.isEditMode) "수정 내용 저장하기" else "게시글 등록하기" + binding.etTitle.doAfterTextChanged { text -> + binding.tvTitleCount.text = "${text?.length ?: 0} / ${CommunityInputValidator.MAX_TITLE_LENGTH}" + } binding.btnSubmit.setOnClickListener { + if (viewModel.needsEditPostLoad) { + viewModel.loadPost() + return@setOnClickListener + } viewModel.submit( category = categories[binding.spinnerCategory.selectedItemPosition], title = binding.etTitle.text?.toString().orEmpty(), @@ -58,25 +75,58 @@ class CommunityWriteFragment : Fragment() { } private fun render(state: CommunityWriteUiState) { - binding.progressSubmit.visibility = if (state is CommunityWriteUiState.Loading) View.VISIBLE else View.GONE - binding.btnSubmit.isEnabled = state !is CommunityWriteUiState.Loading + val isBusy = state is CommunityWriteUiState.Loading || state is CommunityWriteUiState.Submitting + binding.progressSubmit.visibility = if (isBusy) View.VISIBLE else View.GONE + binding.btnSubmit.isEnabled = !isBusy + binding.btnSubmit.text = if (viewModel.needsEditPostLoad) { + "게시글 다시 불러오기" + } else if (viewModel.isEditMode) { + "수정 내용 저장하기" + } else { + "게시글 등록하기" + } binding.tvError.visibility = if (state is CommunityWriteUiState.Error) View.VISIBLE else View.GONE when (state) { - CommunityWriteUiState.Idle, - CommunityWriteUiState.Loading -> Unit + CommunityWriteUiState.Loading, + CommunityWriteUiState.Submitting -> Unit + is CommunityWriteUiState.Ready -> { + val post = state.post ?: return + if (!hasPopulatedForm) { + binding.spinnerCategory.setSelection( + categories.indexOf(post.category.uppercase()).coerceAtLeast(0) + ) + binding.etTitle.setText(post.title) + binding.etContent.setText(post.content) + binding.etHashtags.setText(post.hashtags.joinToString(" ") { "#$it" }) + hasPopulatedForm = true + } + } is CommunityWriteUiState.Error -> binding.tvError.text = state.message is CommunityWriteUiState.Success -> { - findNavController().navigate( - R.id.action_communityWriteFragment_to_communityDetailFragment, - Bundle().apply { putLong("postId", state.postId) } - ) + if (state.isEdit) { + findNavController().popBackStack() + } else { + findNavController().navigate( + R.id.action_communityWriteFragment_to_communityDetailFragment, + Bundle().apply { putLong("postId", state.postId) } + ) + } } } } + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(KEY_FORM_POPULATED, hasPopulatedForm) + super.onSaveInstanceState(outState) + } + override fun onDestroyView() { super.onDestroyView() _binding = null } + + private companion object { + const val KEY_FORM_POPULATED = "form_populated" + } } diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteViewModel.kt index 10b94eb..6044484 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteViewModel.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteViewModel.kt @@ -1,5 +1,6 @@ package kr.ac.anu.mumu.presentation.main +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -7,27 +8,59 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kr.ac.anu.mumu.domain.model.CommunityPost import kr.ac.anu.mumu.domain.repository.CommunityRepository import javax.inject.Inject sealed interface CommunityWriteUiState { - data object Idle : CommunityWriteUiState data object Loading : CommunityWriteUiState - data class Success(val postId: Long) : CommunityWriteUiState + data class Ready(val post: CommunityPost? = null) : CommunityWriteUiState + data object Submitting : CommunityWriteUiState + data class Success(val postId: Long, val isEdit: Boolean) : CommunityWriteUiState data class Error(val message: String) : CommunityWriteUiState } @HiltViewModel class CommunityWriteViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val repository: CommunityRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(CommunityWriteUiState.Idle) + private val editingPostId = savedStateHandle.get("postId")?.takeIf { it > 0L } + private var editingPost: CommunityPost? = null + val isEditMode: Boolean = editingPostId != null + val needsEditPostLoad: Boolean get() = isEditMode && editingPost == null + + private val _uiState = MutableStateFlow( + if (isEditMode) CommunityWriteUiState.Loading else CommunityWriteUiState.Ready() + ) val uiState: StateFlow = _uiState.asStateFlow() + init { + if (isEditMode) loadPost() + } + + fun loadPost() { + val postId = editingPostId ?: return + _uiState.value = CommunityWriteUiState.Loading + viewModelScope.launch { + repository.getPost(postId) + .onSuccess { post -> + editingPost = post + _uiState.value = CommunityWriteUiState.Ready(post) + } + .onFailure { error -> + _uiState.value = CommunityWriteUiState.Error( + error.message ?: "게시글을 불러오지 못했습니다." + ) + } + } + } + fun submit(category: String, title: String, content: String, hashtags: String) { - if (title.isBlank() || content.isBlank()) { - _uiState.value = CommunityWriteUiState.Error("제목과 내용을 입력해 주세요.") + if (_uiState.value is CommunityWriteUiState.Submitting || needsEditPostLoad) return + CommunityInputValidator.validatePost(title, content)?.let { message -> + _uiState.value = CommunityWriteUiState.Error(message) return } val hashtagList = hashtags.split(',', ' ', '#') @@ -36,12 +69,29 @@ class CommunityWriteViewModel @Inject constructor( .distinct() viewModelScope.launch { - _uiState.value = CommunityWriteUiState.Loading - repository.createPost(category, title.trim(), content.trim(), hashtagList) - .onSuccess { post -> _uiState.value = CommunityWriteUiState.Success(post.id) } + _uiState.value = CommunityWriteUiState.Submitting + val result = editingPostId?.let { postId -> + repository.updatePost( + postId = postId, + category = category, + title = title.trim(), + content = content.trim(), + hashtags = hashtagList, + petId = editingPost?.petId + ) + } ?: repository.createPost(category, title.trim(), content.trim(), hashtagList) + + result + .onSuccess { post -> + _uiState.value = CommunityWriteUiState.Success(post.id, isEditMode) + } .onFailure { error -> _uiState.value = CommunityWriteUiState.Error( - error.message ?: "게시글을 등록하지 못했습니다." + error.message ?: if (isEditMode) { + "게시글을 수정하지 못했습니다." + } else { + "게시글을 등록하지 못했습니다." + } ) } } diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryCalendarDays.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryCalendarDays.kt new file mode 100644 index 0000000..22b072c --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryCalendarDays.kt @@ -0,0 +1,10 @@ +package kr.ac.anu.mumu.presentation.main + +import java.time.LocalDate +import java.time.YearMonth + +fun YearMonth.toCalendarCells(): List { + val leadingDays = atDay(1).dayOfWeek.value - 1 + val cells = List(leadingDays) { null } + (1..lengthOfMonth()).map(::atDay) + return cells + List((7 - cells.size % 7) % 7) { null } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryFragment.kt index 6d62481..06c7403 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryFragment.kt @@ -1,30 +1,360 @@ package kr.ac.anu.mumu.presentation.main +import android.app.DatePickerDialog +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatButton +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import coil3.load +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.data.model.DiaryDetailDto +import kr.ac.anu.mumu.data.model.DiaryRequestDto +import kr.ac.anu.mumu.databinding.DialogDiaryDetailBinding +import kr.ac.anu.mumu.databinding.DialogDiaryFormBinding import kr.ac.anu.mumu.databinding.FragmentDiaryBinding -import kr.ac.anu.mumu.presentation.join.JoinViewModel +import kr.ac.anu.mumu.presentation.main.adapter.DiaryAdapter +import kr.ac.anu.mumu.presentation.main.adapter.toMoodLabel +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.DateTimeParseException +@AndroidEntryPoint class DiaryFragment : Fragment() { private var _binding: FragmentDiaryBinding? = null private val binding get() = _binding!! - private val viewModel: JoinViewModel by activityViewModels() + private val viewModel: DiaryViewModel by viewModels() + private val adapter = DiaryAdapter { viewModel.openDiary(it.diaryId) } + private var formDialog: AlertDialog? = null + private var detailDialog: AlertDialog? = null + private var hasResumed = false + private var autoCompose = false + private var selectedImageUri: Uri? = null + private var activeForm: DialogDiaryFormBinding? = null + private val pickDiaryImage = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) { + selectedImageUri = uri + activeForm?.ivDiaryPreview?.apply { + visibility = View.VISIBLE + load(uri) + } + activeForm?.btnRemoveDiaryImage?.visibility = View.VISIBLE + } + } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentDiaryBinding.inflate(inflater, container, false) return binding.root } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + autoCompose = savedInstanceState == null && arguments?.getBoolean("compose") == true + binding.rvDiaries.adapter = adapter + binding.rvDiaries.layoutManager = LinearLayoutManager(requireContext()) + binding.btnWriteDiary.setOnClickListener { + val selected = (viewModel.uiState.value as? DiaryUiState.Ready)?.selectedDate + showForm(null, selected) + } + binding.btnMoreDiaries.setOnClickListener { viewModel.loadMore() } + binding.btnPreviousMonth.setOnClickListener { viewModel.changeMonth(-1) } + binding.btnNextMonth.setOnClickListener { viewModel.changeMonth(1) } + binding.tvCalendarStatus.setOnClickListener { viewModel.retryCalendar() } + binding.tvDiaryState.setOnClickListener { + if (viewModel.uiState.value is DiaryUiState.Error) viewModel.load() + } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.uiState.collect(::render) } + launch { + viewModel.events.collect { event -> + when (event) { + is DiaryEvent.Open -> showDetail(event.diary) + DiaryEvent.Saved -> { + formDialog?.dismiss() + formDialog = null + detailDialog?.dismiss() + detailDialog = null + Toast.makeText(requireContext(), "일기를 저장했습니다.", Toast.LENGTH_SHORT).show() + } + is DiaryEvent.Message -> { + formDialog?.findViewById(R.id.btn_save_diary)?.apply { + isEnabled = true + text = "저장" + } + Toast.makeText(requireContext(), event.text, Toast.LENGTH_SHORT).show() + } + } + } + } + } + } + } + + override fun onResume() { + super.onResume() + if (hasResumed) viewModel.load() else hasResumed = true + } + + private fun render(state: DiaryUiState) { + binding.progressDiaries.visibility = if (state is DiaryUiState.Loading) View.VISIBLE else View.GONE + binding.btnWriteDiary.isEnabled = state is DiaryUiState.Ready && state.petId != null && !state.isWorking + binding.tvDiaryState.visibility = if ( + state is DiaryUiState.Error || state is DiaryUiState.Ready && state.entries.isEmpty() + ) { + View.VISIBLE + } else { + View.GONE + } + binding.tvDiaryState.text = when (state) { + is DiaryUiState.Error -> "${state.message}\n탭해서 다시 시도해 주세요." + is DiaryUiState.Ready -> if (state.petId == null) { + "마이 탭에서 반려동물을 먼저 등록해 주세요." + } else { + "아직 작성한 일기가 없어요." + } + DiaryUiState.Loading -> "" + } + if (state is DiaryUiState.Ready) { + binding.layoutCalendar.visibility = if (state.petId == null) View.GONE else View.VISIBLE + binding.tvDiaryPet.text = state.petName?.let { "${it}의 하루를 기록해 주세요." } + ?: "우리 아이의 하루를 기록해 주세요." + binding.btnWriteDiary.text = state.selectedDate?.let { + "${it.toString().replace('-', '.')} 일기 쓰기" + } ?: "+ 일기 쓰기" + renderCalendar(state) + adapter.submitList(state.entries) + binding.btnMoreDiaries.visibility = if (state.nextPage == null) View.GONE else View.VISIBLE + binding.btnMoreDiaries.isEnabled = !state.isLoadingMore + binding.btnMoreDiaries.text = if (state.isLoadingMore) "불러오는 중…" else "더 보기" + if (autoCompose && state.petId != null) { + autoCompose = false + arguments?.putBoolean("compose", false) + showForm(null) + } + } else { + binding.layoutCalendar.visibility = View.GONE + adapter.submitList(emptyList()) + binding.btnMoreDiaries.visibility = View.GONE + } + } + + private fun renderCalendar(state: DiaryUiState.Ready) { + binding.tvCalendarMonth.text = "${state.calendarMonth.year}년 ${state.calendarMonth.monthValue}월" + binding.btnNextMonth.isEnabled = state.calendarMonth.isBefore(YearMonth.now()) + binding.tvCalendarStatus.visibility = if (state.isCalendarLoading || state.calendarError != null || state.writtenDates.isEmpty()) { + View.VISIBLE + } else { + View.GONE + } + binding.tvCalendarStatus.text = when { + state.isCalendarLoading -> "기록한 날짜를 불러오는 중…" + state.calendarError != null -> "${state.calendarError} · 탭해서 다시 시도" + state.writtenDates.isEmpty() -> "이 달에는 아직 기록한 날짜가 없어요." + else -> "" + } + binding.tvCalendarStatus.isClickable = state.calendarError != null + + val density = resources.displayMetrics.density + val cellHeight = (46 * density).toInt() + binding.layoutCalendarDays.removeAllViews() + val header = LinearLayout(requireContext()) + listOf("월", "화", "수", "목", "금", "토", "일").forEach { label -> + val headerLabel = TextView(requireContext()).apply { + text = label + textSize = 11f + gravity = android.view.Gravity.CENTER + setTextColor(ContextCompat.getColor(requireContext(), R.color.mumugray_350)) + } + header.addView(headerLabel, LinearLayout.LayoutParams(0, (25 * density).toInt(), 1f)) + } + binding.layoutCalendarDays.addView(header) + + state.calendarMonth.toCalendarCells().chunked(7).forEach { week -> + val row = LinearLayout(requireContext()) + week.forEach { date -> + val cell = AppCompatButton(requireContext()).apply { + minWidth = 0 + minHeight = 0 + isAllCaps = false + textSize = 12f + setPadding(0, 0, 0, 0) + if (date == null) { + visibility = View.INVISIBLE + } else { + val written = date in state.writtenDates + text = if (written) "${date.dayOfMonth}\n•" else date.dayOfMonth.toString() + contentDescription = "${date.monthValue}월 ${date.dayOfMonth}일" + + if (written) ", 일기 있음" else "" + isEnabled = !date.isAfter(LocalDate.now()) + val textColor = if (date == state.selectedDate) R.color.white else R.color.mumugray_400 + setTextColor(ContextCompat.getColor(requireContext(), textColor)) + when { + date == state.selectedDate -> setBackgroundResource(R.drawable.bg_analysis_primary) + written -> setBackgroundResource(R.drawable.bg_community_chip) + else -> setBackgroundColor(android.graphics.Color.TRANSPARENT) + } + backgroundTintList = null + setOnClickListener { viewModel.selectDate(date) } + } + } + row.addView(cell, LinearLayout.LayoutParams(0, cellHeight, 1f)) + } + binding.layoutCalendarDays.addView(row) + } + } + + private fun showForm(diary: DiaryDetailDto?, selectedDate: LocalDate? = null) { + val state = viewModel.uiState.value as? DiaryUiState.Ready ?: return + val petId = state.petId ?: return + val form = DialogDiaryFormBinding.inflate(layoutInflater) + selectedImageUri = null + activeForm = form + form.btnPickDiaryImage.visibility = if (diary == null) View.VISIBLE else View.GONE + form.btnPickDiaryImage.setOnClickListener { pickDiaryImage.launch("image/*") } + form.btnRemoveDiaryImage.setOnClickListener { + selectedImageUri = null + form.ivDiaryPreview.setImageDrawable(null) + form.ivDiaryPreview.visibility = View.GONE + form.btnRemoveDiaryImage.visibility = View.GONE + } + diary?.imageUrls?.firstOrNull()?.let { + form.ivDiaryPreview.visibility = View.VISIBLE + form.ivDiaryPreview.load(it) + } + form.tvFormTitle.text = if (diary == null) "일기 쓰기" else "일기 수정" + form.spinnerMood.adapter = ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_dropdown_item, + listOf("행복", "보통", "속상") + ) + form.etDiaryDate.setText(diary?.diaryDate ?: selectedDate?.toString() ?: LocalDate.now().toString()) + form.spinnerMood.setSelection(listOf("happy", "normal", "sad").indexOf(diary?.mood).coerceAtLeast(1)) + form.etDiaryTitle.setText(diary?.title) + form.etDiaryContent.setText(diary?.content) + form.etDiaryDate.setOnClickListener { + val initial = runCatching { LocalDate.parse(form.etDiaryDate.text.toString()) }.getOrDefault(LocalDate.now()) + DatePickerDialog( + requireContext(), + { _, year, month, day -> form.etDiaryDate.setText("%04d-%02d-%02d".format(year, month + 1, day)) }, + initial.year, + initial.monthValue - 1, + initial.dayOfMonth + ).show() + } + val dialog = AlertDialog.Builder(requireContext()).setView(form.root).create() + formDialog = dialog + form.btnCancel.setOnClickListener { dialog.dismiss() } + form.btnSaveDiary.setOnClickListener { + val title = form.etDiaryTitle.text?.toString()?.trim().orEmpty() + val content = form.etDiaryContent.text?.toString()?.trim().orEmpty() + val date = form.etDiaryDate.text?.toString().orEmpty() + val error = when { + title.isBlank() -> "제목을 입력해 주세요." + title.length > 200 -> "제목은 200자 이하로 입력해 주세요." + content.isBlank() -> "내용을 입력해 주세요." + !date.isValidDiaryDate() -> "날짜를 확인해 주세요." + else -> null + } + if (error != null) { + form.tvFormError.text = error + form.tvFormError.visibility = View.VISIBLE + return@setOnClickListener + } + form.btnSaveDiary.isEnabled = false + form.btnSaveDiary.text = if (selectedImageUri == null) "저장 중…" else "사진 업로드 중…" + val linkedAnalysis = diary?.analysisSummary + viewModel.saveDiary( + diary?.diaryId, + DiaryRequestDto( + petId = petId, + mood = listOf("happy", "normal", "sad")[form.spinnerMood.selectedItemPosition], + title = title, + content = content, + diaryDate = date, + behaviorAnalysisId = linkedAnalysis?.analysisId + ?.takeIf { linkedAnalysis.type.equals("behavior", ignoreCase = true) }, + soundAnalysisId = linkedAnalysis?.analysisId + ?.takeIf { linkedAnalysis.type.equals("sound", ignoreCase = true) }, + foodSafetyAnalysisId = linkedAnalysis?.analysisId + ?.takeIf { linkedAnalysis.type.equals("food_safety", ignoreCase = true) } + ), + selectedImageUri + ) + } + dialog.setOnDismissListener { + if (formDialog == dialog) { + formDialog = null + activeForm = null + selectedImageUri = null + } + } + dialog.show() + } + + private fun showDetail(diary: DiaryDetailDto) { + val detail = DialogDiaryDetailBinding.inflate(layoutInflater) + detail.tvDetailDate.text = "${diary.diaryDate.replace('-', '.')} · ${diary.mood.toMoodLabel()}" + detail.tvDetailTitle.text = diary.title + detail.tvDetailContent.text = diary.content + diary.analysisSummary?.let { + detail.tvDetailAnalysis.visibility = View.VISIBLE + detail.tvDetailAnalysis.text = "연결된 분석 · ${it.resultLabel.orEmpty()}" + } + diary.imageUrls?.firstOrNull()?.let { + detail.ivDetailImage.visibility = View.VISIBLE + detail.ivDetailImage.load(it) + } + val dialog = AlertDialog.Builder(requireContext()).setView(detail.root).create() + detailDialog = dialog + detail.btnClose.setOnClickListener { dialog.dismiss() } + detail.btnEdit.setOnClickListener { + dialog.dismiss() + showForm(diary) + } + detail.btnDelete.setOnClickListener { + AlertDialog.Builder(requireContext()) + .setTitle("일기를 삭제할까요?") + .setMessage("삭제한 일기와 사진은 복구할 수 없어요.") + .setNegativeButton("취소", null) + .setPositiveButton("삭제") { _, _ -> + dialog.dismiss() + viewModel.deleteDiary(diary.diaryId) + } + .show() + } + dialog.setOnDismissListener { if (detailDialog == dialog) detailDialog = null } + dialog.show() + } + override fun onDestroyView() { + formDialog?.dismiss() + detailDialog?.dismiss() + formDialog = null + detailDialog = null super.onDestroyView() _binding = null } } + +private fun String.isValidDiaryDate(): Boolean = try { + !LocalDate.parse(this).isAfter(LocalDate.now()) +} catch (_: DateTimeParseException) { + false +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryViewModel.kt new file mode 100644 index 0000000..1a8ac1d --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryViewModel.kt @@ -0,0 +1,217 @@ +package kr.ac.anu.mumu.presentation.main + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.data.local.SessionManager +import kr.ac.anu.mumu.data.model.DiaryDetailDto +import kr.ac.anu.mumu.data.model.DiaryListDto +import kr.ac.anu.mumu.data.model.DiaryRequestDto +import kr.ac.anu.mumu.data.model.selectedPetId +import kr.ac.anu.mumu.domain.repository.DiaryRepository +import kr.ac.anu.mumu.domain.repository.PetRepository +import java.time.LocalDate +import java.time.YearMonth +import javax.inject.Inject + +sealed interface DiaryUiState { + data object Loading : DiaryUiState + data class Ready( + val petId: Long?, + val petName: String?, + val entries: List, + val nextPage: Int?, + val calendarMonth: YearMonth = YearMonth.now(), + val writtenDates: Set = emptySet(), + val selectedDate: LocalDate? = null, + val isCalendarLoading: Boolean = false, + val calendarError: String? = null, + val isWorking: Boolean = false, + val isLoadingMore: Boolean = false + ) : DiaryUiState + data class Error(val message: String) : DiaryUiState +} + +sealed interface DiaryEvent { + data class Open(val diary: DiaryDetailDto) : DiaryEvent + data object Saved : DiaryEvent + data class Message(val text: String) : DiaryEvent +} + +@HiltViewModel +class DiaryViewModel @Inject constructor( + private val repository: DiaryRepository, + private val petRepository: PetRepository, + private val sessionManager: SessionManager +) : ViewModel() { + private val _uiState = MutableStateFlow(DiaryUiState.Loading) + val uiState = _uiState.asStateFlow() + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events = _events.asSharedFlow() + + init { load() } + + fun load() { + viewModelScope.launch { + val previous = _uiState.value as? DiaryUiState.Ready + _uiState.value = DiaryUiState.Loading + petRepository.getPets() + .onSuccess { pets -> + val petId = pets.selectedPetId(sessionManager.selectedPetId) + val petName = pets.firstOrNull { it.petId == petId }?.name + if (petId == null) { + _uiState.value = DiaryUiState.Ready(null, null, emptyList(), null) + } else { + val samePet = previous?.takeIf { it.petId == petId } + val month = samePet?.calendarMonth ?: YearMonth.now() + val selected = samePet?.selectedDate + repository.getDiaries(petId, 0) + .onSuccess { page -> + _uiState.value = DiaryUiState.Ready( + petId, + petName, + page.content, + if (page.page + 1 < page.totalPages) page.page + 1 else null, + calendarMonth = month, + selectedDate = selected, + isCalendarLoading = true + ) + loadCalendar(petId, month) + } + .onFailure { + _uiState.value = DiaryUiState.Error(it.message ?: "일기를 불러오지 못했습니다.") + } + } + } + .onFailure { + _uiState.value = DiaryUiState.Error(it.message ?: "반려동물을 불러오지 못했습니다.") + } + } + } + + fun changeMonth(offset: Long) { + val current = _uiState.value as? DiaryUiState.Ready ?: return + val petId = current.petId ?: return + val month = current.calendarMonth.plusMonths(offset) + _uiState.value = current.copy( + calendarMonth = month, + writtenDates = emptySet(), + selectedDate = null, + isCalendarLoading = true, + calendarError = null + ) + viewModelScope.launch { loadCalendar(petId, month) } + } + + fun selectDate(date: LocalDate) { + val current = _uiState.value as? DiaryUiState.Ready ?: return + if (current.petId == null || YearMonth.from(date) != current.calendarMonth || date.isAfter(LocalDate.now())) return + _uiState.value = current.copy(selectedDate = date) + } + + fun retryCalendar() { + val current = _uiState.value as? DiaryUiState.Ready ?: return + val petId = current.petId ?: return + if (current.isCalendarLoading) return + _uiState.value = current.copy(isCalendarLoading = true, calendarError = null) + viewModelScope.launch { loadCalendar(petId, current.calendarMonth) } + } + + private suspend fun loadCalendar(petId: Long, month: YearMonth) { + repository.getCalendar(petId, month.year, month.monthValue) + .onSuccess { response -> + val current = _uiState.value as? DiaryUiState.Ready ?: return@onSuccess + if (current.petId != petId || current.calendarMonth != month) return@onSuccess + _uiState.value = current.copy( + writtenDates = response.writtenDates.mapNotNull { runCatching { LocalDate.parse(it) }.getOrNull() }.toSet(), + isCalendarLoading = false, + calendarError = null + ) + } + .onFailure { error -> + val current = _uiState.value as? DiaryUiState.Ready ?: return@onFailure + if (current.petId != petId || current.calendarMonth != month) return@onFailure + _uiState.value = current.copy( + isCalendarLoading = false, + calendarError = error.message ?: "달력을 불러오지 못했습니다." + ) + } + } + + fun loadMore() { + val current = _uiState.value as? DiaryUiState.Ready ?: return + val petId = current.petId ?: return + val page = current.nextPage ?: return + if (current.isWorking || current.isLoadingMore) return + viewModelScope.launch { + _uiState.value = current.copy(isLoadingMore = true) + repository.getDiaries(petId, page) + .onSuccess { result -> + _uiState.value = current.copy( + entries = (current.entries + result.content).distinctBy { it.diaryId }, + nextPage = if (result.page + 1 < result.totalPages) result.page + 1 else null + ) + } + .onFailure { + _uiState.value = current + _events.emit(DiaryEvent.Message(it.message ?: "다음 일기를 불러오지 못했습니다.")) + } + } + } + + fun openDiary(diaryId: Long) { + viewModelScope.launch { + repository.getDiary(diaryId) + .onSuccess { _events.emit(DiaryEvent.Open(it)) } + .onFailure { _events.emit(DiaryEvent.Message(it.message ?: "일기를 열지 못했습니다.")) } + } + } + + fun saveDiary(diaryId: Long?, request: DiaryRequestDto, imageUri: Uri? = null) { + val current = _uiState.value as? DiaryUiState.Ready ?: return + if (current.isWorking || current.petId != request.petId) return + viewModelScope.launch { + _uiState.value = current.copy(isWorking = true) + val imageKey = imageUri?.let { uri -> + repository.uploadDiaryImage(uri).getOrElse { + _uiState.value = current + _events.emit(DiaryEvent.Message(it.message ?: "사진을 업로드하지 못했습니다.")) + return@launch + } + } + val savedRequest = if (imageKey == null) request else request.copy(imageKeys = listOf(imageKey)) + repository.saveDiary(diaryId, savedRequest) + .onSuccess { + _events.emit(DiaryEvent.Saved) + load() + } + .onFailure { + _uiState.value = current + _events.emit(DiaryEvent.Message(it.message ?: "일기를 저장하지 못했습니다.")) + } + } + } + + fun deleteDiary(diaryId: Long) { + val current = _uiState.value as? DiaryUiState.Ready ?: return + if (current.isWorking) return + viewModelScope.launch { + _uiState.value = current.copy(isWorking = true) + repository.deleteDiary(diaryId) + .onSuccess { + _uiState.value = current.copy(entries = current.entries.filterNot { it.diaryId == diaryId }) + _events.emit(DiaryEvent.Message("일기를 삭제했습니다.")) + } + .onFailure { + _uiState.value = current + _events.emit(DiaryEvent.Message(it.message ?: "일기를 삭제하지 못했습니다.")) + } + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryFragment.kt index 5da1ea3..bdeb756 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryFragment.kt @@ -25,6 +25,7 @@ class HistoryFragment : Fragment() { private lateinit var historyAdapter: HistoryAdapter private val viewModel: HistoryViewModel by viewModels() + private var hasResumed = false override fun onCreateView( inflater: LayoutInflater, @@ -43,6 +44,11 @@ class HistoryFragment : Fragment() { binding.btnRetry.setOnClickListener { viewModel.loadHistory() } } + override fun onResume() { + super.onResume() + if (hasResumed) viewModel.loadHistory() else hasResumed = true + } + private fun setupRecyclerView() { historyAdapter = HistoryAdapter { item -> findNavController().navigate( diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HospitalFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HospitalFragment.kt index 966a939..27c7aac 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HospitalFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HospitalFragment.kt @@ -4,25 +4,107 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.databinding.DialogHospitalDetailBinding import kr.ac.anu.mumu.databinding.FragmentHospitalBinding +import kr.ac.anu.mumu.presentation.main.adapter.HospitalAdapter +import java.text.NumberFormat @AndroidEntryPoint class HospitalFragment : Fragment() { private var _binding: FragmentHospitalBinding? = null private val binding get() = _binding!! + private val viewModel: HospitalViewModel by viewModels() + private val adapter = HospitalAdapter { viewModel.open(it.hospitalId) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { _binding = FragmentHospitalBinding.inflate(inflater, container, false) return binding.root } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.rvHospitals.layoutManager = LinearLayoutManager(requireContext()) + binding.rvHospitals.adapter = adapter + binding.btnHospitalSearch.setOnClickListener { viewModel.search(binding.etHospitalSearch.text.toString()) } + binding.etHospitalSearch.setOnEditorActionListener { _, action, _ -> + if (action == EditorInfo.IME_ACTION_SEARCH) { + viewModel.search(binding.etHospitalSearch.text.toString()) + true + } else { + false + } + } + binding.btnMoreHospitals.setOnClickListener { viewModel.loadMore() } + binding.tvHospitalState.setOnClickListener { viewModel.search(binding.etHospitalSearch.text.toString()) } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.uiState.collect(::render) } + launch { + viewModel.events.collect { + when (it) { + is HospitalEvent.Open -> showDetail(it.content) + is HospitalEvent.Message -> Toast.makeText(requireContext(), it.text, Toast.LENGTH_SHORT).show() + } + } + } + } + } + } + + private fun render(state: HospitalUiState) { + binding.progressHospitals.visibility = if (state is HospitalUiState.Loading) View.VISIBLE else View.GONE + binding.tvHospitalState.visibility = if (state is HospitalUiState.Error || state is HospitalUiState.Ready && state.hospitals.isEmpty()) View.VISIBLE else View.GONE + binding.tvHospitalState.text = when (state) { + is HospitalUiState.Error -> "${state.message}\n탭해서 다시 시도해 주세요." + is HospitalUiState.Ready -> "검색 결과가 없습니다." + HospitalUiState.Loading -> "" + } + if (state is HospitalUiState.Ready) { + adapter.submitList(state.hospitals) + binding.btnMoreHospitals.visibility = if (state.nextPage == null) View.GONE else View.VISIBLE + binding.btnMoreHospitals.isEnabled = !state.isLoadingMore + binding.btnMoreHospitals.text = if (state.isLoadingMore) "불러오는 중…" else "더 보기" + } else { + adapter.submitList(emptyList()) + binding.btnMoreHospitals.visibility = View.GONE + } + } + + private fun showDetail(content: HospitalDetailContent) { + val dialogBinding = DialogHospitalDetailBinding.inflate(layoutInflater) + val hospital = content.hospital + dialogBinding.tvDetailName.text = hospital.name + dialogBinding.tvDetailMeta.text = "★ %.1f · 리뷰 %d개".format(hospital.averageRating ?: 0.0, hospital.totalReviews ?: 0) + dialogBinding.tvDetailContact.text = listOfNotNull(hospital.address, hospital.phone, hospital.website).joinToString("\n") + dialogBinding.tvDetailSpecialties.text = hospital.specialties?.takeIf { it.isNotEmpty() }?.joinToString(" · ") ?: "진료 분야 정보가 없습니다." + val currency = NumberFormat.getIntegerInstance() + dialogBinding.tvDetailPrices.text = content.prices.takeIf { it.isNotEmpty() }?.joinToString("\n\n") { + val range = listOfNotNull(it.minPrice, it.maxPrice).joinToString(" ~ ") { price -> "${currency.format(price)}원" } + "${it.treatmentType} (${it.species ?: "공통"})\n${range.ifEmpty { "가격 문의" }}" + } ?: "등록된 진료비 정보가 없습니다." + dialogBinding.tvDetailReviews.text = content.reviews.takeIf { it.isNotEmpty() }?.joinToString("\n\n") { + "★ ${it.rating} ${it.content.orEmpty()}" + } ?: "아직 작성된 리뷰가 없습니다." + val dialog = AlertDialog.Builder(requireContext()).setView(dialogBinding.root).create() + dialogBinding.btnClose.setOnClickListener { dialog.dismiss() } + dialog.show() + } + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HospitalViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HospitalViewModel.kt new file mode 100644 index 0000000..e5091a5 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HospitalViewModel.kt @@ -0,0 +1,72 @@ +package kr.ac.anu.mumu.presentation.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.data.model.HospitalDetailDto +import kr.ac.anu.mumu.data.model.HospitalListDto +import kr.ac.anu.mumu.data.model.HospitalPriceDto +import kr.ac.anu.mumu.data.model.HospitalReviewDto +import kr.ac.anu.mumu.domain.repository.HospitalRepository +import javax.inject.Inject + +sealed interface HospitalUiState { + data object Loading : HospitalUiState + data class Ready(val hospitals: List, val keyword: String, val nextPage: Int?, val isLoadingMore: Boolean = false) : HospitalUiState + data class Error(val message: String) : HospitalUiState +} + +data class HospitalDetailContent(val hospital: HospitalDetailDto, val prices: List, val reviews: List) +sealed interface HospitalEvent { + data class Open(val content: HospitalDetailContent) : HospitalEvent + data class Message(val text: String) : HospitalEvent +} + +@HiltViewModel +class HospitalViewModel @Inject constructor(private val repository: HospitalRepository) : ViewModel() { + private val _uiState = MutableStateFlow(HospitalUiState.Loading) + val uiState = _uiState.asStateFlow() + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events = _events.asSharedFlow() + + init { search("") } + + fun search(keyword: String) = viewModelScope.launch { + _uiState.value = HospitalUiState.Loading + repository.search(keyword.trim().takeIf(String::isNotEmpty), 0) + .onSuccess { page -> _uiState.value = HospitalUiState.Ready(page.content, keyword.trim(), page.nextPage()) } + .onFailure { _uiState.value = HospitalUiState.Error(it.message ?: "병원을 찾지 못했습니다.") } + } + + fun loadMore() { + val current = _uiState.value as? HospitalUiState.Ready ?: return + val number = current.nextPage ?: return + if (current.isLoadingMore) return + _uiState.value = current.copy(isLoadingMore = true) + viewModelScope.launch { + repository.search(current.keyword.takeIf(String::isNotEmpty), number) + .onSuccess { page -> _uiState.value = current.copy(hospitals = current.hospitals + page.content, nextPage = page.nextPage()) } + .onFailure { _uiState.value = current; _events.emit(HospitalEvent.Message(it.message ?: "더 불러오지 못했습니다.")) } + } + } + + fun open(id: Long) = viewModelScope.launch { + val detail = async { repository.getDetail(id) } + val prices = async { repository.getPrices(id) } + val reviews = async { repository.getReviews(id) } + detail.await().onSuccess { + _events.emit(HospitalEvent.Open(HospitalDetailContent(it, prices.await().getOrDefault(emptyList()), reviews.await().getOrDefault(emptyList())))) + }.onFailure { + prices.await(); reviews.await() + _events.emit(HospitalEvent.Message(it.message ?: "병원 정보를 불러오지 못했습니다.")) + } + } + + private fun kr.ac.anu.mumu.data.model.PaginatedData.nextPage() = if (page + 1 < totalPages) page + 1 else null +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MyFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MyFragment.kt index 20ec90d..7ce5b5b 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MyFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MyFragment.kt @@ -1,16 +1,41 @@ package kr.ac.anu.mumu.presentation.main +import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatButton import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.data.local.SessionManager +import kr.ac.anu.mumu.data.model.PetDto +import kr.ac.anu.mumu.data.model.PetRequestDto +import kr.ac.anu.mumu.databinding.DialogPetFormBinding import kr.ac.anu.mumu.databinding.FragmentMyBinding +import kr.ac.anu.mumu.databinding.ItemPetProfileBinding +import kr.ac.anu.mumu.presentation.login.LoginActivity +import java.time.LocalDate +import java.time.format.DateTimeParseException +import java.util.Calendar +import javax.inject.Inject @AndroidEntryPoint class MyFragment : Fragment() { + @Inject lateinit var sessionManager: SessionManager + private val viewModel: MyViewModel by viewModels() + private var petDialog: AlertDialog? = null + private var _binding: FragmentMyBinding? = null private val binding get() = _binding!! @@ -23,8 +48,175 @@ class MyFragment : Fragment() { return binding.root } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.btnAddPet.setOnClickListener { showPetForm(null) } + binding.tvPetsState.setOnClickListener { + if (viewModel.uiState.value is MyUiState.Error) viewModel.loadPets() + } + binding.btnLogout.setOnClickListener { + sessionManager.clearTokens() + startActivity( + Intent(requireContext(), LoginActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + ) + } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.uiState.collect(::render) } + launch { + viewModel.events.collect { event -> + when (event) { + MyEvent.Saved -> { + petDialog?.dismiss() + petDialog = null + Toast.makeText(requireContext(), "반려동물을 저장했습니다.", Toast.LENGTH_SHORT).show() + } + is MyEvent.Message -> { + petDialog?.findViewById(R.id.btn_save_pet)?.isEnabled = true + Toast.makeText(requireContext(), event.text, Toast.LENGTH_SHORT).show() + } + } + } + } + } + } + } + + private fun render(state: MyUiState) { + binding.progressPets.visibility = if (state is MyUiState.Loading) View.VISIBLE else View.GONE + binding.btnAddPet.isEnabled = state is MyUiState.Ready && !state.isSaving + binding.tvPetsState.visibility = if ( + state is MyUiState.Error || state is MyUiState.Ready && state.pets.isEmpty() + ) { + View.VISIBLE + } else { + View.GONE + } + binding.tvPetsState.text = when (state) { + is MyUiState.Error -> "${state.message}\n탭해서 다시 시도해 주세요." + is MyUiState.Ready -> "아직 등록한 반려동물이 없어요." + MyUiState.Loading -> "" + } + binding.layoutPets.removeAllViews() + if (state is MyUiState.Ready) { + state.pets.forEach { pet -> + val row = ItemPetProfileBinding.inflate(layoutInflater, binding.layoutPets, false) + row.tvPetName.text = pet.name.orEmpty() + row.tvPetInfo.text = listOfNotNull( + pet.species?.toSpeciesLabel(), + pet.breed?.takeIf(String::isNotBlank), + pet.birthDate?.takeIf(String::isNotBlank) + ).joinToString(" · ") + row.btnEditPet.isEnabled = !state.isSaving + row.btnDeletePet.isEnabled = !state.isSaving + row.btnSelectPet.text = if (state.selectedPetId == pet.petId) "대표 아이 ✓" else "대표로 선택" + row.btnSelectPet.isEnabled = !state.isSaving && state.selectedPetId != pet.petId + row.btnSelectPet.setOnClickListener { viewModel.selectPet(pet.petId) } + row.btnEditPet.setOnClickListener { showPetForm(pet) } + row.btnDeletePet.setOnClickListener { confirmDelete(pet) } + binding.layoutPets.addView(row.root) + } + } + } + + private fun showPetForm(pet: PetDto?) { + val form = DialogPetFormBinding.inflate(layoutInflater) + val species = listOf("강아지", "고양이", "기타") + val genders = listOf("성별 모름", "수컷", "암컷") + form.spinnerSpecies.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, species) + form.spinnerGender.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, genders) + form.tvFormTitle.text = if (pet == null) "반려동물 등록" else "반려동물 수정" + pet?.let { + form.etPetName.setText(it.name) + form.spinnerSpecies.setSelection(listOf("DOG", "CAT", "OTHER").indexOf(it.species).coerceAtLeast(0)) + form.etBreed.setText(it.breed) + form.spinnerGender.setSelection(listOf("unknown", "male", "female").indexOf(it.gender).coerceAtLeast(0)) + form.etBirthDate.setText(it.birthDate) + form.etWeight.setText(it.weight?.toString()) + form.switchNeutered.isChecked = it.neutered + form.etAllergies.setText(it.allergies) + form.etChronicDiseases.setText(it.chronicDiseases) + form.etMedications.setText(it.medications) + } + form.etBirthDate.setOnClickListener { + val today = Calendar.getInstance() + android.app.DatePickerDialog( + requireContext(), + { _, year, month, day -> form.etBirthDate.setText("%04d-%02d-%02d".format(year, month + 1, day)) }, + today.get(Calendar.YEAR), + today.get(Calendar.MONTH), + today.get(Calendar.DAY_OF_MONTH) + ).show() + } + + val dialog = AlertDialog.Builder(requireContext()).setView(form.root).create() + petDialog = dialog + form.btnCancel.setOnClickListener { dialog.dismiss() } + form.btnSavePet.setOnClickListener { + val name = form.etPetName.text?.toString()?.trim().orEmpty() + val date = form.etBirthDate.text?.toString()?.takeIf(String::isNotBlank) + val weightText = form.etWeight.text?.toString()?.trim().orEmpty() + val weight = weightText.toDoubleOrNull() + val validationError = when { + name.isBlank() -> "이름을 입력해 주세요." + name.length > 50 -> "이름은 50자 이하로 입력해 주세요." + date != null && !date.isValidDate() -> "생년월일을 확인해 주세요." + weightText.isNotEmpty() && (weight == null || weight !in 0.0..999.99) -> + "몸무게는 0~999.99kg으로 입력해 주세요." + else -> null + } + if (validationError != null) { + form.tvFormError.text = validationError + form.tvFormError.visibility = View.VISIBLE + return@setOnClickListener + } + form.btnSavePet.isEnabled = false + viewModel.savePet( + pet?.petId, + PetRequestDto( + name = name, + species = listOf("DOG", "CAT", "OTHER")[form.spinnerSpecies.selectedItemPosition], + breed = form.etBreed.text?.toString()?.trim().orEmpty().ifEmpty { null }, + gender = listOf("unknown", "male", "female")[form.spinnerGender.selectedItemPosition], + birthDate = date, + weight = weight, + neutered = form.switchNeutered.isChecked, + allergies = form.etAllergies.text?.toString()?.trim().orEmpty().ifEmpty { null }, + chronicDiseases = form.etChronicDiseases.text?.toString()?.trim().orEmpty().ifEmpty { null }, + medications = form.etMedications.text?.toString()?.trim().orEmpty().ifEmpty { null } + ) + ) + } + dialog.setOnDismissListener { if (petDialog == dialog) petDialog = null } + dialog.show() + } + + private fun confirmDelete(pet: PetDto) { + AlertDialog.Builder(requireContext()) + .setTitle("${pet.name} 정보를 삭제할까요?") + .setMessage("삭제하면 분석에 사용할 수 없어요.") + .setNegativeButton("취소", null) + .setPositiveButton("삭제") { _, _ -> viewModel.deletePet(pet.petId) } + .show() + } + override fun onDestroyView() { + petDialog?.dismiss() + petDialog = null super.onDestroyView() _binding = null } } + +private fun String.toSpeciesLabel(): String = when (uppercase()) { + "DOG" -> "강아지" + "CAT" -> "고양이" + else -> "기타" +} + +private fun String.isValidDate(): Boolean = try { + !LocalDate.parse(this).isAfter(LocalDate.now()) +} catch (_: DateTimeParseException) { + false +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MyViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MyViewModel.kt new file mode 100644 index 0000000..20ee42f --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MyViewModel.kt @@ -0,0 +1,101 @@ +package kr.ac.anu.mumu.presentation.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.data.local.SessionManager +import kr.ac.anu.mumu.data.model.PetDto +import kr.ac.anu.mumu.data.model.PetRequestDto +import kr.ac.anu.mumu.data.model.selectedPetId +import kr.ac.anu.mumu.domain.repository.PetRepository +import javax.inject.Inject + +sealed interface MyUiState { + data object Loading : MyUiState + data class Ready( + val pets: List, + val selectedPetId: Long?, + val isSaving: Boolean = false + ) : MyUiState + data class Error(val message: String) : MyUiState +} + +sealed interface MyEvent { + data object Saved : MyEvent + data class Message(val text: String) : MyEvent +} + +@HiltViewModel +class MyViewModel @Inject constructor( + private val repository: PetRepository, + private val sessionManager: SessionManager +) : ViewModel() { + private val _uiState = MutableStateFlow(MyUiState.Loading) + val uiState = _uiState.asStateFlow() + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events = _events.asSharedFlow() + + init { loadPets() } + + fun loadPets() { + viewModelScope.launch { + _uiState.value = MyUiState.Loading + repository.getPets() + .onSuccess { pets -> + val selected = pets.selectedPetId(sessionManager.selectedPetId) + sessionManager.selectPet(selected) + _uiState.value = MyUiState.Ready(pets, selected) + } + .onFailure { _uiState.value = MyUiState.Error(it.message ?: "반려동물을 불러오지 못했습니다.") } + } + } + + fun selectPet(petId: Long) { + val current = _uiState.value as? MyUiState.Ready ?: return + if (current.isSaving || current.pets.none { it.petId == petId }) return + sessionManager.selectPet(petId) + _uiState.value = current.copy(selectedPetId = petId) + } + + fun savePet(petId: Long?, request: PetRequestDto) { + val current = _uiState.value as? MyUiState.Ready ?: return + if (current.isSaving) return + viewModelScope.launch { + _uiState.value = current.copy(isSaving = true) + repository.savePet(petId, request) + .onSuccess { + _events.emit(MyEvent.Saved) + loadPets() + } + .onFailure { + _uiState.value = current + _events.emit(MyEvent.Message(it.message ?: "저장하지 못했습니다.")) + } + } + } + + fun deletePet(petId: Long) { + val current = _uiState.value as? MyUiState.Ready ?: return + if (current.isSaving) return + viewModelScope.launch { + _uiState.value = current.copy(isSaving = true) + repository.deletePet(petId) + .onSuccess { + val pets = current.pets.filterNot { it.petId == petId } + val selected = current.selectedPetId.takeIf { it != petId } ?: pets.firstOrNull()?.petId + sessionManager.selectPet(selected) + _uiState.value = current.copy(pets = pets, selectedPetId = selected) + _events.emit(MyEvent.Message("반려동물을 삭제했습니다.")) + } + .onFailure { + _uiState.value = current + _events.emit(MyEvent.Message(it.message ?: "삭제하지 못했습니다.")) + } + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt index 3b8a5f6..22c8bac 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt @@ -1,6 +1,7 @@ package kr.ac.anu.mumu.presentation.main.adapter import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter @@ -8,10 +9,15 @@ import androidx.recyclerview.widget.RecyclerView import kr.ac.anu.mumu.databinding.ItemCommunityCommentBinding import kr.ac.anu.mumu.domain.model.CommunityComment -class CommunityCommentAdapter : +class CommunityCommentAdapter( + private val onEdit: (CommunityComment) -> Unit, + private val onDelete: (CommunityComment) -> Unit +) : ListAdapter(DiffCallback) { - class CommentViewHolder( + private var currentUserId: Long? = null + + inner class CommentViewHolder( private val binding: ItemCommunityCommentBinding ) : RecyclerView.ViewHolder(binding.root) { fun bind(comment: CommunityComment) { @@ -19,9 +25,19 @@ class CommunityCommentAdapter : binding.tvContent.text = comment.content binding.tvMeta.text = "${comment.createdAt.take(10).replace('-', '.')} · 좋아요 ${comment.likeCount}" + val isOwner = currentUserId != null && currentUserId == comment.userId + binding.layoutCommentManage.visibility = if (isOwner) View.VISIBLE else View.GONE + binding.btnEditComment.setOnClickListener { onEdit(comment) } + binding.btnDeleteComment.setOnClickListener { onDelete(comment) } } } + fun setCurrentUserId(userId: Long?) { + if (currentUserId == userId) return + currentUserId = userId + notifyItemRangeChanged(0, itemCount) + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentViewHolder { val binding = ItemCommunityCommentBinding.inflate( LayoutInflater.from(parent.context), diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/DiaryAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/DiaryAdapter.kt new file mode 100644 index 0000000..e933941 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/DiaryAdapter.kt @@ -0,0 +1,49 @@ +package kr.ac.anu.mumu.presentation.main.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil3.load +import kr.ac.anu.mumu.data.model.DiaryListDto +import kr.ac.anu.mumu.databinding.ItemDiaryBinding + +class DiaryAdapter( + private val onClick: (DiaryListDto) -> Unit +) : ListAdapter(Diff) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = ViewHolder( + ItemDiaryBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(getItem(position)) + + inner class ViewHolder(private val binding: ItemDiaryBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: DiaryListDto) { + binding.tvDiaryDate.text = "${item.diaryDate.replace('-', '.')} · ${item.mood.toMoodLabel()}" + binding.tvDiaryTitle.text = item.title + binding.tvDiaryPreview.text = item.contentPreview.orEmpty() + binding.ivDiaryThumbnail.visibility = if (item.thumbnailUrl.isNullOrBlank()) View.GONE else View.VISIBLE + if (item.thumbnailUrl.isNullOrBlank()) { + binding.ivDiaryThumbnail.setImageDrawable(null) + } else { + binding.ivDiaryThumbnail.load(item.thumbnailUrl) + } + binding.root.setOnClickListener { onClick(item) } + } + } + + private object Diff : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DiaryListDto, newItem: DiaryListDto): Boolean = + oldItem.diaryId == newItem.diaryId + + override fun areContentsTheSame(oldItem: DiaryListDto, newItem: DiaryListDto): Boolean = oldItem == newItem + } +} + +fun String.toMoodLabel(): String = when (lowercase()) { + "happy" -> "행복" + "sad" -> "속상" + else -> "보통" +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/HospitalAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/HospitalAdapter.kt new file mode 100644 index 0000000..49374d7 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/HospitalAdapter.kt @@ -0,0 +1,30 @@ +package kr.ac.anu.mumu.presentation.main.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil3.load +import kr.ac.anu.mumu.data.model.HospitalListDto +import kr.ac.anu.mumu.databinding.ItemHospitalBinding + +class HospitalAdapter(private val onClick: (HospitalListDto) -> Unit) : ListAdapter(Diff) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(ItemHospitalBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(getItem(position)) + + inner class ViewHolder(private val binding: ItemHospitalBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: HospitalListDto) = with(binding) { + tvHospitalName.text = item.name + tvHospitalAddress.text = item.address ?: "주소 정보 없음" + tvHospitalMeta.text = "★ %.1f (%d) · %.1fkm".format(item.averageRating ?: 0.0, item.totalReviews ?: 0, item.distanceKm ?: 0.0) + if (item.thumbnailUrl.isNullOrBlank()) ivHospital.setImageDrawable(null) else ivHospital.load(item.thumbnailUrl) + root.setOnClickListener { onClick(item) } + } + } + + private object Diff : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: HospitalListDto, newItem: HospitalListDto) = oldItem.hospitalId == newItem.hospitalId + override fun areContentsTheSame(oldItem: HospitalListDto, newItem: HospitalListDto) = oldItem == newItem + } +} diff --git a/app/src/main/res/layout/dialog_diary_detail.xml b/app/src/main/res/layout/dialog_diary_detail.xml new file mode 100644 index 0000000..968c4e4 --- /dev/null +++ b/app/src/main/res/layout/dialog_diary_detail.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_diary_form.xml b/app/src/main/res/layout/dialog_diary_form.xml new file mode 100644 index 0000000..4925065 --- /dev/null +++ b/app/src/main/res/layout/dialog_diary_form.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_hospital_detail.xml b/app/src/main/res/layout/dialog_hospital_detail.xml new file mode 100644 index 0000000..7e175b0 --- /dev/null +++ b/app/src/main/res/layout/dialog_hospital_detail.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_pet_form.xml b/app/src/main/res/layout/dialog_pet_form.xml new file mode 100644 index 0000000..49f0dec --- /dev/null +++ b/app/src/main/res/layout/dialog_pet_form.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_community.xml b/app/src/main/res/layout/fragment_community.xml index 2995a75..222e785 100644 --- a/app/src/main/res/layout/fragment_community.xml +++ b/app/src/main/res/layout/fragment_community.xml @@ -76,8 +76,9 @@ android:id="@+id/layout_guardian_ranking" android:layout_width="142dp" android:layout_height="179dp" - android:layout_marginStart="16dp" android:layout_marginTop="24dp" + app:layout_constraintEnd_toStartOf="@id/layout_popular_posts" + app:layout_constraintHorizontal_chainStyle="packed" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/layout_quick_menu"> @@ -120,15 +121,11 @@ android:layout_width="142dp" android:layout_height="179dp" android:layout_marginTop="7dp" + android:background="@drawable/bg_community_feed_panel" + android:clipToOutline="true" app:layout_constraintStart_toStartOf="@id/layout_guardian_ranking" app:layout_constraintTop_toBottomOf="@id/layout_guardian_ranking"> - - diff --git a/app/src/main/res/layout/fragment_community_detail.xml b/app/src/main/res/layout/fragment_community_detail.xml index dd2cb16..48adfba 100644 --- a/app/src/main/res/layout/fragment_community_detail.xml +++ b/app/src/main/res/layout/fragment_community_detail.xml @@ -12,11 +12,14 @@ android:layout_height="0dp" android:clipToPadding="false" android:fillViewport="true" + android:overScrollMode="never" + android:scrollbars="none" android:visibility="gone" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> + app:layout_constraintTop_toTopOf="parent" + tools:visibility="visible"> + + + + + + + + + + + + + - - - - - - - - - - - - - + android:background="@color/white" + android:orientation="vertical" + android:padding="20dp"> + + - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_hospital.xml b/app/src/main/res/layout/fragment_hospital.xml index 77d9ef6..eac7b15 100644 --- a/app/src/main/res/layout/fragment_hospital.xml +++ b/app/src/main/res/layout/fragment_hospital.xml @@ -1,6 +1,17 @@ + android:layout_height="match_parent" + android:background="@color/white" + android:paddingHorizontal="20dp"> - \ No newline at end of file + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_my.xml b/app/src/main/res/layout/fragment_my.xml index 77d9ef6..db77109 100644 --- a/app/src/main/res/layout/fragment_my.xml +++ b/app/src/main/res/layout/fragment_my.xml @@ -1,6 +1,91 @@ - + android:layout_height="match_parent" + android:background="@color/white"> - \ No newline at end of file + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_community_comment.xml b/app/src/main/res/layout/item_community_comment.xml index a9e7dfd..d291bf3 100644 --- a/app/src/main/res/layout/item_community_comment.xml +++ b/app/src/main/res/layout/item_community_comment.xml @@ -29,11 +29,51 @@ android:fontFamily="@font/pretendard_semibold" android:textColor="@color/mumugray_400" android:textSize="12sp" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@id/layout_comment_manage" app:layout_constraintStart_toEndOf="@id/tv_avatar" app:layout_constraintTop_toTopOf="@id/tv_avatar" tools:text="사용자 2" /> + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_hospital.xml b/app/src/main/res/layout/item_hospital.xml new file mode 100644 index 0000000..89b65b1 --- /dev/null +++ b/app/src/main/res/layout/item_hospital.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/item_pet_profile.xml b/app/src/main/res/layout/item_pet_profile.xml new file mode 100644 index 0000000..f32e5fd --- /dev/null +++ b/app/src/main/res/layout/item_pet_profile.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/nav_graph_main.xml b/app/src/main/res/navigation/nav_graph_main.xml index b565c81..04432ef 100644 --- a/app/src/main/res/navigation/nav_graph_main.xml +++ b/app/src/main/res/navigation/nav_graph_main.xml @@ -110,12 +110,19 @@ + + ().selectedPetId(2L)) + } +} diff --git a/app/src/test/java/kr/ac/anu/mumu/presentation/main/CommunityInputValidatorTest.kt b/app/src/test/java/kr/ac/anu/mumu/presentation/main/CommunityInputValidatorTest.kt new file mode 100644 index 0000000..f58ab42 --- /dev/null +++ b/app/src/test/java/kr/ac/anu/mumu/presentation/main/CommunityInputValidatorTest.kt @@ -0,0 +1,37 @@ +package kr.ac.anu.mumu.presentation.main + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CommunityInputValidatorTest { + + @Test + fun `게시글 제목과 내용이 비어 있으면 오류를 반환한다`() { + assertEquals("제목을 입력해 주세요.", CommunityInputValidator.validatePost(" ", "내용")) + assertEquals("내용을 입력해 주세요.", CommunityInputValidator.validatePost("제목", " ")) + } + + @Test + fun `게시글 제목은 200자까지 허용한다`() { + assertNull( + CommunityInputValidator.validatePost( + "가".repeat(CommunityInputValidator.MAX_TITLE_LENGTH), + "내용" + ) + ) + assertEquals( + "제목은 200자 이하로 입력해 주세요.", + CommunityInputValidator.validatePost( + "가".repeat(CommunityInputValidator.MAX_TITLE_LENGTH + 1), + "내용" + ) + ) + } + + @Test + fun `댓글은 공백만 입력할 수 없다`() { + assertEquals("댓글 내용을 입력해 주세요.", CommunityInputValidator.validateComment(" ")) + assertNull(CommunityInputValidator.validateComment("좋아요")) + } +} diff --git a/app/src/test/java/kr/ac/anu/mumu/presentation/main/DiaryCalendarDaysTest.kt b/app/src/test/java/kr/ac/anu/mumu/presentation/main/DiaryCalendarDaysTest.kt new file mode 100644 index 0000000..db2de37 --- /dev/null +++ b/app/src/test/java/kr/ac/anu/mumu/presentation/main/DiaryCalendarDaysTest.kt @@ -0,0 +1,22 @@ +package kr.ac.anu.mumu.presentation.main + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.LocalDate +import java.time.YearMonth + +class DiaryCalendarDaysTest { + @Test fun september2026StartsOnTuesdayAndFillsWeeks() { + val cells = YearMonth.of(2026, 9).toCalendarCells() + assertNull(cells[0]) + assertEquals(LocalDate.of(2026, 9, 1), cells[1]) + assertEquals(35, cells.size) + } + + @Test fun leapFebruaryIncludes29th() { + val cells = YearMonth.of(2024, 2).toCalendarCells() + assertEquals(LocalDate.of(2024, 2, 29), cells.filterNotNull().last()) + assertEquals(35, cells.size) + } +}