Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
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.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

@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)
// }
// )
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,6 +43,7 @@ sealed interface Screen {
val avatarUrl: String?
) : Screen
@Serializable data object PendingEvents : Screen
@Serializable data object Archive : Screen
}

@Composable
Expand All @@ -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(
Expand Down Expand Up @@ -145,6 +148,13 @@ fun MainScreen(
onBackClick = { navController.popBackStack() }
)
}
composable<Screen.Archive> {
ArchiveEventsView(
onEventDetailClick = { eventId ->
navController.navigate(Screen.EventDetail(eventId))
}
)
}
}
}
}
57 changes: 57 additions & 0 deletions iosApp/iosApp/Modules/Archive/ArchiveEventsReducer.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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())
}
}
Loading
Loading