diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3dbbbf2..a6fb3e4 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -48,6 +48,10 @@ dependencies {
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
+ implementation(libs.coil)
+ implementation(libs.coil.network.okhttp)
+ implementation(libs.navigation.fragment.ktx)
+ implementation(libs.navigation.ui.ktx)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 111c250..244d0f4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,18 +4,33 @@
+
+
+
+
+
+
+
+
+
@@ -33,4 +48,4 @@
android:exported="false" />
-
\ No newline at end of file
+
diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/AnalysisService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/AnalysisService.kt
new file mode 100644
index 0000000..80dac51
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/AnalysisService.kt
@@ -0,0 +1,27 @@
+package kr.ac.anu.mumu.data.datasource
+
+import kr.ac.anu.mumu.data.model.AnalysisDetailDto
+import kr.ac.anu.mumu.data.model.AnalysisTriggerDto
+import kr.ac.anu.mumu.data.model.BaseResponse
+import okhttp3.MultipartBody
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Multipart
+import retrofit2.http.POST
+import retrofit2.http.Part
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface AnalysisService {
+ @Multipart
+ @POST("/api/behavior-analyses")
+ suspend fun triggerBehaviorAnalysis(
+ @Query("petId") petId: Long,
+ @Part video: MultipartBody.Part
+ ): Response>
+
+ @GET("/api/behavior-analyses/{id}")
+ suspend fun getBehaviorAnalysis(
+ @Path("id") analysisId: Long
+ ): Response>
+}
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 8c3d418..3968755 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
@@ -7,6 +7,6 @@ import retrofit2.http.Body
import retrofit2.http.POST
interface AuthService {
- @POST("/api/users/login")
+ @POST("/api/auth/login")
suspend fun login(@Body request: LoginRequest): Response
}
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
new file mode 100644
index 0000000..b6e2926
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt
@@ -0,0 +1,16 @@
+package kr.ac.anu.mumu.data.datasource
+
+import kr.ac.anu.mumu.data.model.BaseResponse
+import kr.ac.anu.mumu.data.model.CommunityPostDto
+import kr.ac.anu.mumu.data.model.PaginatedData
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface CommunityService {
+ @GET("/api/community")
+ suspend fun getPosts(
+ @Query("page") page: Int = 0,
+ @Query("size") size: Int = 50
+ ): Response>>
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/HistoryService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/HistoryService.kt
new file mode 100644
index 0000000..eabbad9
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/HistoryService.kt
@@ -0,0 +1,20 @@
+package kr.ac.anu.mumu.data.datasource
+
+import kr.ac.anu.mumu.data.model.AnalysisHistoryDto
+import kr.ac.anu.mumu.data.model.BaseResponse
+import kr.ac.anu.mumu.data.model.PaginatedData
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface HistoryService {
+ @GET("/api/pets/{petId}/analyses")
+ suspend fun getAnalysisHistory(
+ @Path("petId") petId: Long,
+ @Query("type") type: String = "behavior",
+ @Query("status") status: String = "completed",
+ @Query("page") page: Int = 0,
+ @Query("size") size: Int = 50
+ ): 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
new file mode 100644
index 0000000..8ac62a7
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/PetService.kt
@@ -0,0 +1,11 @@
+package kr.ac.anu.mumu.data.datasource
+
+import kr.ac.anu.mumu.data.model.BaseResponse
+import kr.ac.anu.mumu.data.model.PetDto
+import retrofit2.Response
+import retrofit2.http.GET
+
+interface PetService {
+ @GET("/api/pets")
+ suspend fun getMyPets(): Response>>
+}
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
new file mode 100644
index 0000000..522e1d5
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/local/SessionManager.kt
@@ -0,0 +1,31 @@
+package kr.ac.anu.mumu.data.local
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SessionManager @Inject constructor(
+ @ApplicationContext context: Context
+) {
+ private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+
+ val accessToken: String?
+ get() = preferences.getString(KEY_ACCESS_TOKEN, null)
+
+ fun saveTokens(accessToken: String, refreshToken: String, tokenType: String) {
+ preferences.edit()
+ .putString(KEY_ACCESS_TOKEN, accessToken)
+ .putString(KEY_REFRESH_TOKEN, refreshToken)
+ .putString(KEY_TOKEN_TYPE, tokenType)
+ .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"
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/data/mapper/AnalysisHistoryDto.kt b/app/src/main/java/kr/ac/anu/mumu/data/mapper/AnalysisHistoryDto.kt
new file mode 100644
index 0000000..11499ec
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/mapper/AnalysisHistoryDto.kt
@@ -0,0 +1,35 @@
+package kr.ac.anu.mumu.data.mapper
+
+import kr.ac.anu.mumu.data.model.AnalysisHistoryDto
+import kr.ac.anu.mumu.domain.model.AnalysisHistory
+import java.time.OffsetDateTime
+import java.time.format.DateTimeFormatter
+import kotlin.math.roundToInt
+
+fun AnalysisHistoryDto.toDomain(): AnalysisHistory {
+ val formattedDate = analyzedAt?.let { value ->
+ runCatching {
+ OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern("yyyy.MM.dd"))
+ }.getOrDefault(value.take(10).replace('-', '.'))
+ } ?: "-"
+
+ val probability = confidence?.roundToInt()?.coerceIn(0, 100) ?: 0
+ val isNormal = resultLabel.equals("NORMAL", ignoreCase = true) ||
+ resultLabel == "정상" ||
+ (suspectedItems.isNullOrEmpty() && probability < 50)
+
+ val joinedBehavior = if (suspectedItems.isNullOrEmpty()) {
+ if (isNormal) "특이 행동 없음" else resultLabel ?: "분석 결과 확인"
+ } else {
+ suspectedItems.joinToString(", ")
+ }
+
+ return AnalysisHistory(
+ id = this.analysisId,
+ isNormal = isNormal,
+ behaviorText = joinedBehavior,
+ probability = probability,
+ date = formattedDate,
+ resultLabel = resultLabel
+ )
+}
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
new file mode 100644
index 0000000..146c834
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/model/AnalysisDtos.kt
@@ -0,0 +1,21 @@
+package kr.ac.anu.mumu.data.model
+
+data class AnalysisTriggerDto(
+ val analysisId: Long,
+ val status: String
+)
+
+data class AnalysisDetailDto(
+ val analysisId: Long,
+ val type: String?,
+ val status: String,
+ val resultLabel: String?,
+ val suspectedItems: List?,
+ val confidence: Double?,
+ val analyzedAt: String?
+)
+
+data class PetDto(
+ val petId: Long,
+ val name: 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 01e8b01..7b07c19 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
@@ -1,28 +1,20 @@
package kr.ac.anu.mumu.data.model
-import com.google.gson.annotations.SerializedName
-
data class LoginRequest(
- @SerializedName("id")
- val id: String,
- @SerializedName("password")
- val pw: String
+ val loginId: String,
+ val password: String,
+ val deviceType: String = "android",
+ val deviceInfo: String? = null
)
data class LoginResponse(
val success: Boolean,
val message: String,
- val token: String,
- val user: UserDto
+ val data: TokenDto
)
-data class UserDto(
- @SerializedName("user_id")
- val userId: Int,
- val id: String,
- val name: String,
- val phone: String?,
- @SerializedName("profile_image")
- val profileImage: String?,
- val role: String
+data class TokenDto(
+ val accessToken: String,
+ val refreshToken: String,
+ val tokenType: 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
new file mode 100644
index 0000000..da47c2c
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt
@@ -0,0 +1,19 @@
+package kr.ac.anu.mumu.data.model
+
+data class CommunityPostDto(
+ val communityId: Long,
+ val userId: Long,
+ val petId: Long?,
+ val category: String,
+ val title: String,
+ val content: String,
+ val hashtags: List?,
+ val viewCount: Int,
+ val likeCount: Int,
+ val commentCount: Int,
+ val bookmarkCount: Int,
+ val isBest: Boolean,
+ val createdAt: String,
+ val updatedAt: String,
+ val thumbnailUrl: String?
+)
diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/HistoryDtos.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/HistoryDtos.kt
new file mode 100644
index 0000000..6b1557b
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/model/HistoryDtos.kt
@@ -0,0 +1,25 @@
+package kr.ac.anu.mumu.data.model
+
+data class BaseResponse(
+ val success: Boolean,
+ val message: String,
+ val data: T?
+)
+
+data class PaginatedData(
+ val content: List,
+ val page: Int,
+ val size: Int,
+ val totalElements: Long,
+ val totalPages: Int
+)
+
+data class AnalysisHistoryDto(
+ val analysisId: Long,
+ val type: String,
+ val status: String,
+ val resultLabel: String?,
+ val suspectedItems: List?,
+ val confidence: Double?,
+ val analyzedAt: String?
+)
diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/JoinDto.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/JoinDto.kt
index 314dbd8..865642c 100644
--- a/app/src/main/java/kr/ac/anu/mumu/data/model/JoinDto.kt
+++ b/app/src/main/java/kr/ac/anu/mumu/data/model/JoinDto.kt
@@ -1,29 +1,23 @@
package kr.ac.anu.mumu.data.model
-import com.google.gson.annotations.SerializedName
-
data class JoinRequest(
- val id: String,
+ val loginId: String,
val password: String,
val name: String,
val phone: String,
- val address: String,
- @SerializedName("detail_address") val detailAddress: String,
- @SerializedName("postal_code") val postalCode: String,
- @SerializedName("terms_agreed") val termsAgreed: Boolean,
- @SerializedName("privacy_agreed") val privacyAgreed: Boolean,
- @SerializedName("marketing_agreed") val marketingAgreed: Boolean
+ val termsAgreed: Boolean,
+ val privacyAgreed: Boolean,
+ val marketingAgreed: Boolean
)
data class JoinResponse(
val success: Boolean,
val message: String,
- val token: String?,
- val user: UserDto?
+ val data: UserDto?
) {
data class UserDto(
val userId: Int,
- val id: String,
+ val loginId: String,
val name: String,
val phone: String
)
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
new file mode 100644
index 0000000..52805ad
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/AnalysisRepositoryImpl.kt
@@ -0,0 +1,181 @@
+package kr.ac.anu.mumu.data.repository
+
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+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.model.AnalysisDetailDto
+import kr.ac.anu.mumu.domain.model.BehaviorAnalysisResult
+import kr.ac.anu.mumu.domain.repository.AnalysisRepository
+import okhttp3.MediaType
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import java.io.File
+import javax.inject.Inject
+import kotlin.math.roundToInt
+
+class AnalysisRepositoryImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val analysisService: AnalysisService,
+ private val petService: PetService
+) : AnalysisRepository {
+
+ override suspend fun analyzeBehavior(videoUri: String): Result {
+ return withContext(Dispatchers.IO) {
+ runCatching {
+ val petId = getPrimaryPetId()
+ val uri = Uri.parse(videoUri)
+ val videoPart = createVideoPart(uri)
+
+ try {
+ val triggerResponse = analysisService.triggerBehaviorAnalysis(petId, videoPart)
+ val triggerBody = triggerResponse.body()
+ if (!triggerResponse.isSuccessful || triggerBody?.success != true) {
+ error(triggerBody?.message ?: "분석 요청에 실패했습니다. (${triggerResponse.code()})")
+ }
+ val analysisId = triggerBody.data?.analysisId
+ ?: error("서버가 분석 ID를 반환하지 않았습니다.")
+ pollResult(analysisId, videoUri)
+ } finally {
+ videoPart.body.let { body ->
+ if (body is FileRequestBody) body.file.delete()
+ }
+ }
+ }
+ }
+ }
+
+ private suspend fun getPrimaryPetId(): Long {
+ val response = petService.getMyPets()
+ val body = response.body()
+ if (!response.isSuccessful || body?.success != true) {
+ error(body?.message ?: "반려동물 정보를 불러오지 못했습니다. (${response.code()})")
+ }
+ return body.data?.firstOrNull()?.petId
+ ?: error("분석할 반려동물을 먼저 등록해 주세요.")
+ }
+
+ private fun createVideoPart(uri: Uri): MultipartBody.Part {
+ val metadata = readMetadata(uri)
+ if (metadata.size != null && metadata.size > MAX_VIDEO_BYTES) {
+ error("영상은 100MB 이하만 업로드할 수 있습니다.")
+ }
+
+ val extension = metadata.name.substringAfterLast('.', "mp4").lowercase()
+ if (extension !in SUPPORTED_EXTENSIONS) {
+ error("mp4, avi, mov, mkv 영상만 분석할 수 있습니다.")
+ }
+
+ val temporaryFile = File.createTempFile("mumu-behavior-", ".$extension", context.cacheDir)
+ try {
+ val input = context.contentResolver.openInputStream(uri)
+ ?: error("선택한 영상을 열 수 없습니다.")
+ input.use { source ->
+ temporaryFile.outputStream().use(source::copyTo)
+ }
+ if (temporaryFile.length() > MAX_VIDEO_BYTES) {
+ error("영상은 100MB 이하만 업로드할 수 있습니다.")
+ }
+
+ val mediaType = (context.contentResolver.getType(uri) ?: "video/$extension").toMediaTypeOrNull()
+ val requestBody = FileRequestBody(mediaType, temporaryFile)
+ return MultipartBody.Part.createFormData("video", metadata.name, requestBody)
+ } catch (error: Throwable) {
+ temporaryFile.delete()
+ throw error
+ }
+ }
+
+ private fun readMetadata(uri: Uri): VideoMetadata {
+ val projection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
+ context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
+ val name = if (nameIndex >= 0) cursor.getString(nameIndex) else null
+ val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else null
+ return VideoMetadata(name ?: "behavior-video.mp4", size)
+ }
+ }
+ return VideoMetadata("behavior-video.mp4", null)
+ }
+
+ private suspend fun pollResult(analysisId: Long, videoUri: String): BehaviorAnalysisResult {
+ repeat(MAX_POLL_ATTEMPTS) {
+ val response = analysisService.getBehaviorAnalysis(analysisId)
+ val body = response.body()
+ if (!response.isSuccessful || body?.success != true) {
+ error(body?.message ?: "분석 결과를 불러오지 못했습니다. (${response.code()})")
+ }
+
+ val detail = body.data ?: error("분석 결과가 비어 있습니다.")
+ when (detail.status.uppercase()) {
+ "COMPLETED" -> return detail.toResult(videoUri)
+ "FAILED" -> error("영상 분석에 실패했습니다. 다른 영상으로 다시 시도해 주세요.")
+ }
+ delay(POLL_INTERVAL_MILLIS)
+ }
+ error("분석 시간이 오래 걸리고 있습니다. 잠시 후 내역에서 결과를 확인해 주세요.")
+ }
+
+ private fun AnalysisDetailDto.toResult(videoUri: String): BehaviorAnalysisResult {
+ val probability = confidence?.roundToInt()?.coerceIn(0, 100) ?: 0
+ val items = suspectedItems.orEmpty().filter(String::isNotBlank)
+ val label = resultLabel.orEmpty()
+ val isNormal = label.equals("NORMAL", ignoreCase = true) ||
+ label == "정상" ||
+ (items.isEmpty() && probability < NORMAL_THRESHOLD)
+ val behavior = if (isNormal) {
+ "특이 행동 없음"
+ } else {
+ items.joinToString(", ").ifBlank { label.ifBlank { "이상 행동 의심" } }
+ }
+ val reason = if (isNormal) {
+ "분석 영상에서 뚜렷한 이상 행동이 감지되지 않았습니다."
+ } else {
+ "AI 분석에서 $behavior 패턴이 감지되었습니다."
+ }
+ val recommendation = if (isNormal) {
+ "현재 상태를 계속 관찰하고 새로운 이상 행동이 보이면 다시 분석해 주세요."
+ } else {
+ "같은 행동이 반복되면 가까운 동물병원에서 정밀 검사를 받아보세요."
+ }
+
+ return BehaviorAnalysisResult(
+ videoUri = videoUri,
+ isNormal = isNormal,
+ probability = probability,
+ suspectedBehavior = behavior,
+ reason = reason,
+ recommendation = recommendation
+ )
+ }
+
+ private class FileRequestBody(
+ contentType: MediaType?,
+ val file: File
+ ) : RequestBody() {
+ private val delegate = file.asRequestBody(contentType)
+
+ override fun contentType(): MediaType? = delegate.contentType()
+ override fun contentLength(): Long = delegate.contentLength()
+ override fun writeTo(sink: okio.BufferedSink) = delegate.writeTo(sink)
+ }
+
+ private data class VideoMetadata(val name: String, val size: Long?)
+
+ private companion object {
+ const val MAX_VIDEO_BYTES = 100L * 1024L * 1024L
+ const val MAX_POLL_ATTEMPTS = 60
+ const val POLL_INTERVAL_MILLIS = 5_000L
+ const val NORMAL_THRESHOLD = 50
+ val SUPPORTED_EXTENSIONS = setOf("mp4", "avi", "mov", "mkv")
+ }
+}
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
new file mode 100644
index 0000000..d5a9b58
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt
@@ -0,0 +1,26 @@
+package kr.ac.anu.mumu.data.repository
+
+import kr.ac.anu.mumu.data.datasource.CommunityService
+import kr.ac.anu.mumu.domain.model.CommunityPost
+import kr.ac.anu.mumu.domain.repository.CommunityRepository
+import javax.inject.Inject
+
+class CommunityRepositoryImpl @Inject constructor(
+ private val communityService: CommunityService
+) : CommunityRepository {
+
+ override suspend fun getPosts(): Result> = runCatching {
+ val response = communityService.getPosts()
+ val body = response.body()
+ if (!response.isSuccessful || body?.success != true) {
+ error(body?.message ?: "게시글을 불러오지 못했습니다. (${response.code()})")
+ }
+ body.data?.content.orEmpty().map { post ->
+ CommunityPost(
+ id = post.communityId,
+ title = post.title,
+ thumbnailUrl = post.thumbnailUrl
+ )
+ }
+ }
+}
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
new file mode 100644
index 0000000..1714d56
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/HistoryRepositoryImpl.kt
@@ -0,0 +1,55 @@
+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.mapper.toDomain
+import kr.ac.anu.mumu.data.model.AnalysisHistoryDto
+import kr.ac.anu.mumu.domain.model.AnalysisHistory
+import kr.ac.anu.mumu.domain.repository.HistoryRepository
+import javax.inject.Inject
+
+class HistoryRepositoryImpl @Inject constructor(
+ private val historyService: HistoryService,
+ private val analysisService: AnalysisService,
+ private val petService: PetService
+) : HistoryRepository {
+
+ override suspend fun getBehaviorHistory(): Result> = runCatching {
+ val petId = getPrimaryPetId()
+ val response = historyService.getAnalysisHistory(petId)
+ val body = response.body()
+ if (!response.isSuccessful || body?.success != true) {
+ error(body?.message ?: "분석 내역을 불러오지 못했습니다. (${response.code()})")
+ }
+ body.data?.content.orEmpty().map(AnalysisHistoryDto::toDomain)
+ }
+
+ override suspend fun getBehaviorDetail(analysisId: Long): Result = runCatching {
+ val response = analysisService.getBehaviorAnalysis(analysisId)
+ val body = response.body()
+ if (!response.isSuccessful || body?.success != true) {
+ error(body?.message ?: "분석 상세 정보를 불러오지 못했습니다. (${response.code()})")
+ }
+ val detail = body.data ?: error("분석 상세 정보가 비어 있습니다.")
+ AnalysisHistoryDto(
+ analysisId = detail.analysisId,
+ type = detail.type.orEmpty(),
+ status = detail.status,
+ resultLabel = detail.resultLabel,
+ suspectedItems = detail.suspectedItems,
+ confidence = detail.confidence,
+ analyzedAt = detail.analyzedAt
+ ).toDomain()
+ }
+
+ private suspend fun getPrimaryPetId(): Long {
+ val response = petService.getMyPets()
+ val body = response.body()
+ if (!response.isSuccessful || body?.success != true) {
+ error(body?.message ?: "반려동물 정보를 불러오지 못했습니다. (${response.code()})")
+ }
+ return body.data?.firstOrNull()?.petId
+ ?: error("분석 내역을 확인할 반려동물을 먼저 등록해 주세요.")
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/JoinRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/JoinRepositoryImpl.kt
index 4313d7d..4111bba 100644
--- a/app/src/main/java/kr/ac/anu/mumu/data/repository/JoinRepositoryImpl.kt
+++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/JoinRepositoryImpl.kt
@@ -12,12 +12,12 @@ class JoinRepositoryImpl @Inject constructor(
override suspend fun register(request: JoinRequest): Result {
return try {
val response = joinService.registerUser(request)
- if (response.isSuccessful) {
+ if (response.isSuccessful && response.body()?.success == true) {
Result.success(Unit)
} else {
val errorString = response.errorBody()?.string()
val errorMessage = try {
- JSONObject(errorString ?: "").getString("error")
+ JSONObject(errorString ?: "").optString("message", "회원가입에 실패했습니다.")
} catch (e: Exception) {
"회원가입에 실패했습니다."
}
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 27749cc..8ebbc0c 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
@@ -1,28 +1,34 @@
package kr.ac.anu.mumu.data.repository
import kr.ac.anu.mumu.data.datasource.AuthService
+import kr.ac.anu.mumu.data.local.SessionManager
import kr.ac.anu.mumu.data.model.LoginRequest
import kr.ac.anu.mumu.domain.model.User
import kr.ac.anu.mumu.domain.repository.LoginRepository
import javax.inject.Inject
class LoginRepositoryImpl @Inject constructor(
- private val authService: AuthService
+ private val authService: AuthService,
+ private val sessionManager: SessionManager
) : LoginRepository {
override suspend fun login(id: String, pw: String): Result {
return try {
- val response = authService.login(LoginRequest(id, pw))
+ val response = authService.login(LoginRequest(loginId = id, password = pw))
if (response.isSuccessful && response.body() != null) {
val result = response.body()!!
if (result.success) {
+ sessionManager.saveTokens(
+ accessToken = result.data.accessToken,
+ refreshToken = result.data.refreshToken,
+ tokenType = result.data.tokenType
+ )
val domainUser = User(
- name = result.user.name,
- token = result.token,
- profileImage = result.user.profileImage,
- role = result.user.role
+ accessToken = result.data.accessToken,
+ refreshToken = result.data.refreshToken,
+ tokenType = result.data.tokenType
)
Result.success(domainUser)
} else {
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 8c979fb..17f8cb4 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
@@ -4,10 +4,17 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
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.HistoryService
import kr.ac.anu.mumu.data.datasource.JoinService
+import kr.ac.anu.mumu.data.datasource.PetService
+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.Singleton
@Module
@@ -15,9 +22,31 @@ import javax.inject.Singleton
object NetworkModule {
@Provides
@Singleton
- fun provideRetrofit(): Retrofit {
+ fun provideOkHttpClient(sessionManager: SessionManager): OkHttpClient {
+ return OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ val token = sessionManager.accessToken
+ val request = if (token.isNullOrBlank()) {
+ chain.request()
+ } else {
+ chain.request().newBuilder()
+ .header("Authorization", "Bearer $token")
+ .build()
+ }
+ chain.proceed(request)
+ }
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(2, TimeUnit.MINUTES)
+ .readTimeout(2, TimeUnit.MINUTES)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
- .baseUrl("https://keagan-slipperier-bewilderedly.ngrok-free.dev") // 서버 주소
+ .baseUrl("http://13.125.155.32:8080/")
+ .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@@ -33,4 +62,28 @@ object NetworkModule {
fun provideJoinService(retrofit: Retrofit): JoinService {
return retrofit.create(JoinService::class.java)
}
+
+ @Provides
+ @Singleton
+ fun provideAnalysisService(retrofit: Retrofit): AnalysisService {
+ return retrofit.create(AnalysisService::class.java)
+ }
+
+ @Provides
+ @Singleton
+ fun providePetService(retrofit: Retrofit): PetService {
+ return retrofit.create(PetService::class.java)
+ }
+
+ @Provides
+ @Singleton
+ fun provideHistoryService(retrofit: Retrofit): HistoryService {
+ return retrofit.create(HistoryService::class.java)
+ }
+
+ @Provides
+ @Singleton
+ fun provideCommunityService(retrofit: Retrofit): CommunityService {
+ return retrofit.create(CommunityService::class.java)
+ }
}
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 8591104..9b84967 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
@@ -4,8 +4,14 @@ import dagger.Binds
import dagger.Module
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.HistoryRepositoryImpl
import kr.ac.anu.mumu.data.repository.JoinRepositoryImpl
import kr.ac.anu.mumu.data.repository.LoginRepositoryImpl
+import kr.ac.anu.mumu.domain.repository.AnalysisRepository
+import kr.ac.anu.mumu.domain.repository.CommunityRepository
+import kr.ac.anu.mumu.domain.repository.HistoryRepository
import kr.ac.anu.mumu.domain.repository.JoinRepository
import kr.ac.anu.mumu.domain.repository.LoginRepository
import javax.inject.Singleton
@@ -13,6 +19,25 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindAnalysisRepository(
+ analysisRepositoryImpl: AnalysisRepositoryImpl
+ ): AnalysisRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindHistoryRepository(
+ historyRepositoryImpl: HistoryRepositoryImpl
+ ): HistoryRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindCommunityRepository(
+ communityRepositoryImpl: CommunityRepositoryImpl
+ ): CommunityRepository
+
@Binds
@Singleton
abstract fun bindLoginRepository(
diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/model/AnalysisHistory.kt b/app/src/main/java/kr/ac/anu/mumu/domain/model/AnalysisHistory.kt
new file mode 100644
index 0000000..511f4ae
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/domain/model/AnalysisHistory.kt
@@ -0,0 +1,10 @@
+package kr.ac.anu.mumu.domain.model
+
+data class AnalysisHistory(
+ val id: Long,
+ val isNormal: Boolean,
+ val behaviorText: String,
+ val probability: Int,
+ val date: String,
+ val resultLabel: String?
+)
diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/model/BehaviorAnalysisResult.kt b/app/src/main/java/kr/ac/anu/mumu/domain/model/BehaviorAnalysisResult.kt
new file mode 100644
index 0000000..62f5763
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/domain/model/BehaviorAnalysisResult.kt
@@ -0,0 +1,10 @@
+package kr.ac.anu.mumu.domain.model
+
+data class BehaviorAnalysisResult(
+ val videoUri: String,
+ val isNormal: Boolean,
+ val probability: Int,
+ val suspectedBehavior: String,
+ val reason: String,
+ val recommendation: String
+)
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
new file mode 100644
index 0000000..08cb008
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt
@@ -0,0 +1,7 @@
+package kr.ac.anu.mumu.domain.model
+
+data class CommunityPost(
+ val id: Long,
+ val title: String,
+ val thumbnailUrl: String?
+)
diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/model/User.kt b/app/src/main/java/kr/ac/anu/mumu/domain/model/User.kt
index 8056a13..c6469c9 100644
--- a/app/src/main/java/kr/ac/anu/mumu/domain/model/User.kt
+++ b/app/src/main/java/kr/ac/anu/mumu/domain/model/User.kt
@@ -1,8 +1,7 @@
package kr.ac.anu.mumu.domain.model
data class User(
- val name: String,
- val token: String,
- val profileImage: String?,
- val role: String
+ val accessToken: String,
+ val refreshToken: String,
+ val tokenType: String
)
diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/AnalysisRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/AnalysisRepository.kt
new file mode 100644
index 0000000..5bc0d32
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/AnalysisRepository.kt
@@ -0,0 +1,7 @@
+package kr.ac.anu.mumu.domain.repository
+
+import kr.ac.anu.mumu.domain.model.BehaviorAnalysisResult
+
+interface AnalysisRepository {
+ suspend fun analyzeBehavior(videoUri: String): Result
+}
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
new file mode 100644
index 0000000..b5c4745
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt
@@ -0,0 +1,7 @@
+package kr.ac.anu.mumu.domain.repository
+
+import kr.ac.anu.mumu.domain.model.CommunityPost
+
+interface CommunityRepository {
+ suspend fun getPosts(): Result>
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/HistoryRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/HistoryRepository.kt
new file mode 100644
index 0000000..b052384
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/HistoryRepository.kt
@@ -0,0 +1,8 @@
+package kr.ac.anu.mumu.domain.repository
+
+import kr.ac.anu.mumu.domain.model.AnalysisHistory
+
+interface HistoryRepository {
+ suspend fun getBehaviorHistory(): Result>
+ suspend fun getBehaviorDetail(analysisId: Long): Result
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisCaptureFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisCaptureFragment.kt
new file mode 100644
index 0000000..cee8a5b
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisCaptureFragment.kt
@@ -0,0 +1,113 @@
+package kr.ac.anu.mumu.presentation.analysis
+
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.FileProvider
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import androidx.navigation.fragment.findNavController
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.FragmentAnalysisCaptureBinding
+import java.io.File
+
+class AnalysisCaptureFragment : Fragment() {
+
+ private var _binding: FragmentAnalysisCaptureBinding? = null
+ private val binding get() = _binding!!
+ private var selectedVideoUri: Uri? = null
+ private var pendingCaptureUri: Uri? = null
+
+ private val captureVideo = registerForActivityResult(ActivityResultContracts.CaptureVideo()) { saved ->
+ if (saved) {
+ pendingCaptureUri?.let(::showVideo)
+ } else {
+ Toast.makeText(requireContext(), "영상 촬영이 취소되었습니다.", Toast.LENGTH_SHORT).show()
+ }
+ pendingCaptureUri = null
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAnalysisCaptureBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ savedInstanceState?.getString(STATE_SELECTED_VIDEO)?.let { showVideo(Uri.parse(it)) }
+
+ binding.btnRecord.setOnClickListener {
+ val uri = createVideoUri()
+ pendingCaptureUri = uri
+ captureVideo.launch(uri)
+ }
+ binding.btnRetake.setOnClickListener {
+ val uri = createVideoUri()
+ pendingCaptureUri = uri
+ captureVideo.launch(uri)
+ }
+ binding.btnAnalyzeVideo.setOnClickListener {
+ selectedVideoUri?.let { uri ->
+ findNavController().navigate(
+ R.id.action_analysisCaptureFragment_to_analysisLoadingFragment,
+ bundleOf(ARG_VIDEO_URI to uri.toString())
+ )
+ }
+ }
+ binding.videoPreview.setOnClickListener {
+ if (binding.videoPreview.isPlaying) binding.videoPreview.pause() else binding.videoPreview.start()
+ }
+ }
+
+ private fun createVideoUri(): Uri {
+ val directory = File(requireContext().cacheDir, "analysis-videos").apply { mkdirs() }
+ val file = File.createTempFile("mumu-analysis-", ".mp4", directory)
+ return FileProvider.getUriForFile(
+ requireContext(),
+ "${requireContext().packageName}.fileprovider",
+ file
+ )
+ }
+
+ private fun showVideo(uri: Uri) {
+ selectedVideoUri = uri
+ binding.tvCaptureGuide.visibility = View.GONE
+ binding.videoPreview.visibility = View.VISIBLE
+ binding.videoPreview.setVideoURI(uri)
+ binding.videoPreview.setOnPreparedListener { player ->
+ player.isLooping = true
+ binding.videoPreview.start()
+ }
+ binding.btnRecord.visibility = View.INVISIBLE
+ binding.btnRetake.visibility = View.VISIBLE
+ binding.btnAnalyzeVideo.visibility = View.VISIBLE
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ selectedVideoUri?.let { outState.putString(STATE_SELECTED_VIDEO, it.toString()) }
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun onPause() {
+ binding.videoPreview.pause()
+ super.onPause()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private companion object {
+ const val STATE_SELECTED_VIDEO = "selected_video"
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisContract.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisContract.kt
new file mode 100644
index 0000000..b3a1c97
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisContract.kt
@@ -0,0 +1,12 @@
+package kr.ac.anu.mumu.presentation.analysis
+
+import kr.ac.anu.mumu.domain.model.BehaviorAnalysisResult
+
+internal const val ARG_VIDEO_URI = "analysis_video_uri"
+
+sealed interface AnalysisUiState {
+ data object Idle : AnalysisUiState
+ data class Loading(val videoUri: String) : AnalysisUiState
+ data class Success(val result: BehaviorAnalysisResult) : AnalysisUiState
+ data class Error(val videoUri: String, val message: String) : AnalysisUiState
+}
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
new file mode 100644
index 0000000..7523974
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisLoadingFragment.kt
@@ -0,0 +1,91 @@
+package kr.ac.anu.mumu.presentation.analysis
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.FragmentAnalysisLoadingBinding
+
+@AndroidEntryPoint
+class AnalysisLoadingFragment : Fragment() {
+
+ private var _binding: FragmentAnalysisLoadingBinding? = null
+ private val binding get() = _binding!!
+ private val viewModel: AnalysisViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAnalysisLoadingBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val videoUri = requireArguments().getString(ARG_VIDEO_URI).orEmpty()
+ require(videoUri.isNotBlank()) { "분석할 영상 URI가 필요합니다." }
+ viewModel.analyze(videoUri)
+
+ binding.btnWriteDiary.setOnClickListener {
+ findNavController().navigate(R.id.diaryFragment)
+ }
+ binding.btnLater.setOnClickListener {
+ findNavController().popBackStack(R.id.homeFragment, false)
+ }
+ binding.btnRetry.setOnClickListener {
+ binding.btnRetry.visibility = View.GONE
+ binding.progressAnalysis.visibility = View.VISIBLE
+ binding.btnWriteDiary.visibility = View.VISIBLE
+ binding.btnLater.visibility = View.VISIBLE
+ binding.tvLoadingMessage.text = "mumu가 열심히 분석하고 있어요!..."
+ viewModel.analyze(videoUri)
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState.collect { state ->
+ when (state) {
+ is AnalysisUiState.Loading -> {
+ binding.progressAnalysis.visibility = View.VISIBLE
+ binding.btnRetry.visibility = View.GONE
+ binding.btnWriteDiary.visibility = View.VISIBLE
+ binding.btnLater.visibility = View.VISIBLE
+ }
+ is AnalysisUiState.Success -> {
+ if (findNavController().currentDestination?.id == R.id.analysisLoadingFragment) {
+ findNavController().navigate(
+ R.id.action_analysisLoadingFragment_to_analysisResultFragment
+ )
+ }
+ }
+ is AnalysisUiState.Error -> {
+ binding.progressAnalysis.visibility = View.GONE
+ binding.tvLoadingMessage.text = state.message
+ binding.btnRetry.visibility = View.VISIBLE
+ binding.btnWriteDiary.visibility = View.GONE
+ binding.btnLater.visibility = View.GONE
+ }
+ AnalysisUiState.Idle -> Unit
+ }
+ }
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisResultFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisResultFragment.kt
new file mode 100644
index 0000000..9989df3
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisResultFragment.kt
@@ -0,0 +1,76 @@
+package kr.ac.anu.mumu.presentation.analysis
+
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.findNavController
+import dagger.hilt.android.AndroidEntryPoint
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.FragmentAnalysisResultBinding
+import kr.ac.anu.mumu.domain.model.BehaviorAnalysisResult
+
+@AndroidEntryPoint
+class AnalysisResultFragment : Fragment() {
+
+ private var _binding: FragmentAnalysisResultBinding? = null
+ private val binding get() = _binding!!
+ private val viewModel: AnalysisViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAnalysisResultBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val state = viewModel.uiState.value
+ if (state !is AnalysisUiState.Success) {
+ findNavController().popBackStack(R.id.homeFragment, false)
+ return
+ }
+ bindResult(state.result)
+
+ binding.btnHome.setOnClickListener {
+ findNavController().popBackStack(R.id.homeFragment, false)
+ }
+ binding.videoResult.setOnClickListener {
+ if (binding.videoResult.isPlaying) binding.videoResult.pause() else binding.videoResult.start()
+ }
+ }
+
+ private fun bindResult(result: BehaviorAnalysisResult) {
+ val status = if (result.isNormal) "정상" else "비정상"
+ val statusColor = if (result.isNormal) R.color.cardgreen else R.color.cardred
+
+ binding.tvResultStatus.text = "분석결과: $status"
+ binding.tvResultStatus.setTextColor(ContextCompat.getColor(requireContext(), statusColor))
+ binding.tvProbability.text = "${result.probability}% 확률"
+ binding.tvSuspectedBehavior.text = result.suspectedBehavior
+ binding.tvReason.text = "이유 : ${result.reason}\n\n추천 방향성 : ${result.recommendation}"
+ binding.videoResult.setVideoURI(Uri.parse(result.videoUri))
+ binding.videoResult.setOnPreparedListener { player ->
+ player.isLooping = true
+ binding.videoResult.start()
+ }
+ }
+
+ override fun onPause() {
+ binding.videoResult.pause()
+ super.onPause()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisStartFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisStartFragment.kt
new file mode 100644
index 0000000..954b307
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisStartFragment.kt
@@ -0,0 +1,41 @@
+package kr.ac.anu.mumu.presentation.analysis
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.navigation.fragment.findNavController
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.FragmentAnalysisStartBinding
+
+class AnalysisStartFragment : Fragment() {
+
+ private var _binding: FragmentAnalysisStartBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAnalysisStartBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.btnCapture.setOnClickListener {
+ findNavController().navigate(R.id.action_analysisStartFragment_to_analysisCaptureFragment)
+ }
+ binding.btnUpload.setOnClickListener {
+ findNavController().navigate(R.id.action_analysisStartFragment_to_analysisUploadFragment)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisUploadFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisUploadFragment.kt
new file mode 100644
index 0000000..b32fb3f
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisUploadFragment.kt
@@ -0,0 +1,102 @@
+package kr.ac.anu.mumu.presentation.analysis
+
+import android.database.Cursor
+import android.net.Uri
+import android.os.Bundle
+import android.provider.OpenableColumns
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import androidx.navigation.fragment.findNavController
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.FragmentAnalysisUploadBinding
+
+class AnalysisUploadFragment : Fragment() {
+
+ private var _binding: FragmentAnalysisUploadBinding? = null
+ private val binding get() = _binding!!
+ private var selectedVideoUri: Uri? = null
+
+ private val selectVideo = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ uri?.let(::showVideo)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAnalysisUploadBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val restoredUri = savedInstanceState?.getString(STATE_SELECTED_VIDEO)?.let(Uri::parse)
+ if (restoredUri != null) showVideo(restoredUri)
+
+ binding.btnSelectVideo.setOnClickListener { selectVideo.launch("video/*") }
+ binding.btnAnalyzeVideo.setOnClickListener {
+ selectedVideoUri?.let { uri ->
+ findNavController().navigate(
+ R.id.action_analysisUploadFragment_to_analysisLoadingFragment,
+ bundleOf(ARG_VIDEO_URI to uri.toString())
+ )
+ }
+ }
+
+ if (savedInstanceState == null) selectVideo.launch("video/*")
+ }
+
+ private fun showVideo(uri: Uri) {
+ selectedVideoUri = uri
+ binding.videoPreview.visibility = View.VISIBLE
+ binding.videoPreview.setVideoURI(uri)
+ binding.videoPreview.setOnPreparedListener { player ->
+ player.isLooping = true
+ binding.videoPreview.start()
+ }
+ binding.tvSelectedFile.text = getDisplayName(uri) ?: "선택한 영상"
+ binding.btnSelectVideo.text = "다른 영상 선택"
+ binding.btnAnalyzeVideo.visibility = View.VISIBLE
+ }
+
+ private fun getDisplayName(uri: Uri): String? {
+ var cursor: Cursor? = null
+ return try {
+ cursor = requireContext().contentResolver.query(
+ uri,
+ arrayOf(OpenableColumns.DISPLAY_NAME),
+ null,
+ null,
+ null
+ )
+ if (cursor?.moveToFirst() == true) cursor.getString(0) else null
+ } finally {
+ cursor?.close()
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ selectedVideoUri?.let { outState.putString(STATE_SELECTED_VIDEO, it.toString()) }
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun onPause() {
+ binding.videoPreview.pause()
+ super.onPause()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private companion object {
+ const val STATE_SELECTED_VIDEO = "selected_video"
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisViewModel.kt
new file mode 100644
index 0000000..5ad103b
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/analysis/AnalysisViewModel.kt
@@ -0,0 +1,38 @@
+package kr.ac.anu.mumu.presentation.analysis
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+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.repository.AnalysisRepository
+import javax.inject.Inject
+
+@HiltViewModel
+class AnalysisViewModel @Inject constructor(
+ private val analysisRepository: AnalysisRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(AnalysisUiState.Idle)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun analyze(videoUri: String) {
+ val currentState = _uiState.value
+ if (currentState is AnalysisUiState.Loading && currentState.videoUri == videoUri) return
+ if (currentState is AnalysisUiState.Success && currentState.result.videoUri == videoUri) return
+
+ viewModelScope.launch {
+ _uiState.value = AnalysisUiState.Loading(videoUri)
+ analysisRepository.analyzeBehavior(videoUri)
+ .onSuccess { result -> _uiState.value = AnalysisUiState.Success(result) }
+ .onFailure { error ->
+ _uiState.value = AnalysisUiState.Error(
+ videoUri = videoUri,
+ message = error.message ?: "분석 중 오류가 발생했습니다."
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/AccountFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AccountFragment.kt
index 53d45db..4beb0cf 100644
--- a/app/src/main/java/kr/ac/anu/mumu/presentation/join/AccountFragment.kt
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AccountFragment.kt
@@ -87,7 +87,7 @@ class AccountFragment : Fragment() {
if (start != -1) {
context?.let { ctx ->
- val color = ContextCompat.getColor(ctx, R.color.mumumint_300)
+ val color = ContextCompat.getColor(ctx, R.color.mumu_300)
spannable.setSpan(
ForegroundColorSpan(color),
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/AddressFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AddressFragment.kt
index afb8663..56d329b 100644
--- a/app/src/main/java/kr/ac/anu/mumu/presentation/join/AddressFragment.kt
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AddressFragment.kt
@@ -65,7 +65,7 @@ class AddressFragment : Fragment() {
val end = start + targetWord.length
if (start != -1) {
- val color = ContextCompat.getColor(requireContext(), R.color.mumumint_300)
+ val color = ContextCompat.getColor(requireContext(), R.color.mumu_300)
spannable.setSpan(
ForegroundColorSpan(color),
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/FinishFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/FinishFragment.kt
index 7cd2df2..ffc5e84 100644
--- a/app/src/main/java/kr/ac/anu/mumu/presentation/join/FinishFragment.kt
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/FinishFragment.kt
@@ -41,7 +41,7 @@ class FinishFragment : Fragment() {
if (start != -1) {
// requireContext()를 사용하여 안전하게 색상 가져오기
- val color = ContextCompat.getColor(requireContext(), R.color.mumumint_300)
+ val color = ContextCompat.getColor(requireContext(), R.color.mumu_300)
spannable.setSpan(
ForegroundColorSpan(color),
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinViewModel.kt
index 55d2428..18a4357 100644
--- a/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinViewModel.kt
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinViewModel.kt
@@ -207,13 +207,10 @@ class JoinViewModel @Inject constructor(
viewModelScope.launch {
val request = JoinRequest(
- id = inputId.value ?: "",
+ loginId = inputId.value ?: "",
password = inputPw.value ?: "",
name = inputName.value ?: "",
phone = inputPhoneNum.value ?: "",
- address = inputAddress.value ?: "",
- detailAddress = inputDetailAddress.value ?: "",
- postalCode = inputPostalCode.value ?: "",
termsAgreed = isTermsAgreed.value ?: false,
privacyAgreed = isPrivacyAgreed.value ?: false,
marketingAgreed = isMarketingAgreed.value ?: false
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginActivity.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginActivity.kt
index 5d1dd44..e46dba0 100644
--- a/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginActivity.kt
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginActivity.kt
@@ -57,7 +57,7 @@ class LoginActivity : AppCompatActivity() {
val end = start + targetWord.length
if (start != -1) {
- val color = ContextCompat.getColor(this, R.color.mumumint_300)
+ val color = ContextCompat.getColor(this, R.color.mumu_300)
spannable.setSpan(
ForegroundColorSpan(color),
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommFragment.kt
new file mode 100644
index 0000000..e89d586
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommFragment.kt
@@ -0,0 +1,30 @@
+package kr.ac.anu.mumu.presentation.main
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import kr.ac.anu.mumu.databinding.FragmentCommBinding
+import kr.ac.anu.mumu.presentation.join.JoinViewModel
+
+class CommFragment : Fragment() {
+ private var _binding: FragmentCommBinding? = null
+ private val binding get() = _binding!!
+ private val viewModel: JoinViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentCommBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = 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
new file mode 100644
index 0000000..6d62481
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/DiaryFragment.kt
@@ -0,0 +1,30 @@
+package kr.ac.anu.mumu.presentation.main
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import kr.ac.anu.mumu.databinding.FragmentDiaryBinding
+import kr.ac.anu.mumu.presentation.join.JoinViewModel
+
+class DiaryFragment : Fragment() {
+ private var _binding: FragmentDiaryBinding? = null
+ private val binding get() = _binding!!
+ private val viewModel: JoinViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentDiaryBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailFragment.kt
new file mode 100644
index 0000000..e142409
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailFragment.kt
@@ -0,0 +1,77 @@
+package kr.ac.anu.mumu.presentation.main
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+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.databinding.FragmentHistoryDetailBinding
+import kr.ac.anu.mumu.domain.model.AnalysisHistory
+
+@AndroidEntryPoint
+class HistoryDetailFragment : Fragment() {
+
+ private var _binding: FragmentHistoryDetailBinding? = null
+ private val binding get() = _binding!!
+ private val viewModel: HistoryDetailViewModel by viewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentHistoryDetailBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.btnRetry.setOnClickListener { viewModel.loadDetail() }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState.collect(::render)
+ }
+ }
+ }
+
+ private fun render(state: HistoryDetailUiState) {
+ binding.progressDetail.visibility = if (state is HistoryDetailUiState.Loading) View.VISIBLE else View.GONE
+ binding.layoutContent.visibility = if (state is HistoryDetailUiState.Success) View.VISIBLE else View.GONE
+ binding.layoutError.visibility = if (state is HistoryDetailUiState.Error) View.VISIBLE else View.GONE
+
+ when (state) {
+ HistoryDetailUiState.Loading -> Unit
+ is HistoryDetailUiState.Success -> bindItem(state.item)
+ is HistoryDetailUiState.Error -> binding.tvError.text = state.message
+ }
+ }
+
+ private fun bindItem(item: AnalysisHistory) {
+ val status = if (item.isNormal) "정상" else "비정상"
+ val color = if (item.isNormal) R.color.cardgreen else R.color.cardred
+ binding.tvResultStatus.text = "분석결과: $status"
+ binding.tvResultStatus.setTextColor(ContextCompat.getColor(requireContext(), color))
+ binding.tvProbability.text = "${item.probability}% 확률"
+ binding.tvDate.text = item.date
+ binding.tvSuspectedBehavior.text = item.behaviorText
+ binding.tvReason.text = if (item.isNormal) {
+ "분석 영상에서 뚜렷한 이상 행동이 감지되지 않았습니다."
+ } else {
+ "AI 분석에서 ${item.behaviorText} 패턴이 감지되었습니다."
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailViewModel.kt
new file mode 100644
index 0000000..cd8e93e
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryDetailViewModel.kt
@@ -0,0 +1,47 @@
+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
+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.AnalysisHistory
+import kr.ac.anu.mumu.domain.repository.HistoryRepository
+import javax.inject.Inject
+
+sealed interface HistoryDetailUiState {
+ data object Loading : HistoryDetailUiState
+ data class Success(val item: AnalysisHistory) : HistoryDetailUiState
+ data class Error(val message: String) : HistoryDetailUiState
+}
+
+@HiltViewModel
+class HistoryDetailViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val historyRepository: HistoryRepository
+) : ViewModel() {
+
+ private val analysisId: Long = checkNotNull(savedStateHandle["analysisId"])
+ private val _uiState = MutableStateFlow(HistoryDetailUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadDetail()
+ }
+
+ fun loadDetail() {
+ viewModelScope.launch {
+ _uiState.value = HistoryDetailUiState.Loading
+ historyRepository.getBehaviorDetail(analysisId)
+ .onSuccess { item -> _uiState.value = HistoryDetailUiState.Success(item) }
+ .onFailure { error ->
+ _uiState.value = HistoryDetailUiState.Error(
+ error.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
new file mode 100644
index 0000000..5da1ea3
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryFragment.kt
@@ -0,0 +1,93 @@
+package kr.ac.anu.mumu.presentation.main
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+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 dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.FragmentHistoryBinding
+import kr.ac.anu.mumu.presentation.main.adapter.HistoryAdapter
+
+@AndroidEntryPoint
+class HistoryFragment : Fragment() {
+
+ private var _binding: FragmentHistoryBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var historyAdapter: HistoryAdapter
+ private val viewModel: HistoryViewModel by viewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentHistoryBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setupRecyclerView()
+ observeHistory()
+ binding.btnRetry.setOnClickListener { viewModel.loadHistory() }
+ }
+
+ private fun setupRecyclerView() {
+ historyAdapter = HistoryAdapter { item ->
+ findNavController().navigate(
+ R.id.analysisHistoryDetailFragment,
+ Bundle().apply { putLong("analysisId", item.id) }
+ )
+ }
+ binding.rvHistory.apply {
+ adapter = historyAdapter
+ layoutManager = LinearLayoutManager(requireContext())
+ }
+ }
+
+ private fun observeHistory() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState.collect { state -> render(state) }
+ }
+ }
+ }
+
+ private fun render(state: HistoryUiState) {
+ binding.progressHistory.visibility = if (state is HistoryUiState.Loading) View.VISIBLE else View.GONE
+ binding.layoutError.visibility = if (state is HistoryUiState.Error) View.VISIBLE else View.GONE
+
+ when (state) {
+ HistoryUiState.Loading -> {
+ binding.rvHistory.visibility = View.GONE
+ binding.tvEmpty.visibility = View.GONE
+ }
+ is HistoryUiState.Success -> {
+ historyAdapter.submitList(state.items)
+ binding.rvHistory.visibility = if (state.items.isEmpty()) View.GONE else View.VISIBLE
+ binding.tvEmpty.visibility = if (state.items.isEmpty()) View.VISIBLE else View.GONE
+ }
+ is HistoryUiState.Error -> {
+ binding.rvHistory.visibility = View.GONE
+ binding.tvEmpty.visibility = View.GONE
+ binding.tvError.text = state.message
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryViewModel.kt
new file mode 100644
index 0000000..5d0c0e5
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HistoryViewModel.kt
@@ -0,0 +1,44 @@
+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.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kr.ac.anu.mumu.domain.model.AnalysisHistory
+import kr.ac.anu.mumu.domain.repository.HistoryRepository
+import javax.inject.Inject
+
+sealed interface HistoryUiState {
+ data object Loading : HistoryUiState
+ data class Success(val items: List) : HistoryUiState
+ data class Error(val message: String) : HistoryUiState
+}
+
+@HiltViewModel
+class HistoryViewModel @Inject constructor(
+ private val historyRepository: HistoryRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(HistoryUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadHistory()
+ }
+
+ fun loadHistory() {
+ viewModelScope.launch {
+ _uiState.value = HistoryUiState.Loading
+ historyRepository.getBehaviorHistory()
+ .onSuccess { items -> _uiState.value = HistoryUiState.Success(items) }
+ .onFailure { error ->
+ _uiState.value = HistoryUiState.Error(
+ error.message ?: "분석 내역을 불러오지 못했습니다."
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HomeFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HomeFragment.kt
new file mode 100644
index 0000000..03301e2
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HomeFragment.kt
@@ -0,0 +1,88 @@
+package kr.ac.anu.mumu.presentation.main
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.navigation.fragment.findNavController
+import dagger.hilt.android.AndroidEntryPoint
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.FragmentHomeBinding
+
+@AndroidEntryPoint
+class HomeFragment : Fragment() {
+
+ private var _binding: FragmentHomeBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ _binding = FragmentHomeBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ if (savedInstanceState == null) {
+ setTabActive(isPeedActive = true)
+ replaceFragment(PostFragment())
+ }
+
+ binding.btnPeed.setOnClickListener {
+ setTabActive(isPeedActive = true)
+ replaceFragment(PostFragment())
+ }
+
+ binding.btnHistory.setOnClickListener {
+ setTabActive(isPeedActive = false)
+ replaceFragment(HistoryFragment())
+ }
+
+ binding.btnAnalyze.setOnClickListener {
+ findNavController().navigate(R.id.action_homeFragment_to_analysisStartFragment)
+ }
+ }
+
+ private fun replaceFragment(fragment: Fragment) {
+ childFragmentManager.beginTransaction()
+ .replace(binding.fragmentContainer.id, fragment)
+ .commit()
+ }
+
+ private fun setTabActive(isPeedActive: Boolean) {
+ val activeColor = ContextCompat.getColor(requireContext(), R.color.mumu_300)
+ val inactiveColor = ContextCompat.getColor(requireContext(), R.color.mumugray_150)
+
+ val peedIcon = binding.btnPeed.getChildAt(0) as ImageView
+ val peedText = binding.btnPeed.getChildAt(1) as TextView
+ val historyIcon = binding.btnHistory.getChildAt(0) as ImageView
+ val historyText = binding.btnHistory.getChildAt(1) as TextView
+
+ if (isPeedActive) {
+ peedIcon.setColorFilter(activeColor)
+ peedText.setTextColor(activeColor)
+
+ historyIcon.setColorFilter(inactiveColor)
+ historyText.setTextColor(inactiveColor)
+ } else {
+ peedIcon.setColorFilter(inactiveColor)
+ peedText.setTextColor(inactiveColor)
+
+ historyIcon.setColorFilter(activeColor)
+ historyText.setTextColor(activeColor)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
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
new file mode 100644
index 0000000..966a939
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HospitalFragment.kt
@@ -0,0 +1,30 @@
+package kr.ac.anu.mumu.presentation.main
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import dagger.hilt.android.AndroidEntryPoint
+import kr.ac.anu.mumu.databinding.FragmentHospitalBinding
+
+@AndroidEntryPoint
+class HospitalFragment : Fragment() {
+
+ private var _binding: FragmentHospitalBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ _binding = FragmentHospitalBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt
index 7901e6c..ccccbb1 100644
--- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt
@@ -1,21 +1,64 @@
package kr.ac.anu.mumu.presentation.main
import android.os.Bundle
+import android.view.View
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.ui.setupWithNavController
+import dagger.hilt.android.AndroidEntryPoint
import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.ActivityMainBinding
+@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityMainBinding
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- setContentView(R.layout.activity_main)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
+
+ val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment_main) as NavHostFragment
+ val navController = navHostFragment.navController
+
+ binding.bottomNav.setupWithNavController(navController)
+
+ val analysisDestinations = setOf(
+ R.id.analysisStartFragment,
+ R.id.analysisCaptureFragment,
+ R.id.analysisUploadFragment,
+ R.id.analysisLoadingFragment,
+ R.id.analysisResultFragment,
+ R.id.analysisHistoryDetailFragment
+ )
+
+ binding.btnBack.setOnClickListener {
+ navController.navigateUp()
+ }
+
+ navController.addOnDestinationChangedListener { _, destination, _ ->
+ val isAnalysisDestination = destination.id in analysisDestinations
+ binding.btnBack.visibility = if (isAnalysisDestination) View.VISIBLE else View.GONE
+ binding.ivLogo.visibility = if (isAnalysisDestination) View.GONE else View.VISIBLE
+ binding.tvMumu.visibility = if (isAnalysisDestination) View.GONE else View.VISIBLE
+ binding.tvAnalysisMumu.visibility = if (isAnalysisDestination) View.VISIBLE else View.GONE
+ binding.bottomNav.visibility = if (isAnalysisDestination) View.GONE else View.VISIBLE
+ }
+
+ ViewCompat.setOnApplyWindowInsetsListener(binding.bottomNav) { view, windowInsets ->
+ view.updatePadding(bottom = 0)
+ windowInsets
+ }
}
}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainViewModel.kt
new file mode 100644
index 0000000..d18439d
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainViewModel.kt
@@ -0,0 +1,8 @@
+package kr.ac.anu.mumu.presentation.main
+
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor() : ViewModel()
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
new file mode 100644
index 0000000..20ec90d
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MyFragment.kt
@@ -0,0 +1,30 @@
+package kr.ac.anu.mumu.presentation.main
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import dagger.hilt.android.AndroidEntryPoint
+import kr.ac.anu.mumu.databinding.FragmentMyBinding
+
+@AndroidEntryPoint
+class MyFragment : Fragment() {
+
+ private var _binding: FragmentMyBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ _binding = FragmentMyBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostFragment.kt
new file mode 100644
index 0000000..89733b7
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostFragment.kt
@@ -0,0 +1,79 @@
+package kr.ac.anu.mumu.presentation.main
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+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.GridLayoutManager
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import kr.ac.anu.mumu.databinding.FragmentPostBinding
+import kr.ac.anu.mumu.presentation.main.adapter.PostAdapter
+import kr.ac.anu.mumu.presentation.main.adapter.PostGridSpacingDecoration
+
+@AndroidEntryPoint
+class PostFragment : Fragment() {
+
+ private var _binding: FragmentPostBinding? = null
+ private val binding get() = _binding!!
+ private val viewModel: PostViewModel by viewModels()
+ private val postAdapter = PostAdapter()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentPostBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.rvPosts.apply {
+ adapter = postAdapter
+ layoutManager = GridLayoutManager(requireContext(), 3)
+ addItemDecoration(PostGridSpacingDecoration())
+ }
+ binding.btnRetry.setOnClickListener { viewModel.loadPosts() }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState.collect(::render)
+ }
+ }
+ }
+
+ 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
+
+ when (state) {
+ PostUiState.Loading -> {
+ binding.rvPosts.visibility = View.GONE
+ binding.tvEmpty.visibility = View.GONE
+ }
+ is PostUiState.Success -> {
+ postAdapter.submitList(state.posts)
+ binding.rvPosts.visibility = if (state.posts.isEmpty()) View.GONE else View.VISIBLE
+ binding.tvEmpty.visibility = if (state.posts.isEmpty()) View.VISIBLE else View.GONE
+ }
+ is PostUiState.Error -> {
+ binding.rvPosts.visibility = View.GONE
+ binding.tvEmpty.visibility = View.GONE
+ binding.tvError.text = state.message
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostViewModel.kt
new file mode 100644
index 0000000..379c6fe
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostViewModel.kt
@@ -0,0 +1,44 @@
+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.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 PostUiState {
+ data object Loading : PostUiState
+ data class Success(val posts: List) : PostUiState
+ data class Error(val message: String) : PostUiState
+}
+
+@HiltViewModel
+class PostViewModel @Inject constructor(
+ private val communityRepository: CommunityRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(PostUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadPosts()
+ }
+
+ fun loadPosts() {
+ viewModelScope.launch {
+ _uiState.value = PostUiState.Loading
+ communityRepository.getPosts()
+ .onSuccess { posts -> _uiState.value = PostUiState.Success(posts) }
+ .onFailure { error ->
+ _uiState.value = PostUiState.Error(
+ error.message ?: "게시글을 불러오지 못했습니다."
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/HistoryAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/HistoryAdapter.kt
new file mode 100644
index 0000000..4b35e4f
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/HistoryAdapter.kt
@@ -0,0 +1,61 @@
+package kr.ac.anu.mumu.presentation.main.adapter
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.ItemHistoryBinding
+import kr.ac.anu.mumu.domain.model.AnalysisHistory
+
+class HistoryAdapter(
+ private val onClick: (AnalysisHistory) -> Unit
+) : ListAdapter(HistoryDiffCallback) {
+
+ inner class HistoryViewHolder(private val binding: ItemHistoryBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(item: AnalysisHistory) {
+ val context = binding.root.context
+
+ binding.tvDate.text = item.date
+ binding.tvProbability.text = "확률 : ${item.probability}%"
+
+ if (item.isNormal) {
+ val greenColor = ContextCompat.getColor(context, R.color.cardgreen)
+ binding.cardResult.strokeColor = greenColor
+ binding.tvResultStatus.text = "검사결과 : 정상"
+ binding.tvDescription.text = "검사목록 : ${item.behaviorText}"
+ } else {
+ val redColor = ContextCompat.getColor(context, R.color.cardred)
+ binding.cardResult.strokeColor = redColor
+ binding.tvResultStatus.text = "검사결과 : 비정상"
+ binding.tvDescription.text = "의심행동 : ${item.behaviorText}"
+ }
+ binding.root.setOnClickListener { onClick(item) }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryViewHolder {
+ val binding = ItemHistoryBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ return HistoryViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: HistoryViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ private object HistoryDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: AnalysisHistory, newItem: AnalysisHistory): Boolean =
+ oldItem.id == newItem.id
+
+ override fun areContentsTheSame(oldItem: AnalysisHistory, newItem: AnalysisHistory): Boolean =
+ oldItem == newItem
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostAdapter.kt
new file mode 100644
index 0000000..e4245aa
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostAdapter.kt
@@ -0,0 +1,51 @@
+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.R
+import kr.ac.anu.mumu.databinding.ItemPostBinding
+import kr.ac.anu.mumu.domain.model.CommunityPost
+
+class PostAdapter : ListAdapter(PostDiffCallback) {
+
+ class PostViewHolder(
+ private val binding: ItemPostBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(post: CommunityPost) {
+ binding.ivPostImage.contentDescription = post.title
+ if (post.thumbnailUrl.isNullOrBlank()) {
+ binding.ivPostImage.setImageResource(R.drawable.ic_diary)
+ } else {
+ binding.ivPostImage.load(post.thumbnailUrl)
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder {
+ val binding = ItemPostBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ return PostViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: PostViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ private object PostDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: CommunityPost, newItem: CommunityPost): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(oldItem: CommunityPost, newItem: CommunityPost): Boolean {
+ return oldItem == newItem
+ }
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostGridSpacingDecoration.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostGridSpacingDecoration.kt
new file mode 100644
index 0000000..c00203c
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostGridSpacingDecoration.kt
@@ -0,0 +1,36 @@
+package kr.ac.anu.mumu.presentation.main.adapter
+
+import android.graphics.Rect
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+class PostGridSpacingDecoration : RecyclerView.ItemDecoration() {
+
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State
+ ) {
+ val position = parent.getChildAdapterPosition(view)
+ if (position == RecyclerView.NO_POSITION) return
+
+ when (position % SPAN_COUNT) {
+ 0 -> outRect.set(9.dp(view), 0, 2.dp(view), rowTop(position, view))
+ 1 -> outRect.set(6.dp(view), 0, 5.dp(view), rowTop(position, view))
+ 2 -> outRect.set(2.dp(view), 0, 9.dp(view), rowTop(position, view))
+ }
+ }
+
+ private fun rowTop(position: Int, view: View): Int {
+ return if (position < SPAN_COUNT) 0 else 8.dp(view)
+ }
+
+ private fun Int.dp(view: View): Int {
+ return (this * view.resources.displayMetrics.density).toInt()
+ }
+
+ private companion object {
+ const val SPAN_COUNT = 3
+ }
+}
diff --git a/app/src/main/res/color/selector_bottom_nav.xml b/app/src/main/res/color/selector_bottom_nav.xml
new file mode 100644
index 0000000..084c2c3
--- /dev/null
+++ b/app/src/main/res/color/selector_bottom_nav.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_analysis_circle_outline.xml b/app/src/main/res/drawable/bg_analysis_circle_outline.xml
new file mode 100644
index 0000000..70bf13d
--- /dev/null
+++ b/app/src/main/res/drawable/bg_analysis_circle_outline.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_analysis_circle_primary.xml b/app/src/main/res/drawable/bg_analysis_circle_primary.xml
new file mode 100644
index 0000000..b9d67db
--- /dev/null
+++ b/app/src/main/res/drawable/bg_analysis_circle_primary.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_analysis_header.xml b/app/src/main/res/drawable/bg_analysis_header.xml
new file mode 100644
index 0000000..683a1e1
--- /dev/null
+++ b/app/src/main/res/drawable/bg_analysis_header.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_analysis_item.xml b/app/src/main/res/drawable/bg_analysis_item.xml
new file mode 100644
index 0000000..6e712c5
--- /dev/null
+++ b/app/src/main/res/drawable/bg_analysis_item.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_analysis_panel.xml b/app/src/main/res/drawable/bg_analysis_panel.xml
new file mode 100644
index 0000000..4fde999
--- /dev/null
+++ b/app/src/main/res/drawable/bg_analysis_panel.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_analysis_primary.xml b/app/src/main/res/drawable/bg_analysis_primary.xml
new file mode 100644
index 0000000..3b2feb8
--- /dev/null
+++ b/app/src/main/res/drawable/bg_analysis_primary.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_analysis_secondary.xml b/app/src/main/res/drawable/bg_analysis_secondary.xml
new file mode 100644
index 0000000..1f15749
--- /dev/null
+++ b/app/src/main/res/drawable/bg_analysis_secondary.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_analysis_video.xml b/app/src/main/res/drawable/bg_analysis_video.xml
new file mode 100644
index 0000000..36dbc16
--- /dev/null
+++ b/app/src/main/res/drawable/bg_analysis_video.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_btn_analyze.xml b/app/src/main/res/drawable/bg_btn_analyze.xml
new file mode 100644
index 0000000..67b296e
--- /dev/null
+++ b/app/src/main/res/drawable/bg_btn_analyze.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_btn_write.xml b/app/src/main/res/drawable/bg_btn_write.xml
new file mode 100644
index 0000000..9492222
--- /dev/null
+++ b/app/src/main/res/drawable/bg_btn_write.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_pill_yellow.xml b/app/src/main/res/drawable/bg_pill_yellow.xml
new file mode 100644
index 0000000..89f259b
--- /dev/null
+++ b/app/src/main/res/drawable/bg_pill_yellow.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_post_box.xml b/app/src/main/res/drawable/bg_post_box.xml
new file mode 100644
index 0000000..15ff3d9
--- /dev/null
+++ b/app/src/main/res/drawable/bg_post_box.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/btn_disabled.xml b/app/src/main/res/drawable/btn_disabled.xml
index 375755b..3333edc 100644
--- a/app/src/main/res/drawable/btn_disabled.xml
+++ b/app/src/main/res/drawable/btn_disabled.xml
@@ -3,6 +3,6 @@
android:shape="rectangle">
-
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/btn_enabled.xml b/app/src/main/res/drawable/btn_enabled.xml
index 3fce4ed..cb29bec 100644
--- a/app/src/main/res/drawable/btn_enabled.xml
+++ b/app/src/main/res/drawable/btn_enabled.xml
@@ -3,6 +3,6 @@
android:shape="rectangle">
-
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/btn_join_background.xml b/app/src/main/res/drawable/btn_join_background.xml
index ab4e216..1fe1b95 100644
--- a/app/src/main/res/drawable/btn_join_background.xml
+++ b/app/src/main/res/drawable/btn_join_background.xml
@@ -5,6 +5,6 @@
+ android:color="@color/mumu_150" />
\ No newline at end of file
diff --git a/app/src/main/res/drawable/dog.jpg b/app/src/main/res/drawable/dog.jpg
new file mode 100644
index 0000000..455c843
Binary files /dev/null and b/app/src/main/res/drawable/dog.jpg differ
diff --git a/app/src/main/res/drawable/dog_foot.png b/app/src/main/res/drawable/dog_foot.png
new file mode 100644
index 0000000..465d287
Binary files /dev/null and b/app/src/main/res/drawable/dog_foot.png differ
diff --git a/app/src/main/res/drawable/ic_diary.xml b/app/src/main/res/drawable/ic_diary.xml
new file mode 100644
index 0000000..a90c972
--- /dev/null
+++ b/app/src/main/res/drawable/ic_diary.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_empty_heart.png b/app/src/main/res/drawable/ic_empty_heart.png
new file mode 100644
index 0000000..84a0c10
Binary files /dev/null and b/app/src/main/res/drawable/ic_empty_heart.png differ
diff --git a/app/src/main/res/drawable/ic_foot.png b/app/src/main/res/drawable/ic_foot.png
new file mode 100644
index 0000000..2a38116
Binary files /dev/null and b/app/src/main/res/drawable/ic_foot.png differ
diff --git a/app/src/main/res/drawable/ic_heart.png b/app/src/main/res/drawable/ic_heart.png
new file mode 100644
index 0000000..387a8c0
Binary files /dev/null and b/app/src/main/res/drawable/ic_heart.png differ
diff --git a/app/src/main/res/drawable/ic_history.png b/app/src/main/res/drawable/ic_history.png
new file mode 100644
index 0000000..ed9d701
Binary files /dev/null and b/app/src/main/res/drawable/ic_history.png differ
diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml
new file mode 100644
index 0000000..3ac74ad
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_hospital.xml b/app/src/main/res/drawable/ic_hospital.xml
new file mode 100644
index 0000000..d1bd8ec
--- /dev/null
+++ b/app/src/main/res/drawable/ic_hospital.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_man.png b/app/src/main/res/drawable/ic_man.png
new file mode 100644
index 0000000..a753cc8
Binary files /dev/null and b/app/src/main/res/drawable/ic_man.png differ
diff --git a/app/src/main/res/drawable/ic_message.xml b/app/src/main/res/drawable/ic_message.xml
new file mode 100644
index 0000000..26107c3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_message.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_user.xml b/app/src/main/res/drawable/ic_user.xml
new file mode 100644
index 0000000..492d428
--- /dev/null
+++ b/app/src/main/res/drawable/ic_user.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_woman.png b/app/src/main/res/drawable/ic_woman.png
new file mode 100644
index 0000000..bb66c25
Binary files /dev/null and b/app/src/main/res/drawable/ic_woman.png differ
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
index 779ee7a..56f4247 100644
--- a/app/src/main/res/layout/activity_login.xml
+++ b/app/src/main/res/layout/activity_login.xml
@@ -94,7 +94,7 @@
android:background="@drawable/btn_join_background"
android:fontFamily="@font/pretendard_semibold"
android:text="회원가입"
- android:textColor="@color/mumumint_250"
+ android:textColor="@color/mumu_250"
android:textSize="@dimen/body_4"
app:layout_constraintEnd_toEndOf="@id/btn_login"
app:layout_constraintStart_toStartOf="@id/btn_login"
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index a75041f..6c7d021 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -5,15 +5,99 @@
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:background="@color/white"
tools:context=".presentation.main.MainActivity">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ app:menu="@menu/main_menu"
+ app:paddingBottomSystemWindowInsets="false" />
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/fragment_analysis_capture.xml b/app/src/main/res/layout/fragment_analysis_capture.xml
new file mode 100644
index 0000000..83eaf13
--- /dev/null
+++ b/app/src/main/res/layout/fragment_analysis_capture.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_analysis_loading.xml b/app/src/main/res/layout/fragment_analysis_loading.xml
new file mode 100644
index 0000000..b357af5
--- /dev/null
+++ b/app/src/main/res/layout/fragment_analysis_loading.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_analysis_result.xml b/app/src/main/res/layout/fragment_analysis_result.xml
new file mode 100644
index 0000000..d934149
--- /dev/null
+++ b/app/src/main/res/layout/fragment_analysis_result.xml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_analysis_start.xml b/app/src/main/res/layout/fragment_analysis_start.xml
new file mode 100644
index 0000000..ad484b3
--- /dev/null
+++ b/app/src/main/res/layout/fragment_analysis_start.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_analysis_upload.xml b/app/src/main/res/layout/fragment_analysis_upload.xml
new file mode 100644
index 0000000..04c7f0c
--- /dev/null
+++ b/app/src/main/res/layout/fragment_analysis_upload.xml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_comm.xml b/app/src/main/res/layout/fragment_comm.xml
new file mode 100644
index 0000000..3728533
--- /dev/null
+++ b/app/src/main/res/layout/fragment_comm.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_diary.xml b/app/src/main/res/layout/fragment_diary.xml
new file mode 100644
index 0000000..9c463cc
--- /dev/null
+++ b/app/src/main/res/layout/fragment_diary.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_history.xml b/app/src/main/res/layout/fragment_history.xml
new file mode 100644
index 0000000..8d767e9
--- /dev/null
+++ b/app/src/main/res/layout/fragment_history.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_history_detail.xml b/app/src/main/res/layout/fragment_history_detail.xml
new file mode 100644
index 0000000..8a6d17b
--- /dev/null
+++ b/app/src/main/res/layout/fragment_history_detail.xml
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml
new file mode 100644
index 0000000..b1a3048
--- /dev/null
+++ b/app/src/main/res/layout/fragment_home.xml
@@ -0,0 +1,302 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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
new file mode 100644
index 0000000..77d9ef6
--- /dev/null
+++ b/app/src/main/res/layout/fragment_hospital.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ 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
new file mode 100644
index 0000000..77d9ef6
--- /dev/null
+++ b/app/src/main/res/layout/fragment_my.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_post.xml b/app/src/main/res/layout/fragment_post.xml
new file mode 100644
index 0000000..ed1aec2
--- /dev/null
+++ b/app/src/main/res/layout/fragment_post.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_terms.xml b/app/src/main/res/layout/fragment_terms.xml
index 497a385..8c2cb0e 100644
--- a/app/src/main/res/layout/fragment_terms.xml
+++ b/app/src/main/res/layout/fragment_terms.xml
@@ -44,7 +44,7 @@
android:text="전체 동의하기"
android:textColor="@color/mumublack"
android:textSize="@dimen/body_4"
- app:buttonTint="@color/mumumint_300" app:layout_constraintStart_toStartOf="parent"
+ app:buttonTint="@color/mumu_300" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@@ -99,7 +99,7 @@
android:text="개인정보 취급방침 (필수)"
android:textColor="@color/mumugray_400"
android:textSize="@dimen/body_4"
- app:buttonTint="@color/mumumint_300"
+ app:buttonTint="@color/mumu_300"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cb_terms" />
@@ -133,7 +133,7 @@
android:text="마케팅 수신 동의 (선택)"
android:textColor="@color/mumugray_400"
android:textSize="@dimen/body_4"
- app:buttonTint="@color/mumumint_300"
+ app:buttonTint="@color/mumu_300"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cb_privacy" />
diff --git a/app/src/main/res/layout/item_history.xml b/app/src/main/res/layout/item_history.xml
new file mode 100644
index 0000000..2d334cf
--- /dev/null
+++ b/app/src/main/res/layout/item_history.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_post.xml b/app/src/main/res/layout/item_post.xml
new file mode 100644
index 0000000..5ad9450
--- /dev/null
+++ b/app/src/main/res/layout/item_post.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml
new file mode 100644
index 0000000..99cd1fc
--- /dev/null
+++ b/app/src/main/res/menu/main_menu.xml
@@ -0,0 +1,26 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/nav_graph_main.xml b/app/src/main/res/navigation/nav_graph_main.xml
new file mode 100644
index 0000000..9b560f2
--- /dev/null
+++ b/app/src/main/res/navigation/nav_graph_main.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 88c8551..296f994 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -3,12 +3,12 @@
#262626
#FFFFFF
- #80CBC4
- #97D3CE
- #AFDCD8
- #C6E4E1
- #DEEDEB
- #E9F0F0
+ #FFC13B
+ #FDCB60
+ #FBD685
+ #F9E0AB
+ #F7EBD0
+ #F6EFE2
#F5F5F5
#E9E9EA
@@ -21,4 +21,7 @@
#F44355
#4B88FF
+
+ #DF7171
+ #88BA47
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 328ba4a..9bc8422 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -6,4 +6,18 @@
-
\ No newline at end of file
+
+
+
+
+
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..dae037f
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6ec3a43..effce90 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,8 +14,11 @@ lifecycleViewmodelKtx = "2.10.0"
material = "1.13.0"
activity = "1.12.1"
constraintlayout = "2.2.1"
+coil = "3.2.0"
navigationFragmentKtx = "2.9.7"
retrofit2 = "2.9.0"
+navigationFragmentKtxVersion = "2.9.8"
+navigationUiKtx = "2.9.8"
[libraries]
androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" }
@@ -36,7 +39,11 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
+coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
retrofit2 = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit2" }
+navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtxVersion" }
+navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }