From fc3ad3434fbd3b4e445eaa8c6995ab63e05b9be5 Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Wed, 3 Jun 2026 08:38:47 +0000 Subject: [PATCH] feat(mobile): wire Activity History to GET /my/activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Activity History screen (bottom-nav "History") was permanently empty — it hardcoded `val sections = emptyList()` and never called the API. Wire it to the EXISTING user-scoped endpoint GET /api/v1/my/activity (the current user's own audit events; no admin authority required). The admin GET /api/v1/audit-logs path is left untouched (it 403s for normal users). - AuditLogApi/AuditLogApiImpl: add getMyActivity(page, size) → GET my/activity, reusing the existing AuditLogPageDto response type. getAuditLogs (admin) unchanged. - AuditLogRepository(+Impl): add getMyActivity wrapped in runCatching; extract the shared AuditLogDto→AuditLog mapping into a toDomain() helper. - New ActivityHistoryViewModel + ActivityHistoryUiState: loads page 0 (size 50) on init, exposes isLoading / errorMessage / events. Registered in DI via factoryOf(::ActivityHistoryViewModel). - ActivityHistoryScreen: replace the hardcoded empty list with the VM state. Map each AuditLog.action → chip category: contains "VERIF" → Verifications, contains "ENROLL" → Enrollments, everything else shows only under All. Group by date (YYYY-MM-DD from the ISO timestamp) for the section headers. Adds a loading spinner and a distinct error state with Retry (errors are no longer silently rendered as an empty list). - AppNavigation: inject the VM via koinInject().disposeOnLeave() and pass state, matching the existing per-screen ViewModel wiring. - i18n: new ACTHIST_LOAD_ERROR key with EN + TR; reuse existing RETRY/empty/chip keys. - Update FakeAuditLogRepository test mock with getMyActivity. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/ui/navigation/AppNavigation.kt | 9 +- .../ui/screen/ActivityHistoryScreen.kt | 157 ++++++++++++++---- .../shared/data/remote/api/AuditLogApi.kt | 12 +- .../shared/data/remote/api/AuditLogApiImpl.kt | 10 ++ .../data/repository/AuditLogRepositoryImpl.kt | 40 +++-- .../com/fivucsas/shared/di/ViewModelModule.kt | 2 + .../domain/repository/AuditLogRepository.kt | 9 + .../fivucsas/shared/i18n/StringResources.kt | 3 + .../state/ActivityHistoryUiState.kt | 15 ++ .../viewmodel/ActivityHistoryViewModel.kt | 53 ++++++ .../shared/test/mocks/RepositoryMocks.kt | 6 + 11 files changed, 267 insertions(+), 49 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/fivucsas/shared/presentation/state/ActivityHistoryUiState.kt create mode 100644 shared/src/commonMain/kotlin/com/fivucsas/shared/presentation/viewmodel/ActivityHistoryViewModel.kt diff --git a/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/navigation/AppNavigation.kt b/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/navigation/AppNavigation.kt index f85c47c4..153cc3d8 100644 --- a/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/navigation/AppNavigation.kt @@ -35,6 +35,7 @@ import com.fivucsas.shared.data.local.TokenManager import com.fivucsas.shared.domain.repository.BiometricRepository import com.fivucsas.shared.domain.repository.DataExportRepository import com.fivucsas.shared.domain.model.UserRole +import com.fivucsas.shared.presentation.viewmodel.ActivityHistoryViewModel import com.fivucsas.shared.presentation.viewmodel.auth.ChangePasswordViewModel import com.fivucsas.shared.presentation.viewmodel.UserProfileViewModel import androidx.compose.runtime.collectAsState @@ -237,6 +238,8 @@ fun AppNavigation() { } return@composable } + val activityVm = koinInject().disposeOnLeave() + val activityState by activityVm.uiState.collectAsState() ActivityHistoryScreen( currentRoute = Screen.ActivityHistory.route, onNavigateBottom = { route -> @@ -245,7 +248,11 @@ fun AppNavigation() { restoreState = true } }, - navItems = navItemsForRole + navItems = navItemsForRole, + events = activityState.events, + isLoading = activityState.isLoading, + errorMessage = activityState.errorMessage, + onRetry = { activityVm.load() } ) } diff --git a/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/ActivityHistoryScreen.kt b/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/ActivityHistoryScreen.kt index b3909dda..6266b67f 100644 --- a/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/ActivityHistoryScreen.kt +++ b/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/ActivityHistoryScreen.kt @@ -10,7 +10,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.HowToReg import androidx.compose.material.icons.filled.Security +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -24,8 +27,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.fivucsas.shared.config.UIDimens +import com.fivucsas.shared.domain.model.AuditLog import com.fivucsas.shared.i18n.StringKey import com.fivucsas.shared.i18n.s import com.fivucsas.shared.ui.components.atoms.SectionHeader @@ -37,20 +42,62 @@ import com.fivucsas.shared.ui.components.molecules.FilterChipRow import com.fivucsas.shared.ui.components.organisms.BottomNavBar import com.fivucsas.shared.ui.theme.AppColors +private data class HistoryEntry( + val category: String, + val item: ActivityItemData +) + +/** Chip category an audit action falls into. "all" entries only show under the All chip. */ +private fun categoryFor(action: String): String { + val upper = action.uppercase() + return when { + upper.contains("VERIF") -> "verification" + upper.contains("ENROLL") -> "enrollment" + else -> "all" + } +} + +/** Date portion (YYYY-MM-DD) of an ISO-8601 timestamp; used as the section header. */ +private fun dateOf(timestamp: String): String = + timestamp.substringBefore('T').ifBlank { timestamp } + +private fun AuditLog.toEntry(): HistoryEntry { + val category = categoryFor(action) + val icon = when (category) { + "verification" -> Icons.Default.CameraAlt + "enrollment" -> Icons.Default.HowToReg + else -> Icons.Default.Security + } + val badge = when (status.uppercase()) { + "SUCCESS" -> StatusBadgeType.Success + "FAILURE" -> StatusBadgeType.Failure + else -> StatusBadgeType.Info + } + return HistoryEntry( + category = category, + item = ActivityItemData( + title = action, + description = details, + timestamp = timestamp, + status = badge, + icon = icon + ) + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ActivityHistoryScreen( currentRoute: String, onNavigateBottom: (String) -> Unit, navItems: List = com.fivucsas.mobile.android.ui.navigation.BottomNavDestinations.items, + events: List = emptyList(), + isLoading: Boolean = false, + errorMessage: String? = null, + onRetry: () -> Unit = {}, showExportButton: Boolean = false, onExport: () -> Unit = {} ) { - data class HistoryEntry( - val category: String, - val item: ActivityItemData - ) - val filters = listOf( FilterChipItem(s(StringKey.VERIFICATION_FILTER_ALL), "all"), FilterChipItem(s(StringKey.VERIFICATIONS), "verification"), @@ -58,8 +105,12 @@ fun ActivityHistoryScreen( ) var selectedFilter by remember { mutableStateOf(filters.first().value) } - // Activity history will be loaded from API when endpoint is available - val sections = emptyList>>() + // Group the current user's activity events by date (events arrive newest-first + // from GET /api/v1/my/activity, so the section order is preserved). + val sections: List>> = events + .map { it.toEntry() } + .groupBy { dateOf(it.item.timestamp) } + .map { (date, entries) -> date to entries } val filteredSections = sections.mapNotNull { (title, entries) -> val filteredEntries = if (selectedFilter == "all") { @@ -115,33 +166,75 @@ fun ActivityHistoryScreen( onSelected = { selectedFilter = it.value } ) - if (filteredSections.isEmpty()) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally - ) { - Text( - text = s(StringKey.ACTHIST_EMPTY), - style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, - color = AppColors.OnSurfaceVariant - ) + when { + isLoading -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + } } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(UIDimens.SpacingSmall) - ) { - filteredSections.forEach { (title, itemsList) -> - item { - SectionHeader( - title = title, - modifier = Modifier.padding(vertical = 4.dp) - ) + + errorMessage != null -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally + ) { + Text( + text = s(StringKey.ACTHIST_LOAD_ERROR), + style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, + color = AppColors.OnSurface, + textAlign = TextAlign.Center + ) + Text( + text = errorMessage, + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + color = AppColors.OnSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + Button( + onClick = onRetry, + modifier = Modifier.padding(top = UIDimens.SpacingMedium) + ) { + Text(s(StringKey.RETRY)) } - items(itemsList) { entry -> - ActivityItem(data = entry.item) + } + } + + filteredSections.isEmpty() -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally + ) { + Text( + text = s(StringKey.ACTHIST_EMPTY), + style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, + color = AppColors.OnSurfaceVariant + ) + } + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(UIDimens.SpacingSmall) + ) { + filteredSections.forEach { (title, itemsList) -> + item { + SectionHeader( + title = title, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + items(itemsList) { entry -> + ActivityItem(data = entry.item) + } } } } diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/AuditLogApi.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/AuditLogApi.kt index b00ee844..44325ac4 100644 --- a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/AuditLogApi.kt +++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/AuditLogApi.kt @@ -6,7 +6,8 @@ import com.fivucsas.shared.data.remote.dto.AuditLogPageDto * Audit Log API interface * * Endpoints: - * - GET /api/v1/audit-logs?action=X&userId=Y&page=0&size=20 + * - GET /api/v1/audit-logs?action=X&userId=Y&page=0&size=20 (admin — TENANT_ADMIN/ROOT only) + * - GET /api/v1/my/activity?page=0&size=20 (user-scoped — current user's own events) */ interface AuditLogApi { suspend fun getAuditLogs( @@ -15,4 +16,13 @@ interface AuditLogApi { page: Int = 0, size: Int = 20 ): AuditLogPageDto + + /** + * Current user's OWN activity events (no admin authority required). + * Backed by GET /api/v1/my/activity. + */ + suspend fun getMyActivity( + page: Int = 0, + size: Int = 20 + ): AuditLogPageDto } diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/AuditLogApiImpl.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/AuditLogApiImpl.kt index 372bba0c..c2c84452 100644 --- a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/AuditLogApiImpl.kt +++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/AuditLogApiImpl.kt @@ -23,4 +23,14 @@ class AuditLogApiImpl( userId?.let { parameter("userId", it) } }.body() } + + override suspend fun getMyActivity( + page: Int, + size: Int + ): AuditLogPageDto { + return client.get("my/activity") { + parameter("page", page) + parameter("size", size) + }.body() + } } diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/repository/AuditLogRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/repository/AuditLogRepositoryImpl.kt index 1bd94892..3fac0161 100644 --- a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/repository/AuditLogRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/repository/AuditLogRepositoryImpl.kt @@ -1,6 +1,7 @@ package com.fivucsas.shared.data.repository import com.fivucsas.shared.data.remote.api.AuditLogApi +import com.fivucsas.shared.data.remote.dto.AuditLogDto import com.fivucsas.shared.domain.model.AuditLog import com.fivucsas.shared.domain.repository.AuditLogRepository @@ -14,24 +15,33 @@ class AuditLogRepositoryImpl( page: Int, size: Int ): Result> = runCatching { - val response = auditLogApi.getAuditLogs( + auditLogApi.getAuditLogs( action = action, userId = userId, page = page, size = size - ) - response.content.map { dto -> - AuditLog( - id = dto.id, - userId = dto.userId ?: "", - action = dto.action, - status = if (dto.success) "SUCCESS" else "FAILURE", - ipAddress = dto.ipAddress ?: "", - details = dto.errorMessage - ?: dto.entityType?.let { "$it/${dto.entityId ?: ""}" } - ?: "", - timestamp = dto.timestamp ?: "" - ) - } + ).content.map { it.toDomain() } } + + override suspend fun getMyActivity( + page: Int, + size: Int + ): Result> = runCatching { + auditLogApi.getMyActivity( + page = page, + size = size + ).content.map { it.toDomain() } + } + + private fun AuditLogDto.toDomain(): AuditLog = AuditLog( + id = id, + userId = userId ?: "", + action = action, + status = if (success) "SUCCESS" else "FAILURE", + ipAddress = ipAddress ?: "", + details = errorMessage + ?: entityType?.let { "$it/${entityId ?: ""}" } + ?: "", + timestamp = timestamp ?: "" + ) } diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/di/ViewModelModule.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/di/ViewModelModule.kt index a3c0a900..51ed2357 100644 --- a/shared/src/commonMain/kotlin/com/fivucsas/shared/di/ViewModelModule.kt +++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/di/ViewModelModule.kt @@ -1,6 +1,7 @@ package com.fivucsas.shared.di import com.fivucsas.shared.presentation.viewmodel.AccountLinkingViewModel +import com.fivucsas.shared.presentation.viewmodel.ActivityHistoryViewModel import com.fivucsas.shared.presentation.viewmodel.AdminViewModel import com.fivucsas.shared.presentation.viewmodel.ApproveLoginViewModel import com.fivucsas.shared.presentation.viewmodel.AuthFlowViewModel @@ -67,6 +68,7 @@ val viewModelModule = module { factoryOf(::MfaFlowViewModel) factoryOf(::DeveloperPortalViewModel) factoryOf(::AuditLogDashboardViewModel) + factoryOf(::ActivityHistoryViewModel) factoryOf(::DataExportViewModel) factoryOf(::AccountLinkingViewModel) diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/domain/repository/AuditLogRepository.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/domain/repository/AuditLogRepository.kt index 6d362007..e05bbdaa 100644 --- a/shared/src/commonMain/kotlin/com/fivucsas/shared/domain/repository/AuditLogRepository.kt +++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/domain/repository/AuditLogRepository.kt @@ -9,4 +9,13 @@ interface AuditLogRepository { page: Int = 0, size: Int = 20 ): Result> + + /** + * Current user's OWN activity events (backed by GET /api/v1/my/activity). + * Does not require admin authority, unlike [getAuditLogs]. + */ + suspend fun getMyActivity( + page: Int = 0, + size: Int = 20 + ): Result> } diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/i18n/StringResources.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/i18n/StringResources.kt index 5f059b9b..cf92fd7a 100644 --- a/shared/src/commonMain/kotlin/com/fivucsas/shared/i18n/StringResources.kt +++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/i18n/StringResources.kt @@ -1008,6 +1008,7 @@ enum class StringKey { // ActivityHistoryScreen (ACTHIST_) ACTHIST_EXPORT_DESC, ACTHIST_EMPTY, + ACTHIST_LOAD_ERROR, // === end Info screens i18n === // Approve Login (number-matching approver — Android) @@ -2306,6 +2307,7 @@ private val enStrings = mapOf( StringKey.NOTIF_EMPTY to "No notifications yet", StringKey.ACTHIST_EXPORT_DESC to "Export History", StringKey.ACTHIST_EMPTY to "No activity history yet", + StringKey.ACTHIST_LOAD_ERROR to "Couldn't load your activity history.", // === end Info screens i18n === // Approve Login (number-matching approver — Android) @@ -3604,6 +3606,7 @@ private val trStrings = mapOf( StringKey.NOTIF_EMPTY to "Henüz bildirim yok", StringKey.ACTHIST_EXPORT_DESC to "Geçmişi Dışa Aktar", StringKey.ACTHIST_EMPTY to "Henüz etkinlik geçmişi yok", + StringKey.ACTHIST_LOAD_ERROR to "Etkinlik geçmişiniz yüklenemedi.", // === end Info screens i18n === // Approve Login (number-matching approver — Android) diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/presentation/state/ActivityHistoryUiState.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/presentation/state/ActivityHistoryUiState.kt new file mode 100644 index 00000000..45d2967c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/presentation/state/ActivityHistoryUiState.kt @@ -0,0 +1,15 @@ +package com.fivucsas.shared.presentation.state + +import com.fivucsas.shared.domain.model.AuditLog + +/** + * UI state for the user-facing Activity History screen. + * + * Backed by the user-scoped `GET /api/v1/my/activity` endpoint (the current user's + * OWN events) — NOT the admin audit-log dashboard. + */ +data class ActivityHistoryUiState( + val events: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null +) diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/presentation/viewmodel/ActivityHistoryViewModel.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/presentation/viewmodel/ActivityHistoryViewModel.kt new file mode 100644 index 00000000..a4b1b931 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/presentation/viewmodel/ActivityHistoryViewModel.kt @@ -0,0 +1,53 @@ +package com.fivucsas.shared.presentation.viewmodel + +import com.fivucsas.shared.domain.repository.AuditLogRepository +import com.fivucsas.shared.presentation.state.ActivityHistoryUiState +import com.fivucsas.shared.presentation.util.ErrorMapper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private const val PAGE_SIZE = 50 + +/** + * ViewModel for the user-facing Activity History screen (bottom-nav "History"). + * + * Loads the current user's OWN activity events from `GET /api/v1/my/activity` + * (user-scoped — does NOT require admin authority, unlike the audit-log dashboard). + * Loads the first page on construction. + */ +class ActivityHistoryViewModel( + private val auditLogRepository: AuditLogRepository +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(ActivityHistoryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + + auditLogRepository.getMyActivity(page = 0, size = PAGE_SIZE).fold( + onSuccess = { events -> + _uiState.update { + it.copy(isLoading = false, events = events, errorMessage = null) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = ErrorMapper.mapToUserMessage(error, "load activity history") + ) + } + } + ) + } + } +} diff --git a/shared/src/commonTest/kotlin/com/fivucsas/shared/test/mocks/RepositoryMocks.kt b/shared/src/commonTest/kotlin/com/fivucsas/shared/test/mocks/RepositoryMocks.kt index 9253c240..50cab3e2 100644 --- a/shared/src/commonTest/kotlin/com/fivucsas/shared/test/mocks/RepositoryMocks.kt +++ b/shared/src/commonTest/kotlin/com/fivucsas/shared/test/mocks/RepositoryMocks.kt @@ -257,6 +257,12 @@ class FakeAuditLogRepository : AuditLogRepository { Result.success(paged) } else Result.failure(RuntimeException(errorMessage)) } + + override suspend fun getMyActivity(page: Int, size: Int): Result> { + return if (shouldSucceed) { + Result.success(mockLogs.drop(page * size).take(size)) + } else Result.failure(RuntimeException(errorMessage)) + } } // ── OAuth2ClientRepository ──────────────────────────────────────────────────