diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/archive/ArchiveEventsView.kt b/composeApp/src/androidMain/kotlin/friends/mobile/archive/ArchiveEventsView.kt new file mode 100644 index 0000000..faa5cec --- /dev/null +++ b/composeApp/src/androidMain/kotlin/friends/mobile/archive/ArchiveEventsView.kt @@ -0,0 +1,259 @@ +package friends.mobile.archive + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import friends.mobile.feature.archive.presentation.ArchiveEventsViewModel +import friends.mobile.feature.archive.presentation.ArchiveViewAction +import friends.mobile.feature.archive.presentation.ArchiveViewState +import friends.mobile.feature.main.domain.model.MainEvent + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArchiveEventsView( + onEventDetailClick: (eventId: String) -> Unit = {}, +) { + val viewModel: ArchiveEventsViewModel = viewModel() + val state by viewModel.viewStates.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + logScreenOpen("launch_archive") + } + + val isLoading = state is ArchiveViewState.Loading + val errorMessage = (state as? ArchiveViewState.Error)?.message + val isRefreshing = (state as? ArchiveViewState.Content)?.isRefreshing ?: false + val archivedEvents = (state as? ArchiveViewState.Content)?.archivedEvents ?: emptyList() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Archived Events") }, + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + when { + isLoading && archivedEvents.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + errorMessage != null && archivedEvents.isEmpty() -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + Button( + onClick = { + viewModel.obtainEvent(ArchiveViewAction.OnRefresh) + }, + modifier = Modifier.padding(top = 16.dp), + ) { + Text("Retry") + } + } + } + + archivedEvents.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No archived events yet", + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + ) + } + } + + else -> { + val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) + SwipeRefresh( + state = swipeRefreshState, + onRefresh = { + viewModel.obtainEvent(ArchiveViewAction.OnRefresh) + }, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + archivedEvents, + key = { it.id } + ) { event -> + EventCard( + event = event, + onClick = { + onEventDetailClick(event.id) + }, + isPending = false, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun EventCard( + event: MainEvent, + onClick: () -> Unit, + isPending: Boolean = false, +) { + val backgroundColor = if (isPending) { + Color(red = 1.0f, green = 0.97f, blue = 0.92f).copy(alpha = 0.6f) + } else { + MaterialTheme.colorScheme.surface + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(8.dp), + ) { + Column( + modifier = Modifier + .background(backgroundColor) + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Title with pending badge + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = event.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + if (isPending) { + Box( + modifier = Modifier + .background( + Color(red = 1.0f, green = 0.647f, blue = 0.0f), + shape = MaterialTheme.shapes.small, + ) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + text = "Pending", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + ) + } + } + } + + // Date + Text( + text = event.date, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + + // Time (if present) + event.time?.let { time -> + Text( + text = time, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + + // Participants with person icon + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.Gray, + ) + Text( + text = "${event.participantCount} participants", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + } +} + +private fun logScreenOpen(screenName: String) { + // TODO: Wire Firebase Analytics here + // FirebaseAnalytics.getInstance().logEvent( + // "screen_view", + // Bundle().apply { + // putString(FirebaseAnalytics.Param.SCREEN_NAME, screenName) + // } + // ) +} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/main/BottomNavItem.kt b/composeApp/src/androidMain/kotlin/friends/mobile/main/BottomNavItem.kt index 4a906ba..d7d41ab 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/main/BottomNavItem.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/main/BottomNavItem.kt @@ -1,6 +1,7 @@ package friends.mobile.main import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search @@ -10,4 +11,5 @@ internal sealed class BottomNavItem(val route: String, val title: String, val ic object Home : BottomNavItem("home", "Home", Icons.Default.Home) object Friends : BottomNavItem("friends", "Friends", Icons.Default.Search) object Profile : BottomNavItem("profile", "Profile", Icons.Default.Person) + object Archive : BottomNavItem("archive", "Archive", Icons.Default.CheckCircle) } diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/main/MainScreen.kt b/composeApp/src/androidMain/kotlin/friends/mobile/main/MainScreen.kt index 3fae670..95ae83b 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/main/MainScreen.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/main/MainScreen.kt @@ -21,6 +21,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import friends.mobile.archive.ArchiveEventsView import friends.mobile.events.CreateEventView import friends.mobile.events.EventDetailView import friends.mobile.events.PendingEventListView @@ -42,6 +43,7 @@ sealed interface Screen { val avatarUrl: String? ) : Screen @Serializable data object PendingEvents : Screen + @Serializable data object Archive : Screen } @Composable @@ -52,7 +54,8 @@ fun MainScreen( val bottomNavItems = listOf( Triple(Screen.Home, "Home", BottomNavItem.Home.icon), Triple(Screen.Friends, "Friends", BottomNavItem.Friends.icon), - Triple(Screen.Profile, "Profile", BottomNavItem.Profile.icon) + Triple(Screen.Profile, "Profile", BottomNavItem.Profile.icon), + Triple(Screen.Archive, "Archive", BottomNavItem.Archive.icon) ) Scaffold( @@ -145,6 +148,13 @@ fun MainScreen( onBackClick = { navController.popBackStack() } ) } + composable { + ArchiveEventsView( + onEventDetailClick = { eventId -> + navController.navigate(Screen.EventDetail(eventId)) + } + ) + } } } } diff --git a/iosApp/iosApp/Modules/Archive/ArchiveEventsReducer.swift b/iosApp/iosApp/Modules/Archive/ArchiveEventsReducer.swift new file mode 100644 index 0000000..e3296b0 --- /dev/null +++ b/iosApp/iosApp/Modules/Archive/ArchiveEventsReducer.swift @@ -0,0 +1,57 @@ +// +// ArchiveEventsReducer.swift +// iosApp +// +// Created by Данил Забинский on 20.05.2026. +// + +import SwiftUI +import Shared + +@Observable +final class ArchiveEventsReducer { + + var archivedEvents: [MainEvent] = [] + var isRefreshing: Bool = false + var isLoading: Bool = false + var errorMessage: String? + + private let sharedVM: ArchiveEventsViewModel + private var stateTask: Task? + + init() { + self.sharedVM = ArchiveEventsViewModel() + let scope = sharedVM.viewModelScope + + stateTask = Task { + for await state in sharedVM.viewStates.asAsyncStream(scope: scope) { + guard let archiveState = state as? ArchiveViewState else { continue } + switch archiveState { + case is ArchiveViewState.Loading: + self.isLoading = true + self.errorMessage = nil + case let error as ArchiveViewState.Error: + self.errorMessage = error.message + self.isLoading = false + case let content as ArchiveViewState.Content: + self.archivedEvents = content.archivedEvents + self.isRefreshing = content.isRefreshing + self.isLoading = false + self.errorMessage = nil + default: + self.errorMessage = nil + self.isLoading = false + } + } + } + } + + deinit { + stateTask?.cancel() + sharedVM.clear() + } + + func refresh() { + sharedVM.obtainEvent(event: ArchiveViewAction.OnRefresh()) + } +} diff --git a/iosApp/iosApp/Modules/Archive/ArchiveView.swift b/iosApp/iosApp/Modules/Archive/ArchiveView.swift new file mode 100644 index 0000000..8ada31f --- /dev/null +++ b/iosApp/iosApp/Modules/Archive/ArchiveView.swift @@ -0,0 +1,68 @@ +// +// ArchiveView.swift +// iosApp +// +// Created by Данил Забинский on 20.05.2026. +// + +import SwiftUI +import Shared + +struct ArchiveView: View { + + @State private var reducer = ArchiveEventsReducer() + @Environment(Router.self) private var router + + var body: some View { + VStack { + if reducer.isLoading { + ProgressView() + .frame(maxHeight: .infinity, alignment: .center) + } else if let errorMessage = reducer.errorMessage { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.circle.fill") + .font(.largeTitle) + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + .multilineTextAlignment(.center) + Button(action: { + reducer.refresh() + }) { + Text("Retry") + .font(.headline) + } + .buttonStyle(.bordered) + } + .padding() + .frame(maxHeight: .infinity, alignment: .center) + } else if reducer.archivedEvents.isEmpty { + VStack(spacing: 12) { + Image(systemName: "archivebox") + .font(.largeTitle) + .foregroundColor(.gray) + Text("No archived events") + .foregroundColor(.gray) + } + .frame(maxHeight: .infinity, alignment: .center) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(reducer.archivedEvents, id: \.id) { event in + EventRowView(event: event, isPending: false) + .onTapGesture { + router.push(screen: .eventDetail(id: event.id)) + } + .padding(.horizontal) + } + } + .padding(.vertical) + } + } + } + .navigationTitle("Archive") + .refreshable { + reducer.refresh() + } + } +} diff --git a/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift b/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift index d98ac8b..bbc4355 100644 --- a/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift +++ b/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift @@ -64,7 +64,7 @@ struct EventDetailView: View { VStack(alignment: .leading, spacing: 4) { Text(participant.username) .font(.body) - .foregroundStyle(.primary) + .foregroundStyle(.black) Text(participant.role) .font(.caption) .foregroundStyle(.gray) diff --git a/iosApp/iosApp/Modules/Root/TabBarView.swift b/iosApp/iosApp/Modules/Root/TabBarView.swift index c62a547..76e2e87 100644 --- a/iosApp/iosApp/Modules/Root/TabBarView.swift +++ b/iosApp/iosApp/Modules/Root/TabBarView.swift @@ -11,6 +11,7 @@ import Shared private enum Tab { case main case friends + case archive case profile } @@ -31,6 +32,11 @@ struct TabBarView: View { Label("Friends", systemImage: "person.2.fill") } .tag(Tab.friends) + ArchiveView() + .tabItem { + Label("Archive", systemImage: "checkmark.square.fill") + } + .tag(Tab.archive) ProfileView() .tabItem { Label("Profile", systemImage: "person.fill") diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/archive/presentation/ArchiveEventsViewModel.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/archive/presentation/ArchiveEventsViewModel.kt new file mode 100644 index 0000000..4eaae6c --- /dev/null +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/archive/presentation/ArchiveEventsViewModel.kt @@ -0,0 +1,57 @@ +package friends.mobile.feature.archive.presentation + +import friends.mobile.core.domain.model.ResultWrapper +import friends.mobile.core.domain.model.getErrorMessage +import friends.mobile.core.domain.model.mapApiErrorToUserFriendly +import friends.mobile.core.viewmodel.BaseViewModel +import friends.mobile.feature.main.domain.repository.MainRepository +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class ArchiveEventsViewModel : BaseViewModel( + initState = ArchiveViewState.Loading, +), + KoinComponent { + + private val mainRepository: MainRepository by inject() + + init { + viewModelScope.launch { + loadArchivedEvents() + } + } + + override fun obtainEvent(event: ArchiveViewAction) { + when (event) { + is ArchiveViewAction.OnRefresh -> onRefresh() + } + } + + private fun loadArchivedEvents() { + viewModelScope.launch { + when (val result = mainRepository.getArchivedEvents()) { + is ResultWrapper.Success -> { + viewState = ArchiveViewState.Content( + archivedEvents = result.data, + isRefreshing = false, + ) + } + is ResultWrapper.Error -> { + val userError = mapApiErrorToUserFriendly(result.error) + viewState = ArchiveViewState.Error( + message = getErrorMessage(userError), + ) + } + } + } + } + + private fun onRefresh() { + val currentState = viewState + if (currentState is ArchiveViewState.Content) { + viewState = currentState.copy(isRefreshing = true) + } + loadArchivedEvents() + } +} diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/archive/presentation/ArchiveViewAction.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/archive/presentation/ArchiveViewAction.kt new file mode 100644 index 0000000..010d7a7 --- /dev/null +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/archive/presentation/ArchiveViewAction.kt @@ -0,0 +1,5 @@ +package friends.mobile.feature.archive.presentation + +sealed class ArchiveViewAction { + data object OnRefresh : ArchiveViewAction() +} diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/archive/presentation/ArchiveViewState.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/archive/presentation/ArchiveViewState.kt new file mode 100644 index 0000000..49d8409 --- /dev/null +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/archive/presentation/ArchiveViewState.kt @@ -0,0 +1,14 @@ +package friends.mobile.feature.archive.presentation + +import friends.mobile.feature.main.domain.model.MainEvent + +sealed class ArchiveViewState { + data object Loading : ArchiveViewState() + + data class Error(val message: String) : ArchiveViewState() + + data class Content( + val archivedEvents: List = emptyList(), + val isRefreshing: Boolean = false, + ) : ArchiveViewState() +} diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/remote/dto/EventListItemDto.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/remote/dto/EventListItemDto.kt index 0156a0b..bd8c9d4 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/remote/dto/EventListItemDto.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/remote/dto/EventListItemDto.kt @@ -9,6 +9,7 @@ data class EventListItemDto( @SerialName("title") val title: String, @SerialName("date") val date: String, @SerialName("creator_id") val creatorId: String, + @SerialName("status") val status: String, @SerialName("participants") val participants: List, ) diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/repository/MainRepositoryImpl.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/repository/MainRepositoryImpl.kt index 4cef6e1..fa9e1c5 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/repository/MainRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/data/repository/MainRepositoryImpl.kt @@ -36,6 +36,12 @@ internal class MainRepositoryImpl( ) } + override suspend fun getArchivedEvents(): ResultWrapper> = + safeApiCall { + val pastEvents = api.getEvents(scope = "past") + eventMapper.toDomain(pastEvents.filter { it.status == "completed" }) + } + override suspend fun checkUserAvailability(date: String): ResultWrapper = safeApiCall { val response = api.checkUserAvailability(date) diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/domain/repository/MainRepository.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/domain/repository/MainRepository.kt index 404c019..7fd8dc8 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/main/domain/repository/MainRepository.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/domain/repository/MainRepository.kt @@ -11,5 +11,7 @@ interface MainRepository { suspend fun getActiveAndPendingEvents(): ResultWrapper, List>> + suspend fun getArchivedEvents(): ResultWrapper> + suspend fun checkUserAvailability(date: String): ResultWrapper }