From ed7b789333f999369861fb39079658d07ac59cad Mon Sep 17 00:00:00 2001 From: ralina Date: Tue, 2 Jun 2026 00:01:17 +0300 Subject: [PATCH] fixed all --- .../androidMain/kotlin/friends/mobile/App.kt | 2 +- .../mobile/auth/RegisterBottomSheet.kt | 186 ------- .../designsystem/components/ButtonFactory.kt | 4 +- .../designsystem/components/FormFields.kt | 2 +- .../designsystem/components/FormMessages.kt | 29 -- .../components/IndicatorFactory.kt | 3 +- .../designsystem/components/LoadingView.kt | 2 +- .../designsystem/components/SearchBar.kt | 4 +- .../designsystem/components/UserView.kt | 6 +- .../mobile/designsystem/theme/Colors.kt | 67 +-- .../events/CreateEventViewModelFactory.kt | 17 - .../events/EventDetailViewModelFactory.kt | 17 - .../mobile/events/PendingEventListView.kt | 311 ----------- .../archive/ArchiveEventsView.kt | 18 +- .../mobile/{ => feature}/auth/LoginScreen.kt | 174 ++++--- .../feature/auth/RegisterBottomSheet.kt | 220 ++++++++ .../mobile/{ => feature}/auth/RootScreen.kt | 6 +- .../{ => feature}/calendar/BusyDaysView.kt | 10 +- .../{ => feature}/events/CreateEventView.kt | 335 ++++++------ .../{ => feature}/events/EventDetailView.kt | 264 +++++----- .../events/PendingEventDetailContent.kt | 2 +- .../feature/events/PendingEventListView.kt | 358 +++++++++++++ .../feature/friends/FriendActionCallbacks.kt | 8 + .../friends/FriendProfileScreen.kt | 17 +- .../friends/FriendsComponents.kt | 2 +- .../{ => feature}/friends/FriendsScreen.kt | 2 +- .../mobile/{ => feature}/main/MainView.kt | 492 ++++++++++-------- .../profile/EditProfileScreen.kt | 164 +++--- .../{ => feature}/profile/ProfileScreen.kt | 40 +- .../wishplaces/WishPlacesComponents.kt | 259 +++++---- .../wishplaces/WishPlacesMode.kt | 4 +- .../wishplaces/WishPlacesSection.kt | 147 +++--- .../AppNavigation.kt} | 58 +-- .../{main => navigation}/BottomNavItem.kt | 4 +- .../friends/mobile/navigation/Screen.kt | 27 + .../src/androidMain/res/values/strings.xml | 2 + config/detekt.yml | 459 ++-------------- gradle/libs.versions.toml | 1 - .../analytics/FirebaseAnalyticsServiceImpl.kt | 2 +- .../presentation/ArchiveEventsViewModel.kt | 5 - .../auth/data/remote/AuthenticatedClient.kt | 2 +- .../data/repository/AuthRepositoryImpl.kt | 2 +- .../auth/data/storage/TokenStorageImpl.kt | 1 + .../mobile/feature/auth/di/AuthModule.kt | 2 +- .../feature/auth/domain/model/AuthSession.kt | 1 - .../feature/auth/domain/model/AuthToken.kt | 1 - .../feature/auth/domain/model/AuthUser.kt | 1 - .../{data => domain}/storage/TokenStorage.kt | 2 +- .../mobile/feature/events/di/EventsModule.kt | 2 +- .../{ => createevent}/CreateEventAction.kt | 2 +- .../{ => createevent}/CreateEventEvent.kt | 2 +- .../{ => createevent}/CreateEventViewModel.kt | 52 +- .../{ => createevent}/CreateEventViewState.kt | 2 +- .../pendingevents/PendingAction.kt | 4 +- .../pendingevents/PendingEventViewModel.kt | 43 +- .../friendsProfile/FriendProfileEvent.kt | 10 - .../friendsProfile/FriendProfileViewModel.kt | 1 - .../main/presentation/MainViewModel.kt | 22 - .../feature/profile/domain/model/Profile.kt | 3 - .../presentation/edit/EditProfileViewModel.kt | 5 - .../presentation/profile/ProfileViewModel.kt | 16 +- .../presentation/profile/ProfileViewState.kt | 2 +- .../presentation/WishPlacesViewModel.kt | 144 +---- 63 files changed, 1830 insertions(+), 2222 deletions(-) delete mode 100644 composeApp/src/androidMain/kotlin/friends/mobile/auth/RegisterBottomSheet.kt delete mode 100644 composeApp/src/androidMain/kotlin/friends/mobile/events/CreateEventViewModelFactory.kt delete mode 100644 composeApp/src/androidMain/kotlin/friends/mobile/events/EventDetailViewModelFactory.kt delete mode 100644 composeApp/src/androidMain/kotlin/friends/mobile/events/PendingEventListView.kt rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/archive/ArchiveEventsView.kt (95%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/auth/LoginScreen.kt (58%) create mode 100644 composeApp/src/androidMain/kotlin/friends/mobile/feature/auth/RegisterBottomSheet.kt rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/auth/RootScreen.kt (95%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/calendar/BusyDaysView.kt (97%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/events/CreateEventView.kt (67%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/events/EventDetailView.kt (69%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/events/PendingEventDetailContent.kt (99%) create mode 100644 composeApp/src/androidMain/kotlin/friends/mobile/feature/events/PendingEventListView.kt create mode 100644 composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendActionCallbacks.kt rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/friends/FriendProfileScreen.kt (94%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/friends/FriendsComponents.kt (99%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/friends/FriendsScreen.kt (99%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/main/MainView.kt (54%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/profile/EditProfileScreen.kt (50%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/profile/ProfileScreen.kt (83%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/wishplaces/WishPlacesComponents.kt (62%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/wishplaces/WishPlacesMode.kt (56%) rename composeApp/src/androidMain/kotlin/friends/mobile/{ => feature}/wishplaces/WishPlacesSection.kt (57%) rename composeApp/src/androidMain/kotlin/friends/mobile/{main/MainScreen.kt => navigation/AppNavigation.kt} (87%) rename composeApp/src/androidMain/kotlin/friends/mobile/{main => navigation}/BottomNavItem.kt (83%) create mode 100644 composeApp/src/androidMain/kotlin/friends/mobile/navigation/Screen.kt rename shared/src/commonMain/kotlin/friends/mobile/feature/auth/{data => domain}/storage/TokenStorage.kt (78%) rename shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/{ => createevent}/CreateEventAction.kt (72%) rename shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/{ => createevent}/CreateEventEvent.kt (86%) rename shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/{ => createevent}/CreateEventViewModel.kt (87%) rename shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/{ => createevent}/CreateEventViewState.kt (92%) diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/App.kt b/composeApp/src/androidMain/kotlin/friends/mobile/App.kt index bd99468..172993c 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/App.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/App.kt @@ -2,8 +2,8 @@ package friends.mobile import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import friends.mobile.auth.RootScreen import friends.mobile.designsystem.theme.FriendsAppTheme +import friends.mobile.feature.auth.RootScreen @Composable @Preview diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/auth/RegisterBottomSheet.kt b/composeApp/src/androidMain/kotlin/friends/mobile/auth/RegisterBottomSheet.kt deleted file mode 100644 index 8daae80..0000000 --- a/composeApp/src/androidMain/kotlin/friends/mobile/auth/RegisterBottomSheet.kt +++ /dev/null @@ -1,186 +0,0 @@ -package friends.mobile.auth - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.ui.res.stringResource -import friends.mobile.R -import friends.mobile.designsystem.components.ButtonFactory -import friends.mobile.designsystem.components.FormErrorMessage -import friends.mobile.designsystem.components.FormSecureField -import friends.mobile.designsystem.components.FormTextField -import friends.mobile.designsystem.theme.DesignTheme -import friends.mobile.feature.auth.presentation.register.RegisterAction -import friends.mobile.feature.auth.presentation.register.RegisterEvent -import friends.mobile.feature.auth.presentation.register.RegisterViewModel -import friends.mobile.feature.auth.presentation.register.RegisterViewState -import kotlinx.coroutines.flow.collectLatest -import org.koin.androidx.compose.koinViewModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun RegisterBottomSheet( - onDismiss: () -> Unit, - onRegisterSuccess: () -> Unit, - viewModel: RegisterViewModel = koinViewModel() -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val state by viewModel.viewStates.collectAsStateWithLifecycle() - - var username by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } - - val isRegistering = state is RegisterViewState.Loading || - (state as? RegisterViewState.Content)?.isRegistering == true - val errorMessage = (state as? RegisterViewState.Error)?.message - - LaunchedEffect(viewModel) { - viewModel.viewActions.collectLatest { action -> - when (action) { - RegisterAction.RegisterSucceeded -> onRegisterSuccess() - is RegisterAction.ShowMessage -> { } - } - } - } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xxl), - contentPadding = PaddingValues(vertical = DesignTheme.Spacing.lg, horizontal = 0.dp) - ) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = DesignTheme.Spacing.xl), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(R.string.auth_create_account), - style = DesignTheme.Typography.heading, - color = MaterialTheme.colorScheme.onSurface - ) - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = stringResource(R.string.close), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - } - } - } - - item { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = DesignTheme.Spacing.xl), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg), - ) { - FormTextField( - value = username, - onValueChange = { - username = it - viewModel.obtainEvent(RegisterEvent.OnUsernameChanged(it)) - }, - placeholder = stringResource(R.string.label_username), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false - ) - ) - - FormSecureField( - value = password, - onValueChange = { - password = it - viewModel.obtainEvent(RegisterEvent.OnPasswordChanged(it)) - }, - placeholder = stringResource(R.string.label_password), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) - ) - - if (errorMessage != null) { - FormErrorMessage(message = errorMessage) - } - } - } - - item { - ButtonFactory.Primary( - text = stringResource(R.string.auth_create_account), - onClick = { viewModel.obtainEvent(RegisterEvent.OnRegisterClick) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = DesignTheme.Spacing.xl), - isLoading = isRegistering, - isEnabled = username.isNotBlank() && password.isNotBlank() && !isRegistering - ) - } - - item { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = DesignTheme.Spacing.xl) - ) { - Text( - text = stringResource(R.string.auth_already_have_account), - style = DesignTheme.Typography.caption, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(DesignTheme.Spacing.xs)) - Text( - text = stringResource(R.string.auth_login), - modifier = Modifier.clickable { onDismiss() }, - style = DesignTheme.Typography.captionSemibold, - color = MaterialTheme.colorScheme.primary - ) - } - } - - item { - Spacer(modifier = Modifier.height(DesignTheme.Spacing.xl)) - } - } - } -} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/ButtonFactory.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/ButtonFactory.kt index 4a81889..0d366bd 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/ButtonFactory.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/ButtonFactory.kt @@ -9,16 +9,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.material3.MaterialTheme -import androidx.compose.ui.res.stringResource import friends.mobile.R import friends.mobile.designsystem.theme.DesignTheme diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/FormFields.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/FormFields.kt index a3aec0c..2cd8c5b 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/FormFields.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/FormFields.kt @@ -5,12 +5,12 @@ import androidx.compose.foundation.border import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.material3.MaterialTheme import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/FormMessages.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/FormMessages.kt index b062daf..1b49500 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/FormMessages.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/FormMessages.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -46,31 +45,3 @@ fun FormErrorMessage( ) } } - -@Composable -fun FormInfoMessage( - message: String, - modifier: Modifier = Modifier -) { - val primary = MaterialTheme.colorScheme.primary - Row( - modifier = modifier - .fillMaxWidth() - .background(primary.copy(alpha = 0.08f), RoundedCornerShape(10.dp)) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = primary, - modifier = Modifier.size(13.dp) - ) - Text( - text = message, - style = DesignTheme.Typography.bodySmallest, - color = primary.copy(alpha = 0.8f) - ) - } -} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/IndicatorFactory.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/IndicatorFactory.kt index 20e340f..3bab29d 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/IndicatorFactory.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/IndicatorFactory.kt @@ -8,11 +8,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.AccessTimeFilled import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -20,7 +20,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp -import androidx.compose.material3.MaterialTheme import friends.mobile.designsystem.theme.DesignTheme object IndicatorFactory { diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/LoadingView.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/LoadingView.kt index cb093f9..a34ed40 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/LoadingView.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/LoadingView.kt @@ -5,11 +5,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.material3.MaterialTheme import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import friends.mobile.designsystem.theme.DesignTheme diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/SearchBar.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/SearchBar.kt index 45029bc..9cae684 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/SearchBar.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/SearchBar.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults @@ -21,12 +22,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.material3.MaterialTheme import androidx.compose.ui.unit.dp -import androidx.compose.ui.res.stringResource import friends.mobile.R import friends.mobile.designsystem.theme.DesignTheme diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/UserView.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/UserView.kt index 9f10d5b..42deb19 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/UserView.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/components/UserView.kt @@ -47,7 +47,11 @@ fun UserView( val textStack: @Composable () -> Unit = { Column( - horizontalAlignment = if (dimension == Dimension.Vertical) Alignment.CenterHorizontally else Alignment.Start, + horizontalAlignment = if (dimension == Dimension.Vertical) { + Alignment.CenterHorizontally + } else { + Alignment.Start + }, verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/theme/Colors.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/theme/Colors.kt index fb07407..16acf05 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/theme/Colors.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designsystem/theme/Colors.kt @@ -3,66 +3,50 @@ package friends.mobile.designsystem.theme import androidx.compose.ui.graphics.Color object DesignColors { - - // Brand — iOS system blue #007AFF val primary = Color(0xFF007AFF) - val primaryHex = Color(red = 0.0f, green = 0.48f, blue = 1.0f) val onPrimary = Color.White val primaryContainer = Color(0xFFD0E4FF) val onPrimaryContainer = Color(0xFF001D36) val inversePrimary = Color(0xFF8FBFFF) - // Secondary — iOS systemGray #8E8E93 val secondary = Color(0xFF8E8E93) val onSecondary = Color.White - val secondaryContainer = Color(0xFFE5E5EA) // systemGray5 - val onSecondaryContainer = Color(0xFF1C1C1E) // label + val secondaryContainer = Color(0xFFE5E5EA) + val onSecondaryContainer = Color(0xFF1C1C1E) - // Tertiary — iOS green #34C759 val tertiary = Color(0xFF34C759) val onTertiary = Color.White val tertiaryContainer = Color(0xFFD4F5DD) val onTertiaryContainer = Color(0xFF002112) - // Error — iOS system red #FF3B30 val error = Color(0xFFFF3B30) val onError = Color.White val errorContainer = Color(0xFFFFEDEC) val onErrorContainer = Color(0xFF410002) val errorLight = Color.Red.copy(alpha = 0.08f) - // Background & Surface — systemBackground #FFFFFF val background = Color.White - val onBackground = Color(0xFF1C1C1E) // label + val onBackground = Color(0xFF1C1C1E) val surface = Color.White - val onSurface = Color(0xFF1C1C1E) // label - val surfaceVariant = Color(0xFFF2F2F7) // systemGray6 - val onSurfaceVariant = Color(0xFF8E8E93) // systemGray + val onSurface = Color(0xFF1C1C1E) + val surfaceVariant = Color(0xFFF2F2F7) + val onSurfaceVariant = Color(0xFF8E8E93) val surfaceBright = Color.White val surfaceDim = Color(0xFFEBEBF0) - val surfaceContainer = Color(0xFFF2F2F7) // systemGray6 — NavigationBar bg - val surfaceContainerLow = Color.White // ModalBottomSheet bg - val surfaceContainerHigh = Color(0xFFEBEBF0) // DatePicker dialog bg - val surfaceContainerHighest = Color(0xFFE5E5EA) // systemGray5 — TimePicker clock face + val surfaceContainer = Color(0xFFF2F2F7) + val surfaceContainerLow = Color.White + val surfaceContainerHigh = Color(0xFFEBEBF0) + val surfaceContainerHighest = Color(0xFFE5E5EA) val inverseSurface = Color(0xFF1C1C1E) val inverseOnSurface = Color(0xFFF2F2F7) val surfaceTint = Color.Transparent - // Outline — systemGray3 / systemGray4 - val outline = Color(0xFFC7C7CC) // systemGray3 - val outlineVariant = Color(0xFFD1D1D6) // systemGray4 + val outline = Color(0xFFC7C7CC) + val outlineVariant = Color(0xFFD1D1D6) val scrim = Color.Black - - // App-specific tokens - val textField = Color(0xFFF2F2F7) // text field bg (= systemGray6) - val infoLight = Color(red = 0.0f, green = 0.48f, blue = 1.0f).copy(alpha = 0.06f) - val secondaryAccent = Color(0xFF34C759) // = tertiary — busy day indicator - val systemGray6 = Color(0xFFF2F2F7) } object DesignColorsDark { - - // Brand — iOS dark system blue #0A84FF val primary = Color(0xFF0A84FF) val primaryHex = Color(0xFF0A84FF) val onPrimary = Color(0xFF003566) @@ -70,44 +54,39 @@ object DesignColorsDark { val onPrimaryContainer = Color(0xFFD6E3FF) val inversePrimary = Color(0xFF0A84FF) - // Secondary — iOS dark systemGray2 #636366 val secondary = Color(0xFF636366) val onSecondary = Color.White - val secondaryContainer = Color(0xFF3A3A3C) // systemGray4 dark + val secondaryContainer = Color(0xFF3A3A3C) val onSecondaryContainer = Color(0xFFEBEBF5) - // Tertiary — iOS dark green #30D158 val tertiary = Color(0xFF30D158) val onTertiary = Color.Black val tertiaryContainer = Color(0xFF004D13) val onTertiaryContainer = Color(0xFFC3F0C8) - // Error — iOS dark system red #FF453A val error = Color(0xFFFF453A) val onError = Color.Black val errorContainer = Color(0xFF93000A) val onErrorContainer = Color(0xFFFFDAD6) val errorLight = Color(0xFFFF453A).copy(alpha = 0.12f) - // Background & Surface — iOS dark systemBackground #000000 - val background = Color(0xFF000000) // systemBackground dark - val onBackground = Color.White // label dark + val background = Color(0xFF000000) + val onBackground = Color.White val surface = Color(0xFF000000) val onSurface = Color.White - val surfaceVariant = Color(0xFF1C1C1E) // systemGray6 dark + val surfaceVariant = Color(0xFF1C1C1E) val onSurfaceVariant = Color(0xFF8E8E93) - val surfaceBright = Color(0xFF2C2C2E) // systemGray5 dark + val surfaceBright = Color(0xFF2C2C2E) val surfaceDim = Color(0xFF000000) - val surfaceContainer = Color(0xFF1C1C1E) // systemGray6 dark — NavigationBar bg - val surfaceContainerLow = Color(0xFF000000) // ModalBottomSheet bg - val surfaceContainerHigh = Color(0xFF2C2C2E) // systemGray5 dark — DatePicker bg - val surfaceContainerHighest = Color(0xFF3A3A3C) // systemGray4 dark — TimePicker clock + val surfaceContainer = Color(0xFF1C1C1E) + val surfaceContainerLow = Color(0xFF000000) + val surfaceContainerHigh = Color(0xFF2C2C2E) + val surfaceContainerHighest = Color(0xFF3A3A3C) val inverseSurface = Color.White val inverseOnSurface = Color(0xFF1C1C1E) val surfaceTint = Color.Transparent - // Outline — iOS dark systemGray3 / systemGray4 - val outline = Color(0xFF48484A) // systemGray3 dark - val outlineVariant = Color(0xFF3A3A3C) // systemGray4 dark + val outline = Color(0xFF48484A) + val outlineVariant = Color(0xFF3A3A3C) val scrim = Color.Black } diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/events/CreateEventViewModelFactory.kt b/composeApp/src/androidMain/kotlin/friends/mobile/events/CreateEventViewModelFactory.kt deleted file mode 100644 index e9ff51a..0000000 --- a/composeApp/src/androidMain/kotlin/friends/mobile/events/CreateEventViewModelFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -package friends.mobile.events - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import friends.mobile.feature.events.presentation.CreateEventViewModel - -class CreateEventViewModelFactory( - private val selectedDate: String, -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(CreateEventViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return CreateEventViewModel(selectedDate = selectedDate) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/events/EventDetailViewModelFactory.kt b/composeApp/src/androidMain/kotlin/friends/mobile/events/EventDetailViewModelFactory.kt deleted file mode 100644 index 50f04a3..0000000 --- a/composeApp/src/androidMain/kotlin/friends/mobile/events/EventDetailViewModelFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -package friends.mobile.events - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import friends.mobile.feature.events.presentation.eventdetail.EventDetailViewModel - -class EventDetailViewModelFactory( - private val eventId: String, -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(EventDetailViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return EventDetailViewModel(eventId = eventId) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/events/PendingEventListView.kt b/composeApp/src/androidMain/kotlin/friends/mobile/events/PendingEventListView.kt deleted file mode 100644 index 43086f3..0000000 --- a/composeApp/src/androidMain/kotlin/friends/mobile/events/PendingEventListView.kt +++ /dev/null @@ -1,311 +0,0 @@ -package friends.mobile.events - -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.Spacer -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.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos -import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.Group -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.ui.res.stringResource -import friends.mobile.R -import friends.mobile.designsystem.components.ButtonFactory -import friends.mobile.designsystem.components.ErrorBanner -import friends.mobile.designsystem.components.LoadingView -import friends.mobile.designsystem.theme.DesignTheme -import friends.mobile.feature.events.domain.model.Event -import friends.mobile.feature.events.presentation.pendingevents.PendingAction -import friends.mobile.feature.events.presentation.pendingevents.PendingEvent -import friends.mobile.feature.events.presentation.pendingevents.PendingEventViewModel -import friends.mobile.feature.events.presentation.pendingevents.PendingViewState -import kotlinx.coroutines.flow.collectLatest -import org.koin.androidx.compose.koinViewModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PendingEventListView( - onBackClick: () -> Unit, -) { - val viewModel: PendingEventViewModel = koinViewModel() - val state by viewModel.viewStates.collectAsStateWithLifecycle( - initialValue = PendingViewState.Loading - ) - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(viewModel) { - viewModel.viewActions.collectLatest { action -> - when (action) { - is PendingAction.ShowMessage -> { - snackbarHostState.showSnackbar( - message = action.message, - duration = SnackbarDuration.Short - ) - } - is PendingAction.NavigateBack -> onBackClick() - } - } - } - - val isLoading = state is PendingViewState.Loading - val errorMessage = (state as? PendingViewState.Error)?.message - val isRefreshing = (state as? PendingViewState.Content)?.isRefreshing ?: false - val pendingEvents = (state as? PendingViewState.Content)?.events ?: emptyList() - val selectedEventDetail = (state as? PendingViewState.Content)?.selectedEventDetail - val isLoadingDetail = (state as? PendingViewState.Content)?.isLoadingDetail ?: false - val detailError = (state as? PendingViewState.Content)?.detailError - - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text(stringResource(R.string.pending_title)) }, - navigationIcon = { - IconButton(onClick = { viewModel.obtainEvent(PendingEvent.OnBackClick) }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - tint = MaterialTheme.colorScheme.primary - ) - } - } - ) - }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) } - ) { innerPadding -> - when { - isLoading && pendingEvents.isEmpty() -> { - LoadingView( - modifier = Modifier.fillMaxSize().padding(innerPadding), - message = stringResource(R.string.pending_loading) - ) - } - - errorMessage != null && pendingEvents.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(DesignTheme.Spacing.xl), - contentAlignment = Alignment.TopCenter, - ) { - ErrorBanner( - message = errorMessage, - onRetry = { viewModel.obtainEvent(PendingEvent.OnRefresh) } - ) - } - } - - else -> { - Column( - modifier = Modifier.fillMaxSize().padding(innerPadding) - ) { - if (pendingEvents.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(DesignTheme.Spacing.xl), - contentAlignment = Alignment.TopCenter, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg), - ) { - Icon( - imageVector = Icons.Default.Email, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) - ) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs) - ) { - Text( - text = stringResource(R.string.pending_no_invitations), - style = DesignTheme.Typography.captionSemibold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(R.string.pending_all_caught_up), - style = DesignTheme.Typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } else { - LazyColumn( - modifier = Modifier.fillMaxWidth().weight(1f), - contentPadding = PaddingValues(DesignTheme.Spacing.lg), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm), - ) { - items(pendingEvents, key = { it.id }) { event -> - PendingEventCard( - event = event, - onClick = { - viewModel.obtainEvent(PendingEvent.OnEventClick(event.id)) - } - ) - } - } - } - - if (isRefreshing) { - Box( - modifier = Modifier.fillMaxWidth().padding(DesignTheme.Spacing.lg), - contentAlignment = Alignment.Center, - ) { - LoadingView(modifier = Modifier.fillMaxWidth(), message = stringResource(R.string.loading_refreshing)) - } - } - } - } - } - } - - if (selectedEventDetail != null) { - ModalBottomSheet( - onDismissRequest = { viewModel.obtainEvent(PendingEvent.OnDismissDetail) }, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - ) { - PendingEventDetailContent( - event = selectedEventDetail, - isLoading = isLoadingDetail, - errorMessage = detailError, - onAccept = { eventId -> viewModel.obtainEvent(PendingEvent.OnAcceptEvent(eventId)) }, - onDecline = { eventId -> viewModel.obtainEvent(PendingEvent.OnDeclineEvent(eventId)) }, - modifier = Modifier.padding(bottom = DesignTheme.Spacing.xxl) - ) - } - } -} - -@Composable -private fun PendingEventCard( - event: Event, - onClick: () -> Unit = {} -) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(DesignTheme.CornerRadius.medium)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { onClick() } - .padding(DesignTheme.Spacing.lg), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs), - ) { - Text( - text = event.title, - style = DesignTheme.Typography.captionSemibold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = event.date, - style = DesignTheme.Typography.bodySmallest, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Group, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = "${event.participants.size}", - style = DesignTheme.Typography.bodySmallest, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (!event.time.isNullOrEmpty()) { - Text( - text = event.time!!, - style = DesignTheme.Typography.bodySmallest, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - HorizontalDivider( - modifier = Modifier.padding(vertical = DesignTheme.Spacing.xs), - color = MaterialTheme.colorScheme.outlineVariant, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Notifications, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.tertiary, - ) - Text( - text = stringResource(R.string.pending_awaiting), - style = DesignTheme.Typography.bodySmallest, - color = MaterialTheme.colorScheme.tertiary, - ) - Spacer(modifier = Modifier.weight(1f)) - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/archive/ArchiveEventsView.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/archive/ArchiveEventsView.kt similarity index 95% rename from composeApp/src/androidMain/kotlin/friends/mobile/archive/ArchiveEventsView.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/archive/ArchiveEventsView.kt index 2bf18ee..b4c33fc 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/archive/ArchiveEventsView.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/archive/ArchiveEventsView.kt @@ -1,4 +1,4 @@ -package friends.mobile.archive +package friends.mobile.feature.archive import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -26,30 +26,26 @@ import androidx.compose.material.icons.filled.Schedule 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.material3.pulltorefresh.PullToRefreshBox 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.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign 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 androidx.compose.material3.MaterialTheme -import androidx.compose.ui.res.stringResource import friends.mobile.R -import friends.mobile.designsystem.theme.DesignTheme -import friends.mobile.designsystem.components.ButtonFactory import friends.mobile.designsystem.components.ErrorBanner +import friends.mobile.designsystem.theme.DesignTheme import friends.mobile.feature.archive.presentation.ArchiveEventsViewModel import friends.mobile.feature.archive.presentation.ArchiveViewAction import friends.mobile.feature.archive.presentation.ArchiveViewState @@ -120,8 +116,8 @@ fun ArchiveEventsView( } else -> { - SwipeRefresh( - state = rememberSwipeRefreshState(isRefreshing), + PullToRefreshBox( + isRefreshing = isRefreshing, onRefresh = { viewModel.obtainEvent(ArchiveViewAction.OnRefresh) }, modifier = Modifier.fillMaxSize(), ) { diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/auth/LoginScreen.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/auth/LoginScreen.kt similarity index 58% rename from composeApp/src/androidMain/kotlin/friends/mobile/auth/LoginScreen.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/auth/LoginScreen.kt index 86c90c7..42988c8 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/auth/LoginScreen.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/auth/LoginScreen.kt @@ -1,4 +1,4 @@ -package friends.mobile.auth +package friends.mobile.feature.auth import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -26,8 +26,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -58,7 +58,7 @@ fun LoginScreen( var sideEffectError by remember { mutableStateOf(null) } val isLoggingIn = state is LoginViewState.Loading || - (state as? LoginViewState.Content)?.isLoggingIn == true + (state as? LoginViewState.Content)?.isLoggingIn == true val errorMessage = (state as? LoginViewState.Error)?.message ?: sideEffectError LaunchedEffect(viewModel) { @@ -70,7 +70,7 @@ fun LoginScreen( } } - LoginForm( + LoginContent( username = username, password = password, errorMessage = errorMessage, @@ -98,7 +98,7 @@ fun LoginScreen( } @Composable -private fun LoginForm( +private fun LoginContent( username: String, password: String, errorMessage: String?, @@ -119,83 +119,111 @@ private fun LoginForm( bottom = DesignTheme.Spacing.xl ) ) { + item { LoginHeroImage() } item { - Image( - painter = painterResource(id = R.drawable.onboarding_image), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxWidth() - .padding(top = DesignTheme.Spacing.sm, start = DesignTheme.Spacing.sm, end = DesignTheme.Spacing.sm) + LoginFields( + username = username, + password = password, + errorMessage = errorMessage, + onUsernameChanged = onUsernameChanged, + onPasswordChanged = onPasswordChanged, ) } - - item { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = DesignTheme.Spacing.xl), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg), - ) { - FormTextField( - value = username, - onValueChange = onUsernameChanged, - placeholder = stringResource(R.string.label_username), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false - ) - ) - - FormSecureField( - value = password, - onValueChange = onPasswordChanged, - placeholder = stringResource(R.string.label_password), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) - ) - - if (errorMessage != null) { - FormErrorMessage(message = errorMessage) - } - } - } - item { - ButtonFactory.Primary( - text = stringResource(R.string.auth_login), + LoginButton( + isLoggingIn = isLoggingIn, + isEnabled = username.isNotBlank() && password.isNotBlank() && !isLoggingIn, onClick = onLoginClick, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = DesignTheme.Spacing.xl), - isLoading = isLoggingIn, - isEnabled = username.isNotBlank() && password.isNotBlank() && !isLoggingIn ) } + item { RegisterPrompt(onRegisterClick = onRegisterClick) } + item { Spacer(modifier = Modifier.height(DesignTheme.Spacing.xl)) } + } +} - item { - Row( - horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.auth_no_account), - style = DesignTheme.Typography.caption, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = stringResource(R.string.auth_sign_up), - modifier = Modifier.clickable { onRegisterClick() }, - style = DesignTheme.Typography.captionSemibold, - color = MaterialTheme.colorScheme.primary - ) - } - } +@Composable +private fun LoginHeroImage() { + Image( + painter = painterResource(id = R.drawable.onboarding_image), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth() + .padding(top = DesignTheme.Spacing.sm, start = DesignTheme.Spacing.sm, end = DesignTheme.Spacing.sm) + ) +} - item { - Spacer(modifier = Modifier.height(DesignTheme.Spacing.xl)) +@Composable +private fun LoginFields( + username: String, + password: String, + errorMessage: String?, + onUsernameChanged: (String) -> Unit, + onPasswordChanged: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = DesignTheme.Spacing.xl), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg), + ) { + FormTextField( + value = username, + onValueChange = onUsernameChanged, + placeholder = stringResource(R.string.label_username), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false + ) + ) + FormSecureField( + value = password, + onValueChange = onPasswordChanged, + placeholder = stringResource(R.string.label_password), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) + ) + if (errorMessage != null) { + FormErrorMessage(message = errorMessage) } } } + +@Composable +private fun LoginButton( + isLoggingIn: Boolean, + isEnabled: Boolean, + onClick: () -> Unit, +) { + ButtonFactory.Primary( + text = stringResource(R.string.auth_login), + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = DesignTheme.Spacing.xl), + isLoading = isLoggingIn, + isEnabled = isEnabled, + ) +} + +@Composable +private fun RegisterPrompt(onRegisterClick: () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.auth_no_account), + style = DesignTheme.Typography.caption, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.auth_sign_up), + modifier = Modifier.clickable { onRegisterClick() }, + style = DesignTheme.Typography.captionSemibold, + color = MaterialTheme.colorScheme.primary + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/feature/auth/RegisterBottomSheet.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/auth/RegisterBottomSheet.kt new file mode 100644 index 0000000..62963de --- /dev/null +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/auth/RegisterBottomSheet.kt @@ -0,0 +1,220 @@ +package friends.mobile.feature.auth + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import friends.mobile.R +import friends.mobile.designsystem.components.ButtonFactory +import friends.mobile.designsystem.components.FormErrorMessage +import friends.mobile.designsystem.components.FormSecureField +import friends.mobile.designsystem.components.FormTextField +import friends.mobile.designsystem.theme.DesignTheme +import friends.mobile.feature.auth.presentation.register.RegisterAction +import friends.mobile.feature.auth.presentation.register.RegisterEvent +import friends.mobile.feature.auth.presentation.register.RegisterViewModel +import friends.mobile.feature.auth.presentation.register.RegisterViewState +import kotlinx.coroutines.flow.collectLatest +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RegisterBottomSheet( + onDismiss: () -> Unit, + onRegisterSuccess: () -> Unit, + viewModel: RegisterViewModel = koinViewModel() +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val state by viewModel.viewStates.collectAsStateWithLifecycle() + + var username by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + + val isRegistering = state is RegisterViewState.Loading || + (state as? RegisterViewState.Content)?.isRegistering == true + val errorMessage = (state as? RegisterViewState.Error)?.message + + LaunchedEffect(viewModel) { + viewModel.viewActions.collectLatest { action -> + when (action) { + RegisterAction.RegisterSucceeded -> onRegisterSuccess() + is RegisterAction.ShowMessage -> {} + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xxl), + contentPadding = PaddingValues(vertical = DesignTheme.Spacing.lg, horizontal = 0.dp) + ) { + item { + RegisterHeader(onDismiss = onDismiss) + } + item { + RegisterForm( + username = username, + password = password, + errorMessage = errorMessage, + onUsernameChange = { + username = it + viewModel.obtainEvent(RegisterEvent.OnUsernameChanged(it)) + }, + onPasswordChange = { + password = it + viewModel.obtainEvent(RegisterEvent.OnPasswordChanged(it)) + }, + ) + } + item { + RegisterButton( + isRegistering = isRegistering, + isEnabled = username.isNotBlank() && password.isNotBlank() && !isRegistering, + onClick = { viewModel.obtainEvent(RegisterEvent.OnRegisterClick) }, + ) + } + item { + LoginPrompt(onLoginClick = onDismiss) + } + item { + Spacer(modifier = Modifier.height(DesignTheme.Spacing.xl)) + } + } + } +} + +@Composable +private fun RegisterHeader(onDismiss: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = DesignTheme.Spacing.xl), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.auth_create_account), + style = DesignTheme.Typography.heading, + color = MaterialTheme.colorScheme.onSurface + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = stringResource(R.string.close), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } +} + +@Composable +private fun RegisterForm( + username: String, + password: String, + errorMessage: String?, + onUsernameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = DesignTheme.Spacing.xl), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg), + ) { + FormTextField( + value = username, + onValueChange = onUsernameChange, + placeholder = stringResource(R.string.label_username), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false + ) + ) + FormSecureField( + value = password, + onValueChange = onPasswordChange, + placeholder = stringResource(R.string.label_password), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) + ) + if (errorMessage != null) { + FormErrorMessage(message = errorMessage) + } + } +} + +@Composable +private fun RegisterButton( + isRegistering: Boolean, + isEnabled: Boolean, + onClick: () -> Unit, +) { + ButtonFactory.Primary( + text = stringResource(R.string.auth_create_account), + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = DesignTheme.Spacing.xl), + isLoading = isRegistering, + isEnabled = isEnabled, + ) +} + +@Composable +private fun LoginPrompt(onLoginClick: () -> Unit) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = DesignTheme.Spacing.xl) + ) { + Text( + text = stringResource(R.string.auth_already_have_account), + style = DesignTheme.Typography.caption, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(DesignTheme.Spacing.xs)) + Text( + text = stringResource(R.string.auth_login), + modifier = Modifier.clickable { onLoginClick() }, + style = DesignTheme.Typography.captionSemibold, + color = MaterialTheme.colorScheme.primary + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/auth/RootScreen.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/auth/RootScreen.kt similarity index 95% rename from composeApp/src/androidMain/kotlin/friends/mobile/auth/RootScreen.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/auth/RootScreen.kt index bee1573..870b470 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/auth/RootScreen.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/auth/RootScreen.kt @@ -1,4 +1,4 @@ -package friends.mobile.auth +package friends.mobile.feature.auth import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -15,7 +15,7 @@ import friends.mobile.designsystem.theme.DesignTheme import friends.mobile.feature.auth.presentation.RootEvent import friends.mobile.feature.auth.presentation.RootViewModel import friends.mobile.feature.auth.presentation.RootViewState -import friends.mobile.main.MainScreen +import friends.mobile.navigation.AppNavigation import org.koin.androidx.compose.koinViewModel @Composable @@ -53,7 +53,7 @@ fun RootScreen( }, ) } else { - MainScreen( + AppNavigation( onLogout = { viewModel.obtainEvent(RootEvent.OnLogoutClick) } diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/calendar/BusyDaysView.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/calendar/BusyDaysView.kt similarity index 97% rename from composeApp/src/androidMain/kotlin/friends/mobile/calendar/BusyDaysView.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/calendar/BusyDaysView.kt index 2a87b8f..0907176 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/calendar/BusyDaysView.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/calendar/BusyDaysView.kt @@ -1,4 +1,4 @@ -package friends.mobile.calendar +package friends.mobile.feature.calendar import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -19,16 +19,16 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.ui.res.stringResource import friends.mobile.R -import friends.mobile.designsystem.components.ButtonFactory import friends.mobile.designsystem.components.ErrorBanner import friends.mobile.designsystem.theme.DesignTheme import friends.mobile.feature.calendar.presentation.BusyDaysEvent @@ -51,6 +51,10 @@ fun BusyDaysView( ) { val state by viewModel.viewStates.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + viewModel.obtainEvent(BusyDaysEvent.OnRefresh) + } + Column(modifier = modifier.fillMaxWidth()) { when (val current = state) { is BusyDaysViewState.Loading -> { diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/events/CreateEventView.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/events/CreateEventView.kt similarity index 67% rename from composeApp/src/androidMain/kotlin/friends/mobile/events/CreateEventView.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/events/CreateEventView.kt index ece78e8..9b00dc9 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/events/CreateEventView.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/events/CreateEventView.kt @@ -1,4 +1,4 @@ -package friends.mobile.events +package friends.mobile.feature.events import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -47,10 +47,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.compose.ui.res.stringResource import friends.mobile.R import friends.mobile.designsystem.components.ButtonFactory import friends.mobile.designsystem.components.ErrorBanner @@ -58,12 +57,14 @@ import friends.mobile.designsystem.components.FormErrorMessage import friends.mobile.designsystem.components.FormTextField import friends.mobile.designsystem.components.LoadingView import friends.mobile.designsystem.theme.DesignTheme -import friends.mobile.feature.events.presentation.CreateEventAction -import friends.mobile.feature.events.presentation.CreateEventEvent -import friends.mobile.feature.events.presentation.CreateEventViewModel -import friends.mobile.feature.events.presentation.CreateEventViewState +import friends.mobile.feature.events.presentation.createevent.CreateEventAction +import friends.mobile.feature.events.presentation.createevent.CreateEventEvent +import friends.mobile.feature.events.presentation.createevent.CreateEventViewModel +import friends.mobile.feature.events.presentation.createevent.CreateEventViewState import friends.mobile.feature.friends.domain.model.User import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -72,9 +73,7 @@ fun CreateEventView( onEventCreated: (eventId: String) -> Unit, onBackClick: () -> Unit, ) { - val viewModel: CreateEventViewModel = viewModel( - factory = CreateEventViewModelFactory(selectedDate) - ) + val viewModel: CreateEventViewModel = koinViewModel(parameters = { parametersOf(selectedDate) }) val state by viewModel.viewStates.collectAsStateWithLifecycle() var showFriendsSheet by rememberSaveable { mutableStateOf(false) } @@ -93,20 +92,7 @@ fun CreateEventView( } Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text(stringResource(R.string.event_create_title)) }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - tint = MaterialTheme.colorScheme.primary - ) - } - } - ) - } + topBar = { CreateEventTopBar(onBackClick = onBackClick) } ) { innerPadding -> when (state) { is CreateEventViewState.Loading -> { @@ -115,25 +101,13 @@ fun CreateEventView( message = stringResource(R.string.event_loading_friends) ) } - is CreateEventViewState.Error -> { - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(DesignTheme.Spacing.lg), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - ErrorBanner(message = (state as CreateEventViewState.Error).message) - ButtonFactory.Primary( - text = stringResource(R.string.back), - onClick = onBackClick, - modifier = Modifier.padding(top = DesignTheme.Spacing.lg), - ) - } + CreateEventErrorContent( + message = (state as CreateEventViewState.Error).message, + onBackClick = onBackClick, + modifier = Modifier.fillMaxSize().padding(innerPadding), + ) } - is CreateEventViewState.Content -> { val contentState = state as CreateEventViewState.Content Column( @@ -144,116 +118,19 @@ fun CreateEventView( .padding(top = DesignTheme.Spacing.lg, bottom = DesignTheme.Spacing.lg), verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg) ) { - // Date header (outside scroll) - Column( - modifier = Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - shape = RoundedCornerShape(DesignTheme.CornerRadius.medium) - ) - .padding(vertical = DesignTheme.Spacing.lg), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm) - ) { - Text( - text = stringResource(R.string.event_section_date), - style = DesignTheme.Typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = contentState.selectedDate, - style = DesignTheme.Typography.heading, - color = MaterialTheme.colorScheme.onSurface - ) - } - + CreateEventDateHeader(date = contentState.selectedDate) if (contentState.isLoadingFriends) { - // Loading friends fills remaining space - Box( - modifier = Modifier.weight(1f).fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md) - ) { - CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) - Text( - text = stringResource(R.string.event_loading_friends), - style = DesignTheme.Typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } + FriendsLoadingIndicator(modifier = Modifier.weight(1f).fillMaxWidth()) } else { - // Scrollable form content - LazyColumn( + CreateEventFormContent( + contentState = contentState, + onTitleChange = { viewModel.obtainEvent(CreateEventEvent.OnTitleChanged(it)) }, + onDescriptionChange = { viewModel.obtainEvent(CreateEventEvent.OnDescriptionChanged(it)) }, + onLocationChange = { viewModel.obtainEvent(CreateEventEvent.OnLocationChanged(it)) }, + onToggleFriend = { viewModel.obtainEvent(CreateEventEvent.OnToggleFriend(it)) }, + onShowFriendsSheet = { showFriendsSheet = true }, + onCreateEvent = { viewModel.obtainEvent(CreateEventEvent.OnCreateEvent) }, modifier = Modifier.weight(1f).fillMaxWidth(), - contentPadding = PaddingValues(bottom = DesignTheme.Spacing.xl), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg) - ) { - if (contentState.friendsError != null) { - item { - FormErrorMessage(message = contentState.friendsError!!) - } - } - - // Labeled form fields - item { - Column(verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg)) { - LabeledFormField( - label = stringResource(R.string.label_title), - icon = Icons.Default.Edit, - value = contentState.title, - onValueChange = { - viewModel.obtainEvent(CreateEventEvent.OnTitleChanged(it)) - }, - placeholder = stringResource(R.string.event_hint_title) - ) - LabeledFormField( - label = stringResource(R.string.label_description), - icon = Icons.Default.Description, - value = contentState.description, - onValueChange = { - viewModel.obtainEvent(CreateEventEvent.OnDescriptionChanged(it)) - }, - placeholder = stringResource(R.string.event_hint_description) - ) - LabeledFormField( - label = stringResource(R.string.label_location), - icon = Icons.Default.LocationOn, - value = contentState.location, - onValueChange = { - viewModel.obtainEvent(CreateEventEvent.OnLocationChanged(it)) - }, - placeholder = stringResource(R.string.event_hint_location) - ) - } - } - - // Friends selection - item { - FriendsSelectionSection( - selectedCount = contentState.selectedFriendIds.size, - selectedFriends = contentState.availableFriends.filter { - contentState.selectedFriendIds.contains(it.id) - }, - onShowSheet = { showFriendsSheet = true }, - onRemoveFriend = { friendId -> - viewModel.obtainEvent(CreateEventEvent.OnToggleFriend(friendId)) - } - ) - } - } - - // Create button fixed at bottom - ButtonFactory.Primary( - text = stringResource(R.string.event_create_title), - onClick = { viewModel.obtainEvent(CreateEventEvent.OnCreateEvent) }, - isLoading = contentState.isCreatingEvent, - isEnabled = contentState.isCreateButtonEnabled && !contentState.isCreatingEvent, - modifier = Modifier.fillMaxWidth() ) } } @@ -272,15 +149,166 @@ fun CreateEventView( error = contentState.friendsError, friends = contentState.availableFriends, selectedFriendIds = contentState.selectedFriendIds, - onToggleFriend = { friendId -> - viewModel.obtainEvent(CreateEventEvent.OnToggleFriend(friendId)) - }, + onToggleFriend = { viewModel.obtainEvent(CreateEventEvent.OnToggleFriend(it)) }, onDone = { showFriendsSheet = false }, ) } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreateEventTopBar(onBackClick: () -> Unit) { + CenterAlignedTopAppBar( + title = { Text(stringResource(R.string.event_create_title)) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.primary + ) + } + } + ) +} + +@Composable +private fun CreateEventErrorContent( + message: String, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(DesignTheme.Spacing.lg), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + ErrorBanner(message = message) + ButtonFactory.Primary( + text = stringResource(R.string.back), + onClick = onBackClick, + modifier = Modifier.padding(top = DesignTheme.Spacing.lg), + ) + } +} + +@Composable +private fun CreateEventDateHeader(date: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(DesignTheme.CornerRadius.medium) + ) + .padding(vertical = DesignTheme.Spacing.lg), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm) + ) { + Text( + text = stringResource(R.string.event_section_date), + style = DesignTheme.Typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = date, + style = DesignTheme.Typography.heading, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +private fun FriendsLoadingIndicator(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md) + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Text( + text = stringResource(R.string.event_loading_friends), + style = DesignTheme.Typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun CreateEventFormContent( + contentState: CreateEventViewState.Content, + onTitleChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onLocationChange: (String) -> Unit, + onToggleFriend: (String) -> Unit, + onShowFriendsSheet: () -> Unit, + onCreateEvent: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = DesignTheme.Spacing.xl), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg) + ) { + if (contentState.friendsError != null) { + item { + FormErrorMessage(message = contentState.friendsError!!) + } + } + item { + Column(verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg)) { + LabeledFormField( + label = stringResource(R.string.label_title), + icon = Icons.Default.Edit, + value = contentState.title, + onValueChange = onTitleChange, + placeholder = stringResource(R.string.event_hint_title) + ) + LabeledFormField( + label = stringResource(R.string.label_description), + icon = Icons.Default.Description, + value = contentState.description, + onValueChange = onDescriptionChange, + placeholder = stringResource(R.string.event_hint_description) + ) + LabeledFormField( + label = stringResource(R.string.label_location), + icon = Icons.Default.LocationOn, + value = contentState.location, + onValueChange = onLocationChange, + placeholder = stringResource(R.string.event_hint_location) + ) + } + } + item { + FriendsSelectionSection( + selectedCount = contentState.selectedFriendIds.size, + selectedFriends = contentState.availableFriends.filter { + contentState.selectedFriendIds.contains(it.id) + }, + onShowSheet = onShowFriendsSheet, + onRemoveFriend = onToggleFriend, + ) + } + } + + ButtonFactory.Primary( + text = stringResource(R.string.event_create_title), + onClick = onCreateEvent, + isLoading = contentState.isCreatingEvent, + isEnabled = contentState.isCreateButtonEnabled && !contentState.isCreatingEvent, + modifier = Modifier.fillMaxWidth() + ) + } +} + @Composable private fun LabeledFormField( label: String, @@ -427,6 +455,7 @@ private fun FriendsSelectionSection( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun FriendsSelectionSheet( isLoading: Boolean, @@ -472,11 +501,9 @@ private fun FriendsSelectionSheet( } } } - error != null -> { FormErrorMessage(message = error) } - friends.isEmpty() -> { Column( modifier = Modifier @@ -502,7 +529,6 @@ private fun FriendsSelectionSheet( ) } } - else -> { LazyColumn( modifier = Modifier.fillMaxWidth(), @@ -560,4 +586,3 @@ private fun FriendSelectionItem( ) } } - diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/events/EventDetailView.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/events/EventDetailView.kt similarity index 69% rename from composeApp/src/androidMain/kotlin/friends/mobile/events/EventDetailView.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/events/EventDetailView.kt index 0d97157..ae372d9 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/events/EventDetailView.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/events/EventDetailView.kt @@ -1,4 +1,4 @@ -package friends.mobile.events +package friends.mobile.feature.events import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -36,23 +37,22 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import friends.mobile.R -import androidx.compose.material3.MaterialTheme -import friends.mobile.designsystem.theme.DesignTheme import friends.mobile.designsystem.components.ErrorBanner import friends.mobile.designsystem.components.LoadingView +import friends.mobile.designsystem.theme.DesignTheme import friends.mobile.feature.events.domain.model.Event import friends.mobile.feature.events.domain.model.EventParticipant import friends.mobile.feature.events.domain.model.ParticipationStatus import friends.mobile.feature.events.presentation.eventdetail.EventDetailEvent import friends.mobile.feature.events.presentation.eventdetail.EventDetailViewModel import friends.mobile.feature.events.presentation.eventdetail.EventDetailViewState +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -60,26 +60,11 @@ fun EventDetailView( eventId: String, onBackClick: () -> Unit, ) { - val viewModel: EventDetailViewModel = viewModel( - factory = EventDetailViewModelFactory(eventId) - ) + val viewModel: EventDetailViewModel = koinViewModel(parameters = { parametersOf(eventId) }) val state by viewModel.viewStates.collectAsStateWithLifecycle() Scaffold( - topBar = { - TopAppBar( - title = {}, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - tint = MaterialTheme.colorScheme.primary - ) - } - } - ) - } + topBar = { EventDetailTopBar(onBackClick = onBackClick) } ) { innerPadding -> when (val currentState = state) { is EventDetailViewState.Loading -> { @@ -88,23 +73,13 @@ fun EventDetailView( message = stringResource(R.string.event_detail_loading) ) } - is EventDetailViewState.Error -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(DesignTheme.Spacing.lg), - contentAlignment = Alignment.Center, - ) { - ErrorBanner( - message = currentState.message, - modifier = Modifier.fillMaxWidth(), - onRetry = { viewModel.obtainEvent(EventDetailEvent.OnRefresh) } - ) - } + EventDetailErrorContent( + message = currentState.message, + onRetry = { viewModel.obtainEvent(EventDetailEvent.OnRefresh) }, + modifier = Modifier.fillMaxSize().padding(innerPadding), + ) } - is EventDetailViewState.Content -> { EventDetailContent( event = currentState.event, @@ -115,6 +90,41 @@ fun EventDetailView( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EventDetailTopBar(onBackClick: () -> Unit) { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.primary + ) + } + } + ) +} + +@Composable +private fun EventDetailErrorContent( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding(DesignTheme.Spacing.lg), + contentAlignment = Alignment.Center, + ) { + ErrorBanner( + message = message, + modifier = Modifier.fillMaxWidth(), + onRetry = onRetry, + ) + } +} + @Composable private fun EventDetailContent( event: Event, @@ -128,97 +138,111 @@ private fun EventDetailContent( ), verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg), ) { - item { - EventImageView() - } - - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - Text( - text = event.title, - style = DesignTheme.Typography.heading, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f).padding(end = DesignTheme.Spacing.md), - maxLines = 3 - ) - StatusBadge(status = event.status) + item { EventImageView() } + item { EventTitleRow(title = event.title, status = event.status) } + item { EventDetailsSection(event = event) } + event.description?.let { description -> + if (description.isNotEmpty()) { + item { EventDescriptionSection(description = description) } } } + item { EventParticipantsSection(participants = event.participants) } + } +} - item { - Column(verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md)) { +@Composable +private fun EventTitleRow(title: String, status: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = title, + style = DesignTheme.Typography.heading, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f).padding(end = DesignTheme.Spacing.md), + maxLines = 3 + ) + StatusBadge(status = status) + } +} + +@Composable +private fun EventDetailsSection(event: Event) { + Column(verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md)) { + DetailRowWithIcon( + icon = Icons.Default.CalendarToday, + label = stringResource(R.string.label_date), + value = event.date + ) + event.time?.let { + DetailRowWithIcon( + icon = Icons.Default.Schedule, + label = stringResource(R.string.label_time), + value = it + ) + } + event.location?.let { + if (it.isNotEmpty()) { DetailRowWithIcon( - icon = Icons.Default.CalendarToday, - label = stringResource(R.string.label_date), - value = event.date + icon = Icons.Default.LocationOn, + label = stringResource(R.string.label_location), + value = it ) - event.time?.let { - DetailRowWithIcon(icon = Icons.Default.Schedule, label = stringResource(R.string.label_time), value = it) - } - event.location?.let { - if (it.isNotEmpty()) { - DetailRowWithIcon(icon = Icons.Default.LocationOn, label = stringResource(R.string.label_location), value = it) - } - } } } + } +} - event.description?.let { description -> - if (description.isNotEmpty()) { - item { - Column( - modifier = Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(DesignTheme.CornerRadius.medium) - ) - .padding(DesignTheme.Spacing.md), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm) - ) { - Text( - text = stringResource(R.string.label_description), - style = DesignTheme.Typography.button, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = description, - style = DesignTheme.Typography.body, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } +@Composable +private fun EventDescriptionSection(description: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(DesignTheme.CornerRadius.medium) + ) + .padding(DesignTheme.Spacing.md), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm) + ) { + Text( + text = stringResource(R.string.label_description), + style = DesignTheme.Typography.button, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = description, + style = DesignTheme.Typography.body, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} - item { +@Composable +private fun EventParticipantsSection(participants: List) { + Column(verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md)) { + Text( + text = stringResource(R.string.event_detail_participants), + style = DesignTheme.Typography.button, + color = MaterialTheme.colorScheme.onSurface + ) + if (participants.isEmpty()) { + Text( + text = stringResource(R.string.event_detail_no_participants), + style = DesignTheme.Typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(DesignTheme.Spacing.md) + ) + } else { Column(verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md)) { - Text( - text = stringResource(R.string.event_detail_participants), - style = DesignTheme.Typography.button, - color = MaterialTheme.colorScheme.onSurface - ) - if (event.participants.isEmpty()) { - Text( - text = stringResource(R.string.event_detail_no_participants), - style = DesignTheme.Typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(DesignTheme.Spacing.md) + participants.forEachIndexed { index, participant -> + ParticipantAvatarRow( + participant = participant, + colorIndex = index, + modifier = Modifier.fillMaxWidth() ) - } else { - Column(verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md)) { - event.participants.forEachIndexed { index, participant -> - ParticipantAvatarRow( - participant = participant, - colorIndex = index, - modifier = Modifier.fillMaxWidth() - ) - } - } } } } @@ -255,7 +279,7 @@ private fun DetailRowWithIcon( icon: ImageVector, label: String, value: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Row( modifier = modifier @@ -296,7 +320,7 @@ private fun DetailRowWithIcon( private fun ParticipantAvatarRow( participant: EventParticipant, colorIndex: Int, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val tertiary = MaterialTheme.colorScheme.tertiary val error = MaterialTheme.colorScheme.error diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/events/PendingEventDetailContent.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/events/PendingEventDetailContent.kt similarity index 99% rename from composeApp/src/androidMain/kotlin/friends/mobile/events/PendingEventDetailContent.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/events/PendingEventDetailContent.kt index 60bdfab..573e449 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/events/PendingEventDetailContent.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/events/PendingEventDetailContent.kt @@ -1,4 +1,4 @@ -package friends.mobile.events +package friends.mobile.feature.events import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/feature/events/PendingEventListView.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/events/PendingEventListView.kt new file mode 100644 index 0000000..f270f39 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/events/PendingEventListView.kt @@ -0,0 +1,358 @@ +package friends.mobile.feature.events + +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.Spacer +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.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import friends.mobile.R +import friends.mobile.designsystem.components.ErrorBanner +import friends.mobile.designsystem.components.LoadingView +import friends.mobile.designsystem.theme.DesignTheme +import friends.mobile.feature.events.domain.model.Event +import friends.mobile.feature.events.presentation.pendingevents.PendingAction +import friends.mobile.feature.events.presentation.pendingevents.PendingEvent +import friends.mobile.feature.events.presentation.pendingevents.PendingEventViewModel +import friends.mobile.feature.events.presentation.pendingevents.PendingViewState +import kotlinx.coroutines.flow.collectLatest +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PendingEventListView( + onBackClick: () -> Unit, +) { + val viewModel: PendingEventViewModel = koinViewModel() + val state by viewModel.viewStates.collectAsStateWithLifecycle( + initialValue = PendingViewState.Loading + ) + val snackbarHostState = remember { SnackbarHostState() } + + val acceptedMessage = stringResource(R.string.pending_accepted_message) + val declinedMessage = stringResource(R.string.pending_declined_message) + + LaunchedEffect(viewModel) { + viewModel.viewActions.collectLatest { action -> + when (action) { + is PendingAction.ShowAcceptSuccess -> snackbarHostState.showSnackbar( + message = acceptedMessage, + duration = SnackbarDuration.Short, + ) + is PendingAction.ShowDeclineSuccess -> snackbarHostState.showSnackbar( + message = declinedMessage, + duration = SnackbarDuration.Short, + ) + is PendingAction.ShowError -> snackbarHostState.showSnackbar( + message = action.message, + duration = SnackbarDuration.Short, + ) + is PendingAction.NavigateBack -> onBackClick() + } + } + } + + val isLoading = state is PendingViewState.Loading + val errorMessage = (state as? PendingViewState.Error)?.message + val isRefreshing = (state as? PendingViewState.Content)?.isRefreshing ?: false + val pendingEvents = (state as? PendingViewState.Content)?.events ?: emptyList() + val selectedEventDetail = (state as? PendingViewState.Content)?.selectedEventDetail + val isLoadingDetail = (state as? PendingViewState.Content)?.isLoadingDetail ?: false + val detailError = (state as? PendingViewState.Content)?.detailError + + Scaffold( + topBar = { + PendingEventTopBar( + onBackClick = { viewModel.obtainEvent(PendingEvent.OnBackClick) } + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + ) { innerPadding -> + when { + isLoading && pendingEvents.isEmpty() -> { + LoadingView( + modifier = Modifier.fillMaxSize().padding(innerPadding), + message = stringResource(R.string.pending_loading) + ) + } + errorMessage != null && pendingEvents.isEmpty() -> { + PendingEventErrorContent( + message = errorMessage, + onRetry = { viewModel.obtainEvent(PendingEvent.OnRefresh) }, + modifier = Modifier.fillMaxSize().padding(innerPadding), + ) + } + else -> { + PendingEventListContent( + pendingEvents = pendingEvents, + isRefreshing = isRefreshing, + onEventClick = { viewModel.obtainEvent(PendingEvent.OnEventClick(it)) }, + modifier = Modifier.fillMaxSize().padding(innerPadding), + ) + } + } + } + + if (selectedEventDetail != null) { + ModalBottomSheet( + onDismissRequest = { viewModel.obtainEvent(PendingEvent.OnDismissDetail) }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + PendingEventDetailContent( + event = selectedEventDetail, + isLoading = isLoadingDetail, + errorMessage = detailError, + onAccept = { eventId -> viewModel.obtainEvent(PendingEvent.OnAcceptEvent(eventId)) }, + onDecline = { eventId -> viewModel.obtainEvent(PendingEvent.OnDeclineEvent(eventId)) }, + modifier = Modifier.padding(bottom = DesignTheme.Spacing.xxl) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PendingEventTopBar(onBackClick: () -> Unit) { + CenterAlignedTopAppBar( + title = { Text(stringResource(R.string.pending_title)) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.primary + ) + } + } + ) +} + +@Composable +private fun PendingEventErrorContent( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding(DesignTheme.Spacing.xl), + contentAlignment = Alignment.TopCenter, + ) { + ErrorBanner(message = message, onRetry = onRetry) + } +} + +@Composable +private fun PendingEventListContent( + pendingEvents: List, + isRefreshing: Boolean, + onEventClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + if (pendingEvents.isEmpty()) { + PendingEmptyState(modifier = Modifier.weight(1f).fillMaxWidth()) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth().weight(1f), + contentPadding = PaddingValues(DesignTheme.Spacing.lg), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm), + ) { + items(pendingEvents, key = { it.id }) { event -> + PendingEventCard( + event = event, + onClick = { onEventClick(event.id) } + ) + } + } + } + + if (isRefreshing) { + Box( + modifier = Modifier.fillMaxWidth().padding(DesignTheme.Spacing.lg), + contentAlignment = Alignment.Center, + ) { + LoadingView( + modifier = Modifier.fillMaxWidth(), + message = stringResource(R.string.loading_refreshing) + ) + } + } + } +} + +@Composable +private fun PendingEmptyState(modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(DesignTheme.Spacing.xl), + contentAlignment = Alignment.TopCenter, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg), + ) { + Icon( + imageVector = Icons.Default.Email, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs) + ) { + Text( + text = stringResource(R.string.pending_no_invitations), + style = DesignTheme.Typography.captionSemibold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.pending_all_caught_up), + style = DesignTheme.Typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun PendingEventCard( + event: Event, + onClick: () -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(DesignTheme.CornerRadius.medium)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { onClick() } + .padding(DesignTheme.Spacing.lg), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs), + ) { + PendingEventCardHeader(event = event) + HorizontalDivider( + modifier = Modifier.padding(vertical = DesignTheme.Spacing.xs), + color = MaterialTheme.colorScheme.outlineVariant, + ) + PendingEventCardFooter() + } +} + +@Composable +private fun PendingEventCardHeader(event: Event) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs), + ) { + Text( + text = event.title, + style = DesignTheme.Typography.captionSemibold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = event.date, + style = DesignTheme.Typography.bodySmallest, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Group, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = "${event.participants.size}", + style = DesignTheme.Typography.bodySmallest, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!event.time.isNullOrEmpty()) { + Text( + text = event.time!!, + style = DesignTheme.Typography.bodySmallest, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun PendingEventCardFooter() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.tertiary, + ) + Text( + text = stringResource(R.string.pending_awaiting), + style = DesignTheme.Typography.bodySmallest, + color = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendActionCallbacks.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendActionCallbacks.kt new file mode 100644 index 0000000..0456ca3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendActionCallbacks.kt @@ -0,0 +1,8 @@ +package friends.mobile.feature.friends + +internal data class FriendActionCallbacks( + val onSendRequest: (String) -> Unit, + val onAcceptRequest: (String) -> Unit, + val onRejectRequest: (String) -> Unit, + val onRemoveFriend: (String) -> Unit, +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/friends/FriendProfileScreen.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendProfileScreen.kt similarity index 94% rename from composeApp/src/androidMain/kotlin/friends/mobile/friends/FriendProfileScreen.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendProfileScreen.kt index 22d92c7..b0f5968 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/friends/FriendProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendProfileScreen.kt @@ -1,4 +1,4 @@ -package friends.mobile.friends +package friends.mobile.feature.friends import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -46,19 +46,12 @@ import friends.mobile.feature.friends.presentation.friendsProfile.FriendProfileV import friends.mobile.feature.friends.presentation.friendsProfile.FriendProfileViewState import friends.mobile.feature.friends.presentation.friendsProfile.FriendshipStatus import friends.mobile.feature.wishplaces.domain.model.WishPlace -import friends.mobile.wishplaces.WishPlaceDetailBottomSheet -import friends.mobile.wishplaces.WishPlacesMode -import friends.mobile.wishplaces.WishPlacesSection +import friends.mobile.feature.wishplaces.WishPlaceDetailBottomSheet +import friends.mobile.feature.wishplaces.WishPlacesMode +import friends.mobile.feature.wishplaces.WishPlacesSection import kotlinx.coroutines.flow.collectLatest import org.koin.androidx.compose.koinViewModel -private data class FriendActionCallbacks( - val onSendRequest: (String) -> Unit, - val onAcceptRequest: (String) -> Unit, - val onRejectRequest: (String) -> Unit, - val onRemoveFriend: (String) -> Unit, -) - @OptIn(ExperimentalMaterial3Api::class) @Composable fun FriendProfileBottomSheet( @@ -132,7 +125,6 @@ fun FriendProfileScreenContent( onAcceptRequest = { id -> viewModel.obtainEvent(FriendProfileEvent.OnAcceptRequest(id)) }, onRejectRequest = { id -> viewModel.obtainEvent(FriendProfileEvent.OnRejectRequest(id)) }, onRemoveFriend = { id -> viewModel.obtainEvent(FriendProfileEvent.OnRemoveFriend(id)) }, - onPlaceClick = { selectedPlace = it } ) } } @@ -154,7 +146,6 @@ private fun FriendProfileContent( onAcceptRequest: (String) -> Unit, onRejectRequest: (String) -> Unit, onRemoveFriend: (String) -> Unit, - onPlaceClick: (WishPlace) -> Unit ) { Column( modifier = Modifier diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/friends/FriendsComponents.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendsComponents.kt similarity index 99% rename from composeApp/src/androidMain/kotlin/friends/mobile/friends/FriendsComponents.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendsComponents.kt index 1f5be44..30b185d 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/friends/FriendsComponents.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendsComponents.kt @@ -1,4 +1,4 @@ -package friends.mobile.friends +package friends.mobile.feature.friends import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/friends/FriendsScreen.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendsScreen.kt similarity index 99% rename from composeApp/src/androidMain/kotlin/friends/mobile/friends/FriendsScreen.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendsScreen.kt index c81c56c..d90e21b 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/friends/FriendsScreen.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/friends/FriendsScreen.kt @@ -1,4 +1,4 @@ -package friends.mobile.friends +package friends.mobile.feature.friends import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/main/MainView.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/main/MainView.kt similarity index 54% rename from composeApp/src/androidMain/kotlin/friends/mobile/main/MainView.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/main/MainView.kt index f92ae73..60bc49e 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/main/MainView.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/main/MainView.kt @@ -1,4 +1,4 @@ -package friends.mobile.main +package friends.mobile.feature.main import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -25,11 +25,12 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DatePickerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -37,11 +38,12 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDefaults import androidx.compose.material3.TimePickerLayoutType +import androidx.compose.material3.TimePickerState import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope @@ -53,16 +55,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign 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 androidx.compose.ui.res.stringResource +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import friends.mobile.R -import friends.mobile.designsystem.components.ButtonFactory import friends.mobile.designsystem.components.ErrorBanner import friends.mobile.designsystem.theme.DesignTheme import friends.mobile.feature.events.domain.model.Event @@ -89,9 +88,7 @@ fun MainView( var showTimePickerDialog by rememberSaveable { mutableStateOf(false) } var showBusyAlert by rememberSaveable { mutableStateOf(false) } var isCheckingAvailability by rememberSaveable { mutableStateOf(false) } - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = System.currentTimeMillis() - ) + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = System.currentTimeMillis()) val timePickerState = rememberTimePickerState(initialHour = 12, initialMinute = 0) val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) @@ -102,203 +99,47 @@ fun MainView( val pendingEvents = (state as? MainViewState.Content)?.pendingEvents ?: emptyList() val hasNoEventsAtAll = activeEvents.isEmpty() && pendingEvents.isEmpty() - val primaryColor = MaterialTheme.colorScheme.primary - val tertiaryColor = MaterialTheme.colorScheme.tertiary - val pendingColor = Color(0xFFFF9800) - Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { Column(modifier = Modifier.fillMaxSize()) { - - // Header - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = DesignTheme.Spacing.lg) - .padding(top = DesignTheme.Spacing.xxl, bottom = DesignTheme.Spacing.md) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs)) { - Text( - text = stringResource(R.string.main_my_events), - style = DesignTheme.Typography.heading, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(R.string.main_manage_events), - style = DesignTheme.Typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Box( - modifier = Modifier - .size(46.dp) - .background(MaterialTheme.colorScheme.surfaceVariant, CircleShape), - contentAlignment = Alignment.Center - ) { - IconButton(onClick = onPendingEventsClick) { - Icon( - imageVector = Icons.Default.Notifications, - contentDescription = stringResource(R.string.main_pending_invitations), - tint = primaryColor - ) - } - } - } - } - - // Content + MainHeader(onPendingEventsClick = onPendingEventsClick) when { - isLoading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = primaryColor) - } - } - - errorMessage != null -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(DesignTheme.Spacing.lg), - contentAlignment = Alignment.Center - ) { - ErrorBanner( - message = errorMessage, - onRetry = { viewModel.obtainEvent(MainEventAction.OnRefresh) } - ) - } - } - - hasNoEventsAtAll -> { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.Default.CalendarToday, - contentDescription = null, - modifier = Modifier.size(52.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(DesignTheme.Spacing.lg)) - Text( - text = stringResource(R.string.main_no_events), - style = DesignTheme.Typography.captionSemibold, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(Modifier.height(DesignTheme.Spacing.sm)) - Text( - text = stringResource(R.string.main_create_first_event), - style = DesignTheme.Typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - else -> { - SwipeRefresh( - state = rememberSwipeRefreshState(isRefreshing), - onRefresh = { viewModel.obtainEvent(MainEventAction.OnRefresh) }, - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = DesignTheme.Spacing.lg, - end = DesignTheme.Spacing.lg, - top = DesignTheme.Spacing.md, - bottom = 100.dp - ), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm) - ) { - if (activeEvents.isNotEmpty()) { - item(key = "active_header") { - EventSectionHeader( - title = stringResource(R.string.main_section_active), - count = activeEvents.size, - tint = tertiaryColor - ) - } - items(activeEvents, key = { "active_${it.id}" }) { event -> - EventCard( - event = event, - isPending = false, - onClick = { onEventDetailClick(event.id) } - ) - } - } - - if (pendingEvents.isNotEmpty()) { - if (activeEvents.isNotEmpty()) { - item(key = "section_gap") { - Spacer(Modifier.height(DesignTheme.Spacing.lg)) - } - } - item(key = "pending_header") { - EventSectionHeader( - title = stringResource(R.string.main_section_pending), - count = pendingEvents.size, - tint = pendingColor - ) - } - items(pendingEvents, key = { "pending_${it.id}" }) { event -> - EventCard( - event = event, - isPending = true, - onClick = { onEventDetailClick(event.id) } - ) - } - } - } - } - } + isLoading -> MainLoadingContent() + errorMessage != null -> MainErrorContent( + message = errorMessage, + onRetry = { viewModel.obtainEvent(MainEventAction.OnRefresh) }, + ) + hasNoEventsAtAll -> MainEmptyState() + else -> MainEventList( + activeEvents = activeEvents, + pendingEvents = pendingEvents, + isRefreshing = isRefreshing, + onEventDetailClick = onEventDetailClick, + onRefresh = { viewModel.obtainEvent(MainEventAction.OnRefresh) }, + ) } } - // FAB bottom-trailing - FloatingActionButton( + MainFab( onClick = { showDatePickerDialog = true }, - containerColor = primaryColor, - contentColor = MaterialTheme.colorScheme.onPrimary, - shape = CircleShape, modifier = Modifier .align(Alignment.BottomEnd) .padding(end = DesignTheme.Spacing.lg, bottom = DesignTheme.Spacing.lg) - .size(58.dp) - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.main_create_event_fab) - ) - } + ) } if (showDatePickerDialog && !showTimePickerDialog) { - DatePickerDialog( - onDismissRequest = { showDatePickerDialog = false }, - confirmButton = { - Button(onClick = { - showDatePickerDialog = false - showTimePickerDialog = true - }) { Text(stringResource(R.string.next)) } + MainDatePickerDialog( + datePickerState = datePickerState, + onDismiss = { showDatePickerDialog = false }, + onConfirm = { + showDatePickerDialog = false + showTimePickerDialog = true }, - dismissButton = { - Button(onClick = { showDatePickerDialog = false }) { Text(stringResource(R.string.cancel)) } - }, - ) { - DatePicker(state = datePickerState) - } + ) } if (showTimePickerDialog) { @@ -353,16 +194,219 @@ fun MainView( } if (showBusyAlert) { - androidx.compose.material3.AlertDialog( - onDismissRequest = { showBusyAlert = false }, - title = { Text(stringResource(R.string.main_busy_day_title)) }, - confirmButton = { - Button(onClick = { showBusyAlert = false }) { Text(stringResource(R.string.ok)) } - }, + MainBusyAlert(onDismiss = { showBusyAlert = false }) + } +} + +@Composable +private fun MainHeader(onPendingEventsClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = DesignTheme.Spacing.lg) + .padding(top = DesignTheme.Spacing.xxl, bottom = DesignTheme.Spacing.md) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs)) { + Text( + text = stringResource(R.string.main_my_events), + style = DesignTheme.Typography.heading, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.main_manage_events), + style = DesignTheme.Typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Box( + modifier = Modifier + .size(46.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, CircleShape), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onPendingEventsClick) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = stringResource(R.string.main_pending_invitations), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } +} + +@Composable +private fun MainLoadingContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } +} + +@Composable +private fun MainErrorContent( + message: String, + onRetry: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(DesignTheme.Spacing.lg), + contentAlignment = Alignment.Center + ) { + ErrorBanner(message = message, onRetry = onRetry) + } +} + +@Composable +private fun MainEmptyState() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.CalendarToday, + contentDescription = null, + modifier = Modifier.size(52.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(DesignTheme.Spacing.lg)) + Text( + text = stringResource(R.string.main_no_events), + style = DesignTheme.Typography.captionSemibold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(DesignTheme.Spacing.sm)) + Text( + text = stringResource(R.string.main_create_first_event), + style = DesignTheme.Typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } +@Composable +private fun MainEventList( + activeEvents: List, + pendingEvents: List, + isRefreshing: Boolean, + onEventDetailClick: (String) -> Unit, + onRefresh: () -> Unit, +) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = DesignTheme.Spacing.lg, + end = DesignTheme.Spacing.lg, + top = DesignTheme.Spacing.md, + bottom = 100.dp + ), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm) + ) { + if (activeEvents.isNotEmpty()) { + item(key = "active_header") { + EventSectionHeader( + title = stringResource(R.string.main_section_active), + count = activeEvents.size, + tint = MaterialTheme.colorScheme.tertiary + ) + } + items(activeEvents, key = { "active_${it.id}" }) { event -> + EventCard( + event = event, + isPending = false, + onClick = { onEventDetailClick(event.id) } + ) + } + } + + if (pendingEvents.isNotEmpty()) { + if (activeEvents.isNotEmpty()) { + item(key = "section_gap") { Spacer(Modifier.height(DesignTheme.Spacing.lg)) } + } + item(key = "pending_header") { + EventSectionHeader( + title = stringResource(R.string.main_section_pending), + count = pendingEvents.size, + tint = Color(0xFFFF9800) + ) + } + items(pendingEvents, key = { "pending_${it.id}" }) { event -> + EventCard( + event = event, + isPending = true, + onClick = { onEventDetailClick(event.id) } + ) + } + } + } + } +} + +@Composable +private fun MainFab( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FloatingActionButton( + onClick = onClick, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = CircleShape, + modifier = modifier.size(58.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.main_create_event_fab) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainDatePickerDialog( + datePickerState: DatePickerState, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button(onClick = onConfirm) { Text(stringResource(R.string.next)) } + }, + dismissButton = { + Button(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) { + DatePicker(state = datePickerState) + } +} + +@Composable +private fun MainBusyAlert(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.main_busy_day_title)) }, + confirmButton = { + Button(onClick = onDismiss) { Text(stringResource(R.string.ok)) } + }, + ) +} + @Composable private fun EventSectionHeader(title: String, count: Int, tint: Color) { Row( @@ -419,32 +463,10 @@ private fun EventCard( text = event.title, style = DesignTheme.Typography.captionSemibold, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .weight(1f) - .padding(end = DesignTheme.Spacing.sm) + modifier = Modifier.weight(1f).padding(end = DesignTheme.Spacing.sm) ) - Row( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .background(tintColor.copy(alpha = 0.12f)) - .padding(horizontal = 8.dp, vertical = 5.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = badgeIcon, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = tintColor - ) - Text( - text = badgeText, - style = DesignTheme.Typography.bodySmallest.copy(fontWeight = FontWeight.SemiBold), - color = tintColor - ) - } + EventCardStatusBadge(icon = badgeIcon, text = badgeText, tintColor = tintColor) } - Row( horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm), verticalAlignment = Alignment.CenterVertically @@ -456,6 +478,30 @@ private fun EventCard( } } +@Composable +private fun EventCardStatusBadge(icon: ImageVector, text: String, tintColor: Color) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(tintColor.copy(alpha = 0.12f)) + .padding(horizontal = 8.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = tintColor + ) + Text( + text = text, + style = DesignTheme.Typography.bodySmallest.copy(fontWeight = FontWeight.SemiBold), + color = tintColor + ) + } +} + @Composable private fun EventInfoChip(icon: ImageVector, text: String) { Row( @@ -486,16 +532,16 @@ private fun DateTimePickerDialog( onDismissRequest: () -> Unit, confirmButton: @Composable () -> Unit, dismissButton: @Composable () -> Unit, - timePickerState: androidx.compose.material3.TimePickerState, + timePickerState: TimePickerState, ) { - androidx.compose.material3.AlertDialog( + AlertDialog( onDismissRequest = onDismissRequest, title = { Text(stringResource(R.string.main_select_time)) }, text = { TimePicker( state = timePickerState, layoutType = TimePickerLayoutType.Vertical, - colors = androidx.compose.material3.TimePickerDefaults.colors( + colors = TimePickerDefaults.colors( periodSelectorSelectedContainerColor = MaterialTheme.colorScheme.primaryContainer, periodSelectorSelectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/profile/EditProfileScreen.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/profile/EditProfileScreen.kt similarity index 50% rename from composeApp/src/androidMain/kotlin/friends/mobile/profile/EditProfileScreen.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/profile/EditProfileScreen.kt index e31213f..0c3fd1e 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/profile/EditProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/profile/EditProfileScreen.kt @@ -1,4 +1,4 @@ -package friends.mobile.profile +package friends.mobile.feature.profile import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,8 +24,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import friends.mobile.R import friends.mobile.designsystem.components.ButtonFactory import friends.mobile.designsystem.components.ErrorBanner @@ -64,18 +64,7 @@ fun EditProfileScreen( Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - CenterAlignedTopAppBar( - title = { Text(stringResource(R.string.profile_edit)) }, - navigationIcon = { - IconButton(onClick = { viewModel.obtainEvent(EditProfileEvent.OnBackClick) }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - tint = MaterialTheme.colorScheme.primary - ) - } - } - ) + EditProfileTopBar(onBackClick = { viewModel.obtainEvent(EditProfileEvent.OnBackClick) }) } ) { padding -> Box( @@ -87,64 +76,105 @@ fun EditProfileScreen( is EditProfileViewState.Loading -> { LoadingView(modifier = Modifier.fillMaxSize()) } - is EditProfileViewState.Content -> { - Column( - modifier = Modifier - .fillMaxSize() - .padding(DesignTheme.Spacing.lg), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg) - ) { - FormTextField( - value = currentState.username, - onValueChange = { viewModel.obtainEvent(EditProfileEvent.OnUsernameChanged(it)) }, - placeholder = stringResource(R.string.label_username), - modifier = Modifier.fillMaxWidth() - ) - FormTextField( - value = currentState.bio, - onValueChange = { viewModel.obtainEvent(EditProfileEvent.OnBioChanged(it)) }, - placeholder = stringResource(R.string.label_bio), - modifier = Modifier.fillMaxWidth() - ) - FormTextField( - value = currentState.avatarUrl, - onValueChange = { viewModel.obtainEvent(EditProfileEvent.OnAvatarUrlChanged(it)) }, - placeholder = stringResource(R.string.label_avatar_url), - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.weight(1f)) - - ButtonFactory.Primary( - text = stringResource(R.string.save), - onClick = { viewModel.obtainEvent(EditProfileEvent.OnSaveClick) }, - modifier = Modifier.fillMaxWidth(), - isLoading = currentState.isSaving, - isEnabled = !currentState.isSaving - ) - } + EditProfileContent( + state = currentState, + onUsernameChange = { viewModel.obtainEvent(EditProfileEvent.OnUsernameChanged(it)) }, + onBioChange = { viewModel.obtainEvent(EditProfileEvent.OnBioChanged(it)) }, + onAvatarUrlChange = { viewModel.obtainEvent(EditProfileEvent.OnAvatarUrlChanged(it)) }, + onSaveClick = { viewModel.obtainEvent(EditProfileEvent.OnSaveClick) }, + ) } - is EditProfileViewState.Error -> { - Column( - modifier = Modifier - .fillMaxSize() - .padding(DesignTheme.Spacing.lg), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - ErrorBanner(message = currentState.message) - ButtonFactory.Primary( - text = stringResource(R.string.go_back), - onClick = onBack, - modifier = Modifier - .fillMaxWidth() - .padding(top = DesignTheme.Spacing.lg) - ) - } + EditProfileErrorContent( + message = currentState.message, + onBack = onBack, + ) } } } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditProfileTopBar(onBackClick: () -> Unit) { + CenterAlignedTopAppBar( + title = { Text(stringResource(R.string.profile_edit)) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.primary + ) + } + } + ) +} + +@Composable +private fun EditProfileContent( + state: EditProfileViewState.Content, + onUsernameChange: (String) -> Unit, + onBioChange: (String) -> Unit, + onAvatarUrlChange: (String) -> Unit, + onSaveClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(DesignTheme.Spacing.lg), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg) + ) { + FormTextField( + value = state.username, + onValueChange = onUsernameChange, + placeholder = stringResource(R.string.label_username), + modifier = Modifier.fillMaxWidth() + ) + FormTextField( + value = state.bio, + onValueChange = onBioChange, + placeholder = stringResource(R.string.label_bio), + modifier = Modifier.fillMaxWidth() + ) + FormTextField( + value = state.avatarUrl, + onValueChange = onAvatarUrlChange, + placeholder = stringResource(R.string.label_avatar_url), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.weight(1f)) + ButtonFactory.Primary( + text = stringResource(R.string.save), + onClick = onSaveClick, + modifier = Modifier.fillMaxWidth(), + isLoading = state.isSaving, + isEnabled = !state.isSaving + ) + } +} + +@Composable +private fun EditProfileErrorContent( + message: String, + onBack: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(DesignTheme.Spacing.lg), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + ErrorBanner(message = message) + ButtonFactory.Primary( + text = stringResource(R.string.go_back), + onClick = onBack, + modifier = Modifier + .fillMaxWidth() + .padding(top = DesignTheme.Spacing.lg) + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/profile/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/profile/ProfileScreen.kt similarity index 83% rename from composeApp/src/androidMain/kotlin/friends/mobile/profile/ProfileScreen.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/profile/ProfileScreen.kt index a6a48e6..39a8714 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/profile/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/profile/ProfileScreen.kt @@ -1,4 +1,4 @@ -package friends.mobile.profile +package friends.mobile.feature.profile import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -19,7 +18,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import friends.mobile.calendar.BusyDaysView +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import friends.mobile.feature.calendar.BusyDaysView import androidx.compose.ui.res.stringResource import friends.mobile.R import friends.mobile.designsystem.components.ButtonFactory @@ -33,8 +34,8 @@ import friends.mobile.feature.profile.presentation.profile.ProfileAction import friends.mobile.feature.profile.presentation.profile.ProfileEvent import friends.mobile.feature.profile.presentation.profile.ProfileViewModel import friends.mobile.feature.profile.presentation.profile.ProfileViewState -import friends.mobile.wishplaces.WishPlacesMode -import friends.mobile.wishplaces.WishPlacesSection +import friends.mobile.feature.wishplaces.WishPlacesMode +import friends.mobile.feature.wishplaces.WishPlacesSection import kotlinx.coroutines.flow.collectLatest import org.koin.androidx.compose.koinViewModel @@ -46,12 +47,9 @@ fun ProfileScreen( viewModel: ProfileViewModel = koinViewModel(), ) { val state by viewModel.viewStates.collectAsStateWithLifecycle() + val isRefreshing = (state as? ProfileViewState.Content)?.isRefreshing ?: false val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { - viewModel.obtainEvent(ProfileEvent.OnRefreshProfile) - } - LaunchedEffect(viewModel) { viewModel.viewActions.collectLatest { action -> when (action) { @@ -77,13 +75,18 @@ fun ProfileScreen( ) } is ProfileViewState.Content -> { - ProfileContent( - profile = current.profile, - isLoggingOut = current.isLoggingOut, - onEditClick = { viewModel.obtainEvent(ProfileEvent.OnEditClick) }, - onLogoutClick = { viewModel.obtainEvent(ProfileEvent.OnLogoutClick) }, - contentPadding = innerPadding - ) + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { viewModel.obtainEvent(ProfileEvent.OnRefreshProfile) }, + modifier = Modifier.fillMaxSize().padding(innerPadding) + ) { + ProfileContent( + profile = current.profile, + isLoggingOut = current.isLoggingOut, + onEditClick = { viewModel.obtainEvent(ProfileEvent.OnEditClick) }, + onLogoutClick = { viewModel.obtainEvent(ProfileEvent.OnLogoutClick) }, + ) + } } } } @@ -95,15 +98,14 @@ private fun ProfileContent( isLoggingOut: Boolean, onEditClick: () -> Unit, onLogoutClick: () -> Unit, - contentPadding: PaddingValues, ) { LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = DesignTheme.Spacing.lg), contentPadding = PaddingValues( - top = contentPadding.calculateTopPadding() + DesignTheme.Spacing.lg, - bottom = contentPadding.calculateBottomPadding() + DesignTheme.Spacing.lg + top = DesignTheme.Spacing.lg, + bottom = DesignTheme.Spacing.lg ), verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xxl) ) { diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/wishplaces/WishPlacesComponents.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/wishplaces/WishPlacesComponents.kt similarity index 62% rename from composeApp/src/androidMain/kotlin/friends/mobile/wishplaces/WishPlacesComponents.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/wishplaces/WishPlacesComponents.kt index 665adc6..920ef1b 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/wishplaces/WishPlacesComponents.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/wishplaces/WishPlacesComponents.kt @@ -1,19 +1,25 @@ -package friends.mobile.wishplaces +package friends.mobile.feature.wishplaces -import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures 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.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -21,98 +27,155 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp import friends.mobile.R -import friends.mobile.designsystem.theme.DesignTheme import friends.mobile.designsystem.components.ButtonFactory import friends.mobile.designsystem.components.FormTextField +import friends.mobile.designsystem.theme.DesignTheme import friends.mobile.feature.wishplaces.domain.model.WishPlace +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +private const val SWIPE_DELETE_THRESHOLD = 0.4f -@OptIn(ExperimentalMaterial3Api::class) @Composable fun WishPlaceItem( place: WishPlace, onClick: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier, - enableDelete: Boolean = true + enableDelete: Boolean = true, ) { if (!enableDelete) { WishPlaceCard(place = place, onClick = onClick, modifier = modifier) - } else { - val dismissState = rememberSwipeToDismissBoxState( - positionalThreshold = { totalDistance -> totalDistance * 0.4f }, - confirmValueChange = { value -> - when (value) { - SwipeToDismissBoxValue.EndToStart -> { - onDelete() - true - } - else -> false - } - } - ) + return + } - LaunchedEffect(place.id) { - dismissState.reset() - } + val coroutineScope = rememberCoroutineScope() + val offsetX = remember { Animatable(0f) } + val collapseProgress = remember { Animatable(1f) } + var itemWidth by remember { mutableIntStateOf(1) } - SwipeToDismissBox( - state = dismissState, - enableDismissFromStartToEnd = false, - backgroundContent = { - val isDismissing = dismissState.targetValue == SwipeToDismissBoxValue.EndToStart - val color by animateColorAsState( - targetValue = if (isDismissing) MaterialTheme.colorScheme.error else Color.Transparent, - label = "delete_color" - ) - Box( - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(DesignTheme.CornerRadius.medium)) - .background(color) - .padding(horizontal = 20.dp), - contentAlignment = Alignment.CenterEnd - ) { - if (isDismissing) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.delete), - tint = Color.White - ) - } + val heightFraction = collapseProgress.value + val swipeProgress = (-offsetX.value / itemWidth).coerceIn(0f, 1f) + + Box( + modifier = modifier + .fillMaxWidth() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val collapsedHeight = (placeable.height * heightFraction).roundToInt().coerceAtLeast(0) + layout(placeable.width, collapsedHeight) { + placeable.placeRelative(0, 0) } - }, - modifier = modifier.padding(vertical = 4.dp) + } + .clipToBounds() + .onSizeChanged { size -> + if (size.width > 0) itemWidth = size.width + } + ) { + // Red delete background — always rendered, icon fades in with drag progress + Box( + modifier = Modifier + .matchParentSize() + .clip(RoundedCornerShape(DesignTheme.CornerRadius.medium)) + .background(MaterialTheme.colorScheme.error), + contentAlignment = Alignment.CenterEnd, ) { - WishPlaceCard(place = place, onClick = onClick) + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.White.copy(alpha = (swipeProgress * 3f).coerceAtMost(1f)), + modifier = Modifier + .padding(end = 20.dp) + .size(22.dp), + ) } + + // Card — draggable on top of the background + WishPlaceCard( + place = place, + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(offsetX.value.roundToInt(), 0) } + .pointerInput(place.id) { + detectHorizontalDragGestures( + onDragEnd = { + val progress = -offsetX.value / itemWidth + if (progress >= SWIPE_DELETE_THRESHOLD) { + coroutineScope.launch { + launch { + offsetX.animateTo( + targetValue = -itemWidth.toFloat(), + animationSpec = tween(durationMillis = 200, easing = FastOutLinearInEasing), + ) + } + delay(80) + collapseProgress.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + ) + onDelete() + } + } else { + coroutineScope.launch { + offsetX.animateTo( + targetValue = 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + } + } + }, + onDragCancel = { + coroutineScope.launch { + offsetX.animateTo(targetValue = 0f, animationSpec = spring()) + } + }, + onHorizontalDrag = { change, dragAmount -> + change.consume() + coroutineScope.launch { + offsetX.snapTo((offsetX.value + dragAmount).coerceAtMost(0f)) + } + }, + ) + } + ) } } @@ -120,7 +183,7 @@ fun WishPlaceItem( private fun WishPlaceCard( place: WishPlace, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Row( modifier = modifier @@ -130,32 +193,32 @@ private fun WishPlaceCard( .clickable(onClick = onClick) .padding(DesignTheme.Spacing.md), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm) + horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm), ) { Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs) + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs), ) { Text( text = place.title, style = DesignTheme.Typography.captionSemibold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) place.location?.let { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs) + horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs), ) { Icon( imageVector = Icons.Default.LocationOn, contentDescription = null, modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) Text( text = it, style = DesignTheme.Typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -167,9 +230,9 @@ private fun WishPlaceCard( modifier = Modifier .background( color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(DesignTheme.CornerRadius.capsule) + shape = RoundedCornerShape(DesignTheme.CornerRadius.capsule), ) - .padding(horizontal = DesignTheme.Spacing.sm, vertical = DesignTheme.Spacing.xs) + .padding(horizontal = DesignTheme.Spacing.sm, vertical = DesignTheme.Spacing.xs), ) } } @@ -178,7 +241,7 @@ private fun WishPlaceCard( @Composable fun CreateWishPlaceBottomSheet( onDismiss: () -> Unit, - onCreate: (String, String?, String?, String?) -> Unit + onCreate: (String, String?, String?, String?) -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var title by remember { mutableStateOf("") } @@ -191,7 +254,7 @@ fun CreateWishPlaceBottomSheet( sheetState = sheetState, dragHandle = { BottomSheetDefaults.DragHandle() }, containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - scrimColor = Color.Black.copy(alpha = 0.3f) + scrimColor = Color.Black.copy(alpha = 0.3f), ) { Column( modifier = Modifier @@ -200,7 +263,7 @@ fun CreateWishPlaceBottomSheet( .navigationBarsPadding() .imePadding() .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md) + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md), ) { Text(stringResource(R.string.wish_places_add), style = DesignTheme.Typography.heading) @@ -208,25 +271,25 @@ fun CreateWishPlaceBottomSheet( value = title, onValueChange = { title = it }, placeholder = stringResource(R.string.label_title), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) FormTextField( value = location, onValueChange = { location = it }, placeholder = stringResource(R.string.label_location), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) FormTextField( value = description, onValueChange = { description = it }, placeholder = stringResource(R.string.label_description), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) FormTextField( value = link, onValueChange = { link = it }, placeholder = stringResource(R.string.label_link), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(DesignTheme.Spacing.md)) @@ -238,11 +301,11 @@ fun CreateWishPlaceBottomSheet( title, description.ifBlank { null }, location.ifBlank { null }, - link.ifBlank { null } + link.ifBlank { null }, ) }, modifier = Modifier.fillMaxWidth(), - isEnabled = title.isNotBlank() + isEnabled = title.isNotBlank(), ) Spacer(modifier = Modifier.height(DesignTheme.Spacing.xxxl)) @@ -259,37 +322,37 @@ fun WishPlaceDetailBottomSheet(place: WishPlace, onDismiss: () -> Unit) { containerColor = MaterialTheme.colorScheme.surfaceContainerLow, shape = RoundedCornerShape( topStart = DesignTheme.CornerRadius.medium, - topEnd = DesignTheme.CornerRadius.medium - ) + topEnd = DesignTheme.CornerRadius.medium, + ), ) { Column( modifier = Modifier .fillMaxWidth() .padding(DesignTheme.Spacing.lg) .height(175.dp), - verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md) + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top + verticalAlignment = Alignment.Top, ) { Text( text = place.title, style = DesignTheme.Typography.heading, maxLines = 1, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) IconButton( onClick = onDismiss, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.close), tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(20.dp), ) } } @@ -298,19 +361,19 @@ fun WishPlaceDetailBottomSheet(place: WishPlace, onDismiss: () -> Unit) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xs), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Default.LocationOn, contentDescription = null, modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) Text( text = it, style = DesignTheme.Typography.body, color = MaterialTheme.colorScheme.onSurface, - maxLines = 1 + maxLines = 1, ) } } @@ -320,41 +383,41 @@ fun WishPlaceDetailBottomSheet(place: WishPlace, onDismiss: () -> Unit) { text = place.description!!, style = DesignTheme.Typography.body, color = MaterialTheme.colorScheme.onSurface, - maxLines = 2 + maxLines = 2, ) } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.md), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { StatusBadgeCompact(status = place.status.name) if (!place.link.isNullOrEmpty()) { - androidx.compose.material3.TextButton( + TextButton( onClick = { }, modifier = Modifier.height(28.dp), shape = RoundedCornerShape(DesignTheme.CornerRadius.capsule), - colors = androidx.compose.material3.ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, ), contentPadding = PaddingValues( horizontal = DesignTheme.Spacing.md, - vertical = DesignTheme.Spacing.xs - ) + vertical = DesignTheme.Spacing.xs, + ), ) { Icon( - imageVector = Icons.Default.OpenInNew, + imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = Color.White, - modifier = Modifier.size(12.dp) + modifier = Modifier.size(12.dp), ) Spacer(modifier = Modifier.width(DesignTheme.Spacing.xs)) Text( stringResource(R.string.label_link), style = DesignTheme.Typography.bodySmall, - color = Color.White + color = Color.White, ) } } @@ -365,7 +428,7 @@ fun WishPlaceDetailBottomSheet(place: WishPlace, onDismiss: () -> Unit) { Text( text = stringResource(R.string.wish_places_added_date, place.createdAt.take(10)), style = DesignTheme.Typography.bodySmallest, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -380,11 +443,11 @@ private fun StatusBadgeCompact(status: String) { modifier = Modifier .background( color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(DesignTheme.CornerRadius.capsule) + shape = RoundedCornerShape(DesignTheme.CornerRadius.capsule), ) .padding( horizontal = DesignTheme.Spacing.md, - vertical = DesignTheme.Spacing.xs - ) + vertical = DesignTheme.Spacing.xs, + ), ) } diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/wishplaces/WishPlacesMode.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/wishplaces/WishPlacesMode.kt similarity index 56% rename from composeApp/src/androidMain/kotlin/friends/mobile/wishplaces/WishPlacesMode.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/wishplaces/WishPlacesMode.kt index a5d2ffd..e256b83 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/wishplaces/WishPlacesMode.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/wishplaces/WishPlacesMode.kt @@ -1,6 +1,6 @@ -package friends.mobile.wishplaces +package friends.mobile.feature.wishplaces enum class WishPlacesMode { EDITABLE, READ_ONLY -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/wishplaces/WishPlacesSection.kt b/composeApp/src/androidMain/kotlin/friends/mobile/feature/wishplaces/WishPlacesSection.kt similarity index 57% rename from composeApp/src/androidMain/kotlin/friends/mobile/wishplaces/WishPlacesSection.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/feature/wishplaces/WishPlacesSection.kt index 06ec70c..a7c483c 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/wishplaces/WishPlacesSection.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/feature/wishplaces/WishPlacesSection.kt @@ -1,10 +1,9 @@ -package friends.mobile.wishplaces +package friends.mobile.feature.wishplaces import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -18,15 +17,16 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.ui.res.stringResource import friends.mobile.R import friends.mobile.designsystem.components.ErrorBanner import friends.mobile.designsystem.theme.DesignTheme @@ -58,49 +58,18 @@ fun WishPlacesSection( viewModel.viewActions.collectLatest { action -> when (action) { is WishPlacesAction.PlaceCreated -> showCreateSheet = false - is WishPlacesAction.ShowError -> { } + is WishPlacesAction.ShowError -> {} } } } Column(modifier = modifier) { if (mode == WishPlacesMode.EDITABLE) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = DesignTheme.Spacing.md), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.wish_places_my), - style = DesignTheme.Typography.heading, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - IconButton( - onClick = { showCreateSheet = true }, - modifier = Modifier.size(44.dp) - ) { - Icon( - imageVector = Icons.Default.AddCircle, - contentDescription = stringResource(R.string.wish_places_add), - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } + WishPlacesSectionHeader(onAddClick = { showCreateSheet = true }) } when (val current = state) { - is WishPlacesViewState.Loading -> { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) - } - } - + is WishPlacesViewState.Loading -> WishPlacesLoadingContent() is WishPlacesViewState.Error -> { ErrorBanner( message = current.message, @@ -108,34 +77,14 @@ fun WishPlacesSection( onRetry = { viewModel.obtainEvent(WishPlacesEvent.LoadPlaces(userId)) } ) } - is WishPlacesViewState.Content -> { - if (current.places.isEmpty()) { - Text( - text = stringResource(R.string.wish_places_empty), - style = DesignTheme.Typography.body, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } - - Column(verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm)) { - current.places.forEach { place -> - WishPlaceItem( - place = place, - onClick = { selectedPlace = place }, - onDelete = { - if (mode == WishPlacesMode.EDITABLE) { - viewModel.obtainEvent( - WishPlacesEvent.ArchivePlace(userId = userId, id = place.id) - ) - } - }, - enableDelete = mode == WishPlacesMode.EDITABLE - ) - } - } + WishPlacesContentBody( + places = current.places, + mode = mode, + userId = userId, + onPlaceClick = { selectedPlace = it }, + onDeletePlace = { viewModel.obtainEvent(WishPlacesEvent.ArchivePlace(userId = userId, id = it)) }, + ) } } } @@ -164,3 +113,73 @@ fun WishPlacesSection( ) } } + +@Composable +private fun WishPlacesSectionHeader(onAddClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = DesignTheme.Spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.wish_places_my), + style = DesignTheme.Typography.heading, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = onAddClick, + modifier = Modifier.size(44.dp) + ) { + Icon( + imageVector = Icons.Default.AddCircle, + contentDescription = stringResource(R.string.wish_places_add), + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Composable +private fun WishPlacesLoadingContent() { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } +} + +@Composable +private fun WishPlacesContentBody( + places: List, + mode: WishPlacesMode, + userId: String, + onPlaceClick: (WishPlace) -> Unit, + onDeletePlace: (String) -> Unit, +) { + if (places.isEmpty()) { + Text( + text = stringResource(R.string.wish_places_empty), + style = DesignTheme.Typography.body, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.sm)) { + places.forEach { place -> + key(place.id) { + WishPlaceItem( + place = place, + onClick = { onPlaceClick(place) }, + onDelete = { if (mode == WishPlacesMode.EDITABLE) onDeletePlace(place.id) }, + enableDelete = mode == WishPlacesMode.EDITABLE + ) + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/main/MainScreen.kt b/composeApp/src/androidMain/kotlin/friends/mobile/navigation/AppNavigation.kt similarity index 87% rename from composeApp/src/androidMain/kotlin/friends/mobile/main/MainScreen.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/navigation/AppNavigation.kt index cd059e9..25d4c4f 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/main/MainScreen.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/navigation/AppNavigation.kt @@ -1,4 +1,4 @@ -package friends.mobile.main +package friends.mobile.navigation import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween @@ -8,6 +8,7 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults @@ -17,7 +18,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.material3.MaterialTheme import androidx.compose.ui.graphics.Color import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination @@ -26,34 +26,18 @@ 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.designsystem.theme.DesignTheme -import friends.mobile.events.CreateEventView -import friends.mobile.events.EventDetailView -import friends.mobile.events.PendingEventListView -import friends.mobile.friends.FriendsScreen -import friends.mobile.profile.EditProfileScreen -import friends.mobile.profile.ProfileScreen -import kotlinx.serialization.Serializable - -@Serializable -sealed interface Screen { - @Serializable data object Home : Screen - @Serializable data class EventDetail(val eventId: String) : Screen - @Serializable data class CreateEvent(val date: String) : Screen - @Serializable data object Friends : Screen - @Serializable data object Profile : Screen - @Serializable data class ProfileEdit( - val username: String, - val bio: String?, - val avatarUrl: String? - ) : Screen - @Serializable data object PendingEvents : Screen - @Serializable data object Archive : Screen -} +import friends.mobile.feature.archive.ArchiveEventsView +import friends.mobile.feature.events.CreateEventView +import friends.mobile.feature.events.EventDetailView +import friends.mobile.feature.events.PendingEventListView +import friends.mobile.feature.friends.FriendsScreen +import friends.mobile.feature.main.MainView +import friends.mobile.feature.profile.EditProfileScreen +import friends.mobile.feature.profile.ProfileScreen @Composable -fun MainScreen( +fun AppNavigation( onLogout: () -> Unit ) { val navController = rememberNavController() @@ -131,28 +115,32 @@ fun MainScreen( startDestination = Screen.Home, modifier = Modifier.padding(innerPadding), enterTransition = { - if (isTab(initialState.destination.route) && isTab(targetState.destination.route)) + if (isTab(initialState.destination.route) && isTab(targetState.destination.route)) { fadeIn(tween(200)) - else + } else { slideInHorizontally(tween(300, easing = FastOutSlowInEasing)) { it } + } }, exitTransition = { - if (isTab(initialState.destination.route) && isTab(targetState.destination.route)) + if (isTab(initialState.destination.route) && isTab(targetState.destination.route)) { fadeOut(tween(200)) - else + } else { slideOutHorizontally(tween(300, easing = FastOutSlowInEasing)) { -it / 4 } + } }, popEnterTransition = { - if (isTab(initialState.destination.route) && isTab(targetState.destination.route)) + if (isTab(initialState.destination.route) && isTab(targetState.destination.route)) { fadeIn(tween(200)) - else + } else { slideInHorizontally(tween(300, easing = FastOutSlowInEasing)) { -it / 4 } + } }, popExitTransition = { - if (isTab(initialState.destination.route) && isTab(targetState.destination.route)) + if (isTab(initialState.destination.route) && isTab(targetState.destination.route)) { fadeOut(tween(200)) - else + } else { slideOutHorizontally(tween(300, easing = FastOutSlowInEasing)) { it } + } } ) { composable { diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/main/BottomNavItem.kt b/composeApp/src/androidMain/kotlin/friends/mobile/navigation/BottomNavItem.kt similarity index 83% rename from composeApp/src/androidMain/kotlin/friends/mobile/main/BottomNavItem.kt rename to composeApp/src/androidMain/kotlin/friends/mobile/navigation/BottomNavItem.kt index a812068..065c42f 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/main/BottomNavItem.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/navigation/BottomNavItem.kt @@ -1,12 +1,10 @@ -package friends.mobile.main +package friends.mobile.navigation import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckBox -import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Search import androidx.compose.ui.graphics.vector.ImageVector internal sealed class BottomNavItem(val route: String, val title: String, val icon: ImageVector) { diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/navigation/Screen.kt b/composeApp/src/androidMain/kotlin/friends/mobile/navigation/Screen.kt new file mode 100644 index 0000000..27b7c52 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/friends/mobile/navigation/Screen.kt @@ -0,0 +1,27 @@ +package friends.mobile.navigation + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface Screen { + + @Serializable data object Home : Screen + + @Serializable data class EventDetail(val eventId: String) : Screen + + @Serializable data class CreateEvent(val date: String) : Screen + + @Serializable data object Friends : Screen + + @Serializable data object Profile : Screen + + @Serializable data class ProfileEdit( + val username: String, + val bio: String?, + val avatarUrl: String? + ) : Screen + + @Serializable data object PendingEvents : Screen + + @Serializable data object Archive : Screen +} diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index a28f8a0..f169bae 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -96,6 +96,8 @@ Pending Accept Invitation Decline Invitation + Invitation accepted + Invitation declined Search users diff --git a/config/detekt.yml b/config/detekt.yml index 0a28729..11feb1b 100644 --- a/config/detekt.yml +++ b/config/detekt.yml @@ -1,35 +1,17 @@ build: maxIssues: 0 excludeCorrectable: false - weights: - # complexity: 2 - # LongParameterList: 1 - # style: 1 - # comments: 1 config: validation: true warningsAsErrors: false checkExhaustiveness: false - # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' excludes: '' processors: active: true exclude: - 'DetektProgressListener' - # - 'KtFileCountProcessor' - # - 'PackageCountProcessor' - # - 'ClassCountProcessor' - # - 'FunctionCountProcessor' - # - 'PropertyCountProcessor' - # - 'ProjectComplexityProcessor' - # - 'ProjectCognitiveComplexityProcessor' - # - 'ProjectLLOCProcessor' - # - 'ProjectCLOCProcessor' - # - 'ProjectLOCProcessor' - # - 'ProjectSLOCProcessor' - # - 'LicenseHeaderLoaderExtension' console-reports: active: true @@ -39,166 +21,45 @@ console-reports: - 'NotificationReport' - 'FindingsReport' - 'FileBasedFindingsReport' - # - 'LiteFindingsReport' - -output-reports: - active: true - exclude: - # - 'TxtOutputReport' - # - 'XmlOutputReport' - # - 'HtmlOutputReport' - # - 'MdOutputReport' - # - 'SarifOutputReport' comments: - active: true - AbsentOrWrongFileLicense: - active: false - licenseTemplateFile: 'license.template' - licenseTemplateIsRegex: false - CommentOverPrivateFunction: - active: false - CommentOverPrivateProperty: - active: false - DeprecatedBlockTag: - active: false - EndOfSentenceFormat: - active: false - endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' - KDocReferencesNonPublicProperty: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - OutdatedDocumentation: - active: false - matchTypeParameters: true - matchDeclarationsOrder: true - allowParamOnConstructorProperties: false - UndocumentedPublicClass: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - searchInNestedClass: true - searchInInnerClass: true - searchInInnerObject: true - searchInInnerInterface: true - searchInProtectedClass: false - UndocumentedPublicFunction: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - searchProtectedFunction: false - UndocumentedPublicProperty: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - searchProtectedProperty: false + active: false complexity: active: true CognitiveComplexMethod: active: false - threshold: 15 ComplexCondition: - active: true - threshold: 4 + active: false ComplexInterface: active: false - threshold: 10 - includeStaticDeclarations: false - includePrivateDeclarations: false - ignoreOverloaded: false CyclomaticComplexMethod: - active: true - threshold: 15 - ignoreSingleWhenExpression: true - ignoreSimpleWhenEntries: true - ignoreNestingFunctions: false - nestingFunctions: - - 'also' - - 'apply' - - 'forEach' - - 'isNotNull' - - 'ifNull' - - 'let' - - 'run' - - 'use' - - 'with' + active: false LabeledExpression: active: false - ignoredLabels: [] LargeClass: - active: true - threshold: 600 + active: false LongMethod: - active: true - threshold: 60 - ignoreAnnotated: - - Composable + active: false LongParameterList: - active: true - functionThreshold: 6 - constructorThreshold: 7 - ignoreDefaultParameters: true - ignoreDataClasses: true - ignoreAnnotatedParameter: [] + active: false MethodOverloading: active: false - threshold: 6 NamedArguments: active: false - threshold: 3 - ignoreArgumentsMatchingNames: false NestedBlockDepth: - active: true - threshold: 4 + active: false NestedScopeFunctions: active: false - threshold: 1 - functions: - - 'kotlin.apply' - - 'kotlin.run' - - 'kotlin.with' - - 'kotlin.let' - - 'kotlin.also' ReplaceSafeCallChainWithRun: active: false StringLiteralDuplication: active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - threshold: 3 - ignoreAnnotation: true - excludeStringsWithLessThan5Characters: true - ignoreStringsRegex: '$^' TooManyFunctions: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - thresholdInFiles: 11 - thresholdInClasses: 11 - thresholdInInterfaces: 11 - thresholdInObjects: 11 - thresholdInEnums: 11 - ignoreDeprecated: false - ignorePrivate: false - ignoreOverridden: false - ignoreAnnotatedFunctions: ['Preview', 'Composable'] + active: false coroutines: - active: true - GlobalCoroutineUsage: - active: false - InjectDispatcher: - active: true - dispatcherNames: - - 'IO' - - 'Default' - - 'Unconfined' - RedundantSuspendModifier: - active: true - SleepInsteadOfDelay: - active: true - SuspendFunSwallowedCancellation: - active: false - SuspendFunWithCoroutineScopeReceiver: - active: false - SuspendFunWithFlowReturnType: - active: true + active: false empty-blocks: active: true @@ -219,7 +80,7 @@ empty-blocks: active: true EmptyFunctionBlock: active: true - ignoreOverridden: false + ignoreOverridden: true EmptyIfBlock: active: true EmptyInitBlock: @@ -236,81 +97,12 @@ empty-blocks: active: true exceptions: - active: true - ExceptionRaisedInUnexpectedLocation: - active: true - methodNames: - - 'equals' - - 'finalize' - - 'hashCode' - - 'toString' - InstanceOfCheckForException: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - NotImplementedDeclaration: - active: false - ObjectExtendsThrowable: - active: false - PrintStackTrace: - active: true - RethrowCaughtException: - active: true - ReturnFromFinally: - active: true - ignoreLabeled: false - SwallowedException: - active: true - ignoredExceptionTypes: - - 'InterruptedException' - - 'MalformedURLException' - - 'NumberFormatException' - - 'ParseException' - allowedExceptionNameRegex: '_|(ignore|expected).*' - ThrowingExceptionFromFinally: - active: true - ThrowingExceptionInMain: - active: false - ThrowingExceptionsWithoutMessageOrCause: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - exceptions: - - 'ArrayIndexOutOfBoundsException' - - 'Exception' - - 'IllegalArgumentException' - - 'IllegalMonitorStateException' - - 'IllegalStateException' - - 'IndexOutOfBoundsException' - - 'NullPointerException' - - 'RuntimeException' - - 'Throwable' - ThrowingNewInstanceOfSameException: - active: true - TooGenericExceptionCaught: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/GetFriendStatusUseCase.kt'] - exceptionNames: - - 'ArrayIndexOutOfBoundsException' - - 'Error' - - 'Exception' - - 'IllegalMonitorStateException' - - 'IndexOutOfBoundsException' - - 'NullPointerException' - - 'RuntimeException' - - 'Throwable' - allowedExceptionNameRegex: '_|(ignore|expected).*' - TooGenericExceptionThrown: - active: true - exceptionNames: - - 'Error' - - 'Exception' - - 'RuntimeException' - - 'Throwable' + active: false naming: active: true BooleanPropertyNaming: active: false - allowedPattern: '^(is|has|are)' ClassNaming: active: true classPattern: '[A-Z][a-zA-Z0-9]*' @@ -324,13 +116,10 @@ naming: enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' ForbiddenClassName: active: false - forbiddenName: [] FunctionMaxLength: active: false - maximumFunctionNameLength: 30 FunctionMinLength: active: false - minimumFunctionNameLength: 3 FunctionNaming: active: true excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] @@ -348,7 +137,6 @@ naming: requireRootInDeclaration: false LambdaParameterNaming: active: false - parameterPattern: '[a-z][A-Za-z0-9]*|_' MatchingDeclarationName: active: true mustBeFirst: true @@ -359,7 +147,7 @@ naming: active: true ignoreOverridden: true NoNameShadowing: - active: true + active: false NonBooleanPropertyPrefixedWithIs: active: false ObjectPropertyNaming: @@ -377,10 +165,8 @@ naming: privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' VariableMaxLength: active: false - maximumVariableNameLength: 64 VariableMinLength: active: false - minimumVariableNameLength: 1 VariableNaming: active: true variablePattern: '[a-z][A-Za-z0-9]*' @@ -388,29 +174,12 @@ naming: excludeClassPattern: '$^' performance: - active: true - ArrayPrimitive: - active: true - CouldBeSequence: - active: false - threshold: 3 - ForEachOnRange: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - SpreadOperator: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - UnnecessaryPartOfBinaryExpression: - active: false - UnnecessaryTemporaryInstantiation: - active: true + active: false potential-bugs: active: true AvoidReferentialEquality: - active: true - forbiddenTypePatterns: - - 'kotlin.String' + active: false CastNullableToNonNullableType: active: false CastToNullableType: @@ -420,19 +189,9 @@ potential-bugs: DontDowncastCollectionTypes: active: false DoubleMutabilityForCollection: - active: true - mutableTypes: - - 'kotlin.collections.MutableList' - - 'kotlin.collections.MutableMap' - - 'kotlin.collections.MutableSet' - - 'java.util.ArrayList' - - 'java.util.LinkedHashSet' - - 'java.util.HashSet' - - 'java.util.LinkedHashMap' - - 'java.util.HashMap' + active: false ElseCaseInsteadOfExhaustiveWhen: active: false - ignoredSubjectTypes: [] EqualsAlwaysReturnsTrueOrFalse: active: true EqualsWithHashCodeExist: @@ -442,28 +201,13 @@ potential-bugs: ExplicitGarbageCollectionCall: active: true HasPlatformType: - active: true + active: false IgnoredReturnValue: - active: true - restrictToConfig: true - returnValueAnnotations: - - 'CheckResult' - - '*.CheckResult' - - 'CheckReturnValue' - - '*.CheckReturnValue' - ignoreReturnValueAnnotations: - - 'CanIgnoreReturnValue' - - '*.CanIgnoreReturnValue' - returnValueTypes: - - 'kotlin.sequences.Sequence' - - 'kotlinx.coroutines.flow.*Flow' - - 'java.util.stream.*Stream' - ignoreFunctionCall: [] + active: false ImplicitDefaultLocale: - active: true + active: false ImplicitUnitReturnType: active: false - allowExplicitReturnType: true InvalidRange: active: true IteratorHasNextCallsNextMethod: @@ -472,13 +216,10 @@ potential-bugs: active: true LateinitUsage: active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - ignoreOnClassesPattern: '' MapGetWithNotNullAssertionOperator: - active: true + active: false MissingPackageDeclaration: active: false - excludes: ['**/*.kts'] NullCheckOnMutableProperty: active: false NullableToStringCall: @@ -498,10 +239,9 @@ potential-bugs: UnreachableCode: active: true UnsafeCallOnNullableType: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + active: false UnsafeCast: - active: true + active: false UnusedUnaryOperator: active: true UselessPostfixExpression: @@ -515,41 +255,24 @@ style: active: false BracesOnIfStatements: active: false - singleLine: 'never' - multiLine: 'always' BracesOnWhenStatements: active: false - singleLine: 'necessary' - multiLine: 'consistent' CanBeNonNullable: active: false CascadingCallWrapping: active: false - includeElvis: true ClassOrdering: active: false CollapsibleIfStatements: active: false DataClassContainsFunctions: active: false - conversionFunctionPrefix: - - 'to' - allowOperators: false DataClassShouldBeImmutable: active: false DestructuringDeclarationWithTooManyEntries: - active: true - maxDestructuringEntries: 3 + active: false DoubleNegativeLambda: active: false - negativeFunctions: - - reason: 'Use `takeIf` instead.' - value: 'takeUnless' - - reason: 'Use `all` instead.' - value: 'none' - negativeFunctionNameParts: - - 'not' - - 'non' EqualsNullCall: active: true EqualsOnSignatureLine: @@ -557,107 +280,33 @@ style: ExplicitCollectionElementAccessMethod: active: false ExplicitItLambdaParameter: - active: true + active: false ExpressionBodySyntax: active: false - includeLineWrapping: false ForbiddenAnnotation: active: false - annotations: - - reason: 'it is a java annotation. Use `Suppress` instead.' - value: 'java.lang.SuppressWarnings' - - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' - value: 'java.lang.Deprecated' - - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' - value: 'java.lang.annotation.Documented' - - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' - value: 'java.lang.annotation.Target' - - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' - value: 'java.lang.annotation.Retention' - - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' - value: 'java.lang.annotation.Repeatable' - - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' - value: 'java.lang.annotation.Inherited' ForbiddenComment: - active: true - comments: - - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' - value: 'FIXME:' - - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' - value: 'STOPSHIP:' - - reason: 'Forbidden TODO todo marker in comment, please do the changes.' - value: 'TODO:' - allowedPatterns: '' + active: false ForbiddenImport: active: false - imports: [] - forbiddenPatterns: '' ForbiddenMethodCall: active: false - methods: - - reason: 'print does not allow you to configure the output stream. Use a logger instead.' - value: 'kotlin.io.print' - - reason: 'println does not allow you to configure the output stream. Use a logger instead.' - value: 'kotlin.io.println' ForbiddenSuppress: active: false - rules: [] ForbiddenVoid: - active: true - ignoreOverridden: false - ignoreUsageInGenerics: false + active: false FunctionOnlyReturningConstant: - active: true - ignoreOverridableFunction: true - ignoreActualFunction: true - excludedFunctions: [] + active: false LoopWithTooManyJumpStatements: - active: true - maxJumpCount: 1 + active: false MagicNumber: - active: true - excludes: - - '**/test/**' - - '**/androidTest/**' - - '**/commonTest/**' - - '**/jvmTest/**' - - '**/androidUnitTest/**' - - '**/androidInstrumentedTest/**' - - '**/jsTest/**' - - '**/iosTest/**' - - '**/*.kts' - - '**/designsystem/**' - - '**/model/**' - - '**/network/**' - ignoreAnnotated: - - Composable - ignoreNumbers: - - '-1' - - '0' - - '1' - - '2' - ignoreHashCodeFunction: true - ignorePropertyDeclaration: false - ignoreLocalVariableDeclaration: false - ignoreConstantDeclaration: true - ignoreCompanionObjectPropertyDeclaration: true - ignoreAnnotation: false - ignoreNamedArgument: true - ignoreEnums: false - ignoreRanges: false - ignoreExtensionFunctions: true + active: false MandatoryBracesLoops: active: false MaxChainedCallsOnSameLine: active: false - maxChainedCalls: 5 MaxLineLength: - active: true - maxLineLength: 120 - excludePackageStatements: true - excludeImportStatements: true - excludeCommentStatements: false - excludeRawStrings: true + active: false MayBeConst: active: true ModifierOrder: @@ -666,10 +315,6 @@ style: active: false MultilineRawStringIndentation: active: false - indentSize: 4 - trimmingMethods: - - 'trimIndent' - - 'trimMargin' NestedClassesVisibility: active: true NewLineAtEndOfFile: @@ -691,48 +336,33 @@ style: RedundantExplicitType: active: false RedundantHigherOrderMapUsage: - active: true + active: false RedundantVisibilityModifierRule: active: false ReturnCount: - active: true - max: 2 - excludedFunctions: - - 'equals' - excludeLabeled: false - excludeReturnFromLambda: true - excludeGuardClauses: false + active: false SafeCast: active: true SerialVersionUIDInSerializableClass: - active: true + active: false SpacingBetweenPackageAndImports: active: false StringShouldBeRawString: active: false - maxEscapedCharacterCount: 2 - ignoredCharacters: [] ThrowsCount: - active: true - max: 2 - excludeGuardClauses: false + active: false TrailingWhitespace: active: false TrimMultilineRawString: active: false - trimmingMethods: - - 'trimIndent' - - 'trimMargin' UnderscoresInNumericLiterals: active: false - acceptableLength: 4 - allowNonStandardGrouping: false UnnecessaryAbstractClass: active: true UnnecessaryAnnotationUseSiteTarget: active: false UnnecessaryApply: - active: true + active: false UnnecessaryBackticks: active: false UnnecessaryBracesAroundTrailingLambda: @@ -747,7 +377,6 @@ style: active: false UnnecessaryParentheses: active: false - allowForUnclearPrecedence: false UntilInsteadOfRangeTo: active: false UnusedImports: @@ -764,39 +393,37 @@ style: active: true allowedNames: '_|ignored|expected|serialVersionUID' UseAnyOrNoneInsteadOfFind: - active: true + active: false UseArrayLiteralsInAnnotations: - active: true + active: false UseCheckNotNull: - active: true + active: false UseCheckOrError: - active: true + active: false UseDataClass: active: false - allowVars: false UseEmptyCounterpart: active: false UseIfEmptyOrIfBlank: active: false UseIfInsteadOfWhen: active: false - ignoreWhenContainingVariableDeclaration: false UseIsNullOrEmpty: - active: true + active: false UseLet: active: false UseOrEmpty: - active: true + active: false UseRequire: - active: true + active: false UseRequireNotNull: - active: true + active: false UseSumOfInsteadOfFlatMapSize: active: false UselessCallOnNotNull: active: true UtilityClassWithPublicConstructor: - active: true + active: false VarCouldBeVal: active: true ignoreLateinitVar: false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 828517c..5735a50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,6 @@ kotlin = "2.3.0" material3 = "1.10.0-alpha05" androidx-navigation = "2.8.5" accompanist = "0.34.0" -sqlite-bundled = "2.5.1" sqldelight = "2.0.2" [libraries] diff --git a/shared/src/androidMain/kotlin/friends/mobile/core/analytics/FirebaseAnalyticsServiceImpl.kt b/shared/src/androidMain/kotlin/friends/mobile/core/analytics/FirebaseAnalyticsServiceImpl.kt index d88ce04..23944d0 100644 --- a/shared/src/androidMain/kotlin/friends/mobile/core/analytics/FirebaseAnalyticsServiceImpl.kt +++ b/shared/src/androidMain/kotlin/friends/mobile/core/analytics/FirebaseAnalyticsServiceImpl.kt @@ -6,7 +6,7 @@ import com.google.firebase.analytics.ktx.analytics import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase -class FirebaseAnalyticsServiceImpl : AnalyticsService { +internal class FirebaseAnalyticsServiceImpl : AnalyticsService { private val analytics: FirebaseAnalytics = Firebase.analytics 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 index a6a8e5b..0b7a665 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/archive/presentation/ArchiveEventsViewModel.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/archive/presentation/ArchiveEventsViewModel.kt @@ -28,19 +28,15 @@ class ArchiveEventsViewModel : BaseViewModel { viewState = ArchiveViewState.Content( archivedEvents = result.data, isRefreshing = false, ) } - is ResultWrapper.Error -> { val userError = mapApiErrorToUserFriendly(result.error) - viewState = ArchiveViewState.Error( message = getErrorMessage(userError), ) @@ -53,7 +49,6 @@ class ArchiveEventsViewModel : BaseViewModel { updateContent { it.copy( @@ -48,23 +47,19 @@ class CreateEventViewModel( ) } } - is CreateEventEvent.OnDescriptionChanged -> { updateContent { it.copy(description = event.description) } } - is CreateEventEvent.OnLocationChanged -> { updateContent { it.copy(location = event.location) } } - is CreateEventEvent.OnToggleFriend -> { toggleFriend(event.friendId) } - CreateEventEvent.OnCreateEvent -> { createEvent() } @@ -72,13 +67,9 @@ class CreateEventViewModel( } private suspend fun loadAvailableFriends() { - when (val friendsResult = checkFriendsAvailabilityUseCase(selectedDate)) { - is ResultWrapper.Success -> { - when (val availabilityResult = checkUserAvailabilityUseCase(selectedDate)) { - is ResultWrapper.Success -> { viewState = CreateEventViewState.Content( selectedDate = selectedDate, @@ -87,13 +78,11 @@ class CreateEventViewModel( isLoadingFriends = false, ) } - is ResultWrapper.Error -> { handleError(availabilityResult.error) } } } - is ResultWrapper.Error -> { handleError(friendsResult.error) } @@ -101,19 +90,14 @@ class CreateEventViewModel( } private fun toggleFriend(friendId: String) { - updateContent { currentState -> - val updatedIds = currentState.selectedFriendIds.toMutableSet() - if (friendId in updatedIds) { updatedIds.remove(friendId) } else { updatedIds.add(friendId) } - val selectedIds = updatedIds.toSet() - currentState.copy( selectedFriendIds = selectedIds, isCreateButtonEnabled = computeCreateButtonEnabled( @@ -126,39 +110,12 @@ class CreateEventViewModel( } private fun createEvent() { - val currentState = viewState as? CreateEventViewState.Content ?: return - - when { - currentState.title.isBlank() -> { - viewState = CreateEventViewState.Error( - message = "Event title is required", - ) - return - } - - currentState.selectedFriendIds.isEmpty() -> { - viewState = CreateEventViewState.Error( - message = "Please select at least one friend", - ) - return - } - - !currentState.isOwnerAvailable -> { - viewState = CreateEventViewState.Error( - message = "You are not available on this date. Please select a different date for the event.", - ) - return - } - } - updateContent { it.copy(isCreatingEvent = true) } - viewModelScope.launch { - when ( val result = createEventUseCase( title = currentState.title, @@ -169,13 +126,11 @@ class CreateEventViewModel( invitedFriendIds = currentState.selectedFriendIds.toList(), ) ) { - is ResultWrapper.Success -> { viewAction = CreateEventAction.NavigateToEventDetail( eventId = result.data, ) } - is ResultWrapper.Error -> { handleError(result.error) } @@ -185,7 +140,6 @@ class CreateEventViewModel( private fun handleError(error: ApiError) { val userError = mapApiErrorToUserFriendly(error) - viewState = CreateEventViewState.Error( message = getErrorMessage(userError), ) @@ -194,9 +148,7 @@ class CreateEventViewModel( private inline fun updateContent( transform: (CreateEventViewState.Content) -> CreateEventViewState.Content, ) { - val currentState = viewState as? CreateEventViewState.Content - ?: return - + val currentState = viewState as? CreateEventViewState.Content ?: return viewState = transform(currentState) } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/CreateEventViewState.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/createevent/CreateEventViewState.kt similarity index 92% rename from shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/CreateEventViewState.kt rename to shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/createevent/CreateEventViewState.kt index 5dfa22a..2c2ec35 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/CreateEventViewState.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/createevent/CreateEventViewState.kt @@ -1,4 +1,4 @@ -package friends.mobile.feature.events.presentation +package friends.mobile.feature.events.presentation.createevent import friends.mobile.feature.friends.domain.model.User diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingAction.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingAction.kt index de23ddf..8e3a3bb 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingAction.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingAction.kt @@ -1,6 +1,8 @@ package friends.mobile.feature.events.presentation.pendingevents sealed class PendingAction { - data class ShowMessage(val message: String) : PendingAction() + data object ShowAcceptSuccess : PendingAction() + data object ShowDeclineSuccess : PendingAction() + data class ShowError(val message: String) : PendingAction() data object NavigateBack : PendingAction() } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingEventViewModel.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingEventViewModel.kt index 3c83634..4f9a0f6 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingEventViewModel.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/events/presentation/pendingevents/PendingEventViewModel.kt @@ -28,35 +28,28 @@ class PendingEventViewModel : BaseViewModel { viewModelScope.launch { loadPendingEvents(showLoading = true) } } - PendingEvent.OnRefresh -> { viewModelScope.launch { loadPendingEvents(showLoading = false) } } - is PendingEvent.OnEventClick -> { fetchEventDetail(event.eventId) } - is PendingEvent.OnAcceptEvent -> { acceptEvent(event.eventId) } - is PendingEvent.OnDeclineEvent -> { declineEvent(event.eventId) } - PendingEvent.OnDismissDetail -> { dismissDetail() } - PendingEvent.OnBackClick -> { viewAction = PendingAction.NavigateBack } @@ -64,7 +57,6 @@ class PendingEventViewModel : BaseViewModel { viewState = PendingViewState.Content( events = result.data, ) } - is ResultWrapper.Error -> { val userError = mapApiErrorToUserFriendly(result.error) @@ -93,16 +83,13 @@ class PendingEventViewModel : BaseViewModel { updateContent { it.copy( @@ -111,10 +98,8 @@ class PendingEventViewModel : BaseViewModel { val userError = mapApiErrorToUserFriendly(result.error) - updateContent { it.copy( detailError = getErrorMessage(userError), @@ -137,26 +122,16 @@ class PendingEventViewModel : BaseViewModel { - dismissDetail() - loadPendingEvents(showLoading = false) - - viewAction = PendingAction.ShowMessage( - "Приглашение принято", - ) + viewAction = PendingAction.ShowAcceptSuccess } is ResultWrapper.Error -> { val userError = mapApiErrorToUserFriendly(result.error) - - viewAction = PendingAction.ShowMessage( - getErrorMessage(userError), - ) + viewAction = PendingAction.ShowError(getErrorMessage(userError)) } } } @@ -164,26 +139,16 @@ class PendingEventViewModel : BaseViewModel { - dismissDetail() - loadPendingEvents(showLoading = false) - - viewAction = PendingAction.ShowMessage( - "Приглашение отклонено", - ) + viewAction = PendingAction.ShowDeclineSuccess } is ResultWrapper.Error -> { val userError = mapApiErrorToUserFriendly(result.error) - - viewAction = PendingAction.ShowMessage( - getErrorMessage(userError), - ) + viewAction = PendingAction.ShowError(getErrorMessage(userError)) } } } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/friends/presentation/friendsProfile/FriendProfileEvent.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/friends/presentation/friendsProfile/FriendProfileEvent.kt index 841472f..e826c84 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/friends/presentation/friendsProfile/FriendProfileEvent.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/friends/presentation/friendsProfile/FriendProfileEvent.kt @@ -1,15 +1,5 @@ package friends.mobile.feature.friends.presentation.friendsProfile -/** - * MVI events for the friend profile screen. - * - * Events: - * - ScreenOpened: triggered when viewing a friend's profile - * - OnSendRequest: send a friend request to this user - * - OnAcceptRequest: accept an incoming friend request from this user - * - OnRejectRequest: reject an incoming friend request from this user - * - OnRemoveFriend: remove this user from friends list - */ sealed class FriendProfileEvent { data class ScreenOpened(val userId: String) : FriendProfileEvent() data class OnSendRequest(val userId: String) : FriendProfileEvent() diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/friends/presentation/friendsProfile/FriendProfileViewModel.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/friends/presentation/friendsProfile/FriendProfileViewModel.kt index 91bfc21..bd5df80 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/friends/presentation/friendsProfile/FriendProfileViewModel.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/friends/presentation/friendsProfile/FriendProfileViewModel.kt @@ -74,7 +74,6 @@ class FriendProfileViewModel : BaseViewModel { - // Re-load status to get the updated UI state loadFriendProfile(userId) } is ResultWrapper.Error -> { diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt index 687a059..ca2a08f 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/main/presentation/MainViewModel.kt @@ -42,9 +42,7 @@ class MainViewModel : BaseViewModel< private fun loadEvents(showLoading: Boolean = true) { viewModelScope.launch { if (isLoadingInProgress) return@launch - isLoadingInProgress = true - try { if (showLoading) { viewState = MainViewState.Loading @@ -56,11 +54,8 @@ class MainViewModel : BaseViewModel< } when (val activeResult = getAcceptedEventsUseCase()) { - is ResultWrapper.Success -> { - when (val pendingResult = getPendingEventsUseCase()) { - is ResultWrapper.Success -> { viewState = MainViewState.Content( activeEvents = activeResult.data, @@ -68,11 +63,9 @@ class MainViewModel : BaseViewModel< isRefreshing = false, ) } - is ResultWrapper.Error -> handleError(pendingResult.error) } } - is ResultWrapper.Error -> handleError(activeResult.error) } } finally { @@ -83,7 +76,6 @@ class MainViewModel : BaseViewModel< private fun onRefresh() { if (isLoadingInProgress) return - if (viewState is MainViewState.Content) { loadEvents(showLoading = false) } else { @@ -94,15 +86,12 @@ class MainViewModel : BaseViewModel< suspend fun checkAvailability( date: String, ): Boolean? { - return when ( val result = checkUserAvailabilityUseCase(date) ) { - is ResultWrapper.Success -> { result.data } - is ResultWrapper.Error -> { null } @@ -111,23 +100,12 @@ class MainViewModel : BaseViewModel< private fun handleError(error: ApiError) { logError(RuntimeException("MainViewModel: $error")) - val userError = mapApiErrorToUserFriendly(error) val currentState = viewState - if (currentState is MainViewState.Content) { viewState = currentState.copy(isRefreshing = false) } else { viewState = MainViewState.Error(message = getErrorMessage(userError)) } } - - private inline fun updateContent( - transform: MainViewState.Content.() -> MainViewState.Content, - ) { - val currentState = - viewState as? MainViewState.Content ?: return - - viewState = currentState.transform() - } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/domain/model/Profile.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/domain/model/Profile.kt index 4bab213..f5b4d83 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/domain/model/Profile.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/domain/model/Profile.kt @@ -1,8 +1,5 @@ package friends.mobile.feature.profile.domain.model -import kotlinx.serialization.Serializable - -@Serializable data class Profile( val id: String, val username: String, diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/edit/EditProfileViewModel.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/edit/EditProfileViewModel.kt index 0a2c041..f02f78f 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/edit/EditProfileViewModel.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/edit/EditProfileViewModel.kt @@ -29,7 +29,6 @@ class EditProfileViewModel : BaseViewModel { - // После успешного сохранения возвращаемся назад viewAction = EditProfileAction.NavigateBack } is ResultWrapper.Error -> { val userError = mapApiErrorToUserFriendly(result.error) - // Переключаем в состояние Error, как вы и просили viewState = EditProfileViewState.Error( message = getErrorMessage(userError) ) diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewModel.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewModel.kt index 684df3f..554e89f 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewModel.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/presentation/profile/ProfileViewModel.kt @@ -13,7 +13,6 @@ class ProfileViewModel : BaseViewModel { viewState = ProfileViewState.Content( profile = result.data, - isRefreshing = false + isRefreshing = false, ) } is ResultWrapper.Error -> { @@ -74,13 +70,11 @@ class ProfileViewModel : BaseViewModel( + BaseViewModel( initState = WishPlacesViewState.Loading, ), KoinComponent { @@ -30,7 +26,6 @@ class WishPlacesViewModel : is WishPlacesEvent.LoadPlaces -> { loadPlaces(event.userId) } - is WishPlacesEvent.CreatePlace -> { createPlace( userId = event.userId, @@ -40,7 +35,6 @@ class WishPlacesViewModel : link = event.link ) } - is WishPlacesEvent.ArchivePlace -> { archivePlace( userId = event.userId, @@ -52,22 +46,17 @@ class WishPlacesViewModel : private fun loadPlaces(userId: String) { viewModelScope.launch { - if (viewState !is WishPlacesViewState.Content) { viewState = WishPlacesViewState.Loading } - when (val result = getWishPlacesUseCase(userId)) { - is ResultWrapper.Success -> { viewState = WishPlacesViewState.Content( places = result.data ) } - is ResultWrapper.Error -> { val error = mapApiErrorToUserFriendly(result.error) - viewState = WishPlacesViewState.Error( getErrorMessage(error) ) @@ -84,35 +73,21 @@ class WishPlacesViewModel : link: String?, ) { viewModelScope.launch { - updateContent { it.copy(isActionPending = true) } - when ( - val result = createWishPlaceUseCase( - title, - description, - location, - link - ) + val result = createWishPlaceUseCase(title, description, location, link) ) { - is ResultWrapper.Success -> { - loadPlaces(userId) - viewAction = WishPlacesAction.PlaceCreated } - is ResultWrapper.Error -> { - val error = mapApiErrorToUserFriendly(result.error) - updateContent { it.copy(isActionPending = false) } - viewAction = WishPlacesAction.ShowError( getErrorMessage(error) ) @@ -125,37 +100,22 @@ class WishPlacesViewModel : userId: String, id: String, ) { - - val current = - viewState as? WishPlacesViewState.Content - ?: return - - val updated = - current.places.filterNot { it.id == id } - + val current = viewState as? WishPlacesViewState.Content ?: return + val updated = current.places.filterNot { it.id == id } viewState = current.copy( places = updated, isActionPending = true ) - viewModelScope.launch { - when (val result = archiveWishPlaceUseCase(id)) { - is ResultWrapper.Success -> { - updateContent { it.copy(isActionPending = false) } } - is ResultWrapper.Error -> { - loadPlaces(userId) - - val error = - mapApiErrorToUserFriendly(result.error) - + val error = mapApiErrorToUserFriendly(result.error) viewAction = WishPlacesAction.ShowError( getErrorMessage(error) ) @@ -175,97 +135,3 @@ class WishPlacesViewModel : } } } -//class WishPlacesViewModel : -// BaseViewModel( -// initState = WishPlacesViewState.Loading, -// ), -// KoinComponent { -// -// private val getWishPlacesUseCase: GetWishPlacesUseCase by inject() -// private val createWishPlaceUseCase: CreateWishPlaceUseCase by inject() -// private val archiveWishPlaceUseCase: ArchiveWishPlaceUseCase by inject() -// -// private var currentUserId: String? = null -// -// override fun obtainEvent(event: WishPlacesEvent) { -// when (event) { -// is WishPlacesEvent.LoadPlaces -> loadPlaces(event.userId) -// is WishPlacesEvent.CreatePlace -> createPlace(event.title, event.description, event.location, event.link) -// is WishPlacesEvent.ArchivePlace -> archivePlace(event.id) -// } -// } -// -// private fun loadPlaces(userId: String) { -// currentUserId = userId -// viewModelScope.launch { -// if (viewState !is WishPlacesViewState.Content) { -// viewState = WishPlacesViewState.Loading -// } else { -// updateContent { it.copy(isRefreshing = true) } -// } -// -// when (val result = getWishPlacesUseCase(userId)) { -// is ResultWrapper.Success -> { -// viewState = WishPlacesViewState.Content(places = result.data) -// } -// is ResultWrapper.Error -> { -// val userError = mapApiErrorToUserFriendly(result.error) -// if (viewState !is WishPlacesViewState.Content) { -// viewState = WishPlacesViewState.Error(getErrorMessage(userError)) -// } else { -// updateContent { it.copy(isRefreshing = false) } -// viewAction = WishPlacesAction.ShowError(getErrorMessage(userError)) -// } -// } -// } -// } -// } -// -// private fun createPlace(title: String, description: String?, location: String?, link: String?) { -// val userId = currentUserId ?: return -// viewModelScope.launch { -// updateContent { it.copy(isActionPending = true) } -// when (val result = createWishPlaceUseCase(title, description, location, link)) { -// is ResultWrapper.Success -> { -// viewAction = WishPlacesAction.PlaceCreated -// loadPlaces(userId) -// } -// is ResultWrapper.Error -> { -// val userError = mapApiErrorToUserFriendly(result.error) -// updateContent { it.copy(isActionPending = false) } -// viewAction = WishPlacesAction.ShowError(getErrorMessage(userError)) -// } -// } -// } -// } -// -// private fun archivePlace(id: String) { -// val userId = currentUserId ?: return -// val currentState = viewState as? WishPlacesViewState.Content ?: return -// -// // Оптимистичное удаление: убираем из списка сразу -// val updatedList = currentState.places.filter { it.id != id } -// viewState = currentState.copy(places = updatedList, isActionPending = true) -// -// viewModelScope.launch { -// when (val result = archiveWishPlaceUseCase(id)) { -// is ResultWrapper.Success -> { -// // Просто выключаем лоадер, список уже обновлен -// updateContent { it.copy(isActionPending = false) } -// } -// is ResultWrapper.Error -> { -// // Если ошибка — возвращаем список назад (или перезагружаем) -// val userError = mapApiErrorToUserFriendly(result.error) -// viewAction = WishPlacesAction.ShowError(getErrorMessage(userError)) -// loadPlaces(userId) -// } -// } -// } -// } -// -// private fun updateContent(transform: (WishPlacesViewState.Content) -> WishPlacesViewState.Content) { -// (viewState as? WishPlacesViewState.Content)?.let { -// viewState = transform(it) -// } -// } -//}