diff --git a/app/src/main/java/com/android/periodpals/resources/C.kt b/app/src/main/java/com/android/periodpals/resources/C.kt index e72df6662..ee7b728cc 100644 --- a/app/src/main/java/com/android/periodpals/resources/C.kt +++ b/app/src/main/java/com/android/periodpals/resources/C.kt @@ -53,6 +53,14 @@ object C { } } + object EditAlertScreen { + const val SCREEN = "editAlertScreen" + // reuse the same tags as CreateAlertScreen for product, urgency, location and message + const val DELETE_BUTTON = "deleteButton" + const val SAVE_BUTTON = "saveButton" + const val RESOLVE_BUTTON = "resolveButton" + } + /** Constants for tagging UI components in the authentication screens. */ object AuthenticationScreens { /** Constants for tagging UI components in the SignInScreen. */ diff --git a/app/src/main/java/com/android/periodpals/ui/alert/CreateAlert.kt b/app/src/main/java/com/android/periodpals/ui/alert/CreateAlert.kt index 9e2b6c064..ea949063a 100644 --- a/app/src/main/java/com/android/periodpals/ui/alert/CreateAlert.kt +++ b/app/src/main/java/com/android/periodpals/ui/alert/CreateAlert.kt @@ -1,53 +1,36 @@ package com.android.periodpals.ui.alert -import android.util.Log import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.GpsFixed -import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.focus.onFocusEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.viewmodel.compose.viewModel import com.android.periodpals.model.location.Location import com.android.periodpals.model.location.LocationViewModel import com.android.periodpals.resources.C.Tag.CreateAlertScreen import com.android.periodpals.resources.ComponentColor.getFilledPrimaryContainerButtonColors -import com.android.periodpals.resources.ComponentColor.getMenuItemColors -import com.android.periodpals.resources.ComponentColor.getMenuOutlinedTextFieldColors -import com.android.periodpals.resources.ComponentColor.getMenuTextFieldColors -import com.android.periodpals.resources.ComponentColor.getOutlinedTextFieldColors +import com.android.periodpals.ui.components.ActionButton +import com.android.periodpals.ui.components.LocationField +import com.android.periodpals.ui.components.MessageField +import com.android.periodpals.ui.components.productField +import com.android.periodpals.ui.components.urgencyField +import com.android.periodpals.ui.components.validateFields import com.android.periodpals.ui.navigation.BottomNavigationMenu import com.android.periodpals.ui.navigation.LIST_TOP_LEVEL_DESTINATION import com.android.periodpals.ui.navigation.NavigationActions @@ -56,32 +39,16 @@ import com.android.periodpals.ui.navigation.TopAppBar import com.android.periodpals.ui.theme.dimens private const val SCREEN_TITLE = "Create Alert" -private const val DEFAULT_MESSAGE = "" private const val INSTRUCTION_TEXT = "Push a notification to users near you! If they are available and have the products you need, they'll be able to help you!" -private val PRODUCT_DROPDOWN_CHOICES = listOf("Tampons", "Pads", "No Preference") -private const val PRODUCT_DROPDOWN_LABEL = "Product Needed" private const val PRODUCT_DROPDOWN_DEFAULT_VALUE = "Please choose a product" - -private val EMERGENCY_DROPDOWN_CHOICES = listOf("!!! High", "!! Medium", "! Low") -private const val EMERGENCY_DROPDOWN_LABEL = "Urgency Level" -private const val EMERGENCY_DROPDOWN_DEFAULT_VALUE = "Please choose an urgency level" - -private const val LOCATION_FIELD_LABEL = "Location" -private const val LOCATION_FIELD_PLACEHOLDER = "Enter your location" - -private const val MESSAGE_FIELD_LABEL = "Message" -private const val MESSAGE_FIELD_PLACEHOLDER = "Write a message for the other users" +private const val URGENCY_DROPDOWN_DEFAULT_VALUE = "Please choose an urgency level" +private const val DEFAULT_MESSAGE = "" private const val SUCCESSFUL_SUBMISSION_TOAST_MESSAGE = "Alert sent" private const val SUBMISSION_BUTTON_TEXT = "Ask for Help" -private const val MAX_NAME_LEN = 30 -private const val MAX_LOCATION_SUGGESTIONS = 3 - -private const val CURRENT_LOCATION_TEXT = "Current Location" - /** * Composable function for the CreateAlert screen. * @@ -89,7 +56,6 @@ private const val CURRENT_LOCATION_TEXT = "Current Location" * @param locationViewModel The location view model that provides location-related data and * functionality. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun CreateAlertScreen( navigationActions: NavigationActions, @@ -97,15 +63,7 @@ fun CreateAlertScreen( ) { val context = LocalContext.current var message by remember { mutableStateOf(DEFAULT_MESSAGE) } - val (productIsSelected, setProductIsSelected) = remember { mutableStateOf(false) } - val (urgencyIsSelected, setUrgencyIsSelected) = remember { mutableStateOf(false) } - var selectedLocation by remember { mutableStateOf(null) } - val locationQuery by locationViewModel.query.collectAsState() - - // State for dropdown visibility - var showDropdown by remember { mutableStateOf(false) } - val locationSuggestions by locationViewModel.locationSuggestions.collectAsState() // Screen Scaffold( @@ -143,139 +101,31 @@ fun CreateAlertScreen( ) // Product dropdown menu - ExposedDropdownMenuSample( - itemsList = PRODUCT_DROPDOWN_CHOICES, - label = PRODUCT_DROPDOWN_LABEL, - defaultValue = PRODUCT_DROPDOWN_DEFAULT_VALUE, - setIsSelected = setProductIsSelected, - testTag = CreateAlertScreen.PRODUCT_FIELD, - ) + val productIsSelected = + productField( + product = PRODUCT_DROPDOWN_DEFAULT_VALUE, + onValueChange = {}) // TODO: onValueChange should fill the product parameter of the + // alert // Urgency dropdown menu - ExposedDropdownMenuSample( - itemsList = EMERGENCY_DROPDOWN_CHOICES, - label = EMERGENCY_DROPDOWN_LABEL, - defaultValue = EMERGENCY_DROPDOWN_DEFAULT_VALUE, - setIsSelected = setUrgencyIsSelected, - testTag = CreateAlertScreen.URGENCY_FIELD, - ) - - // Location Input with dropdown using ExposedDropdownMenuBox - ExposedDropdownMenuBox( - expanded = showDropdown && locationSuggestions.isNotEmpty(), - onExpandedChange = { showDropdown = it }, // Toggle dropdown visibility - modifier = Modifier.wrapContentSize(), - ) { - OutlinedTextField( - modifier = - Modifier.menuAnchor() // Anchor the dropdown to this text field - .wrapContentHeight() - .fillMaxWidth() - .testTag(CreateAlertScreen.LOCATION_FIELD), - textStyle = MaterialTheme.typography.labelLarge, - value = locationQuery, - onValueChange = { - locationViewModel.setQuery(it) - showDropdown = true // Show dropdown when user starts typing - }, - label = { - Text(text = LOCATION_FIELD_LABEL, style = MaterialTheme.typography.labelMedium) - }, - placeholder = { - Text(text = LOCATION_FIELD_PLACEHOLDER, style = MaterialTheme.typography.labelMedium) - }, - singleLine = true, - colors = getMenuOutlinedTextFieldColors(), - ) - - // Dropdown menu for location suggestions - ExposedDropdownMenu( - expanded = showDropdown && locationSuggestions.isNotEmpty(), - onDismissRequest = { showDropdown = false }, - modifier = Modifier.wrapContentSize(), - containerColor = MaterialTheme.colorScheme.primaryContainer, - ) { - DropdownMenuItem( - text = { Text(CURRENT_LOCATION_TEXT) }, - onClick = { - // TODO : Logic for fetching and setting current location - showDropdown = false // For now close dropdown on selection - }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.GpsFixed, - contentDescription = "GPS icon", - modifier = Modifier.size(MaterialTheme.dimens.iconSize)) - }, - colors = getMenuItemColors(), - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - Log.d("CreateAlertScreen", "Location suggestions: $locationSuggestions") - locationSuggestions.take(MAX_LOCATION_SUGGESTIONS).forEach { location -> - DropdownMenuItem( - text = { - Text( - text = - location.name.take(MAX_NAME_LEN) + - if (location.name.length > MAX_NAME_LEN) "..." - else "", // Limit name length - maxLines = 1, // Ensure name doesn't overflow - style = MaterialTheme.typography.labelLarge) - }, - onClick = { - Log.d("CreateAlertScreen", "Selected location: ${location.name}") - locationViewModel.setQuery(location.name) - selectedLocation = location - showDropdown = false // Close dropdown on selection - }, - modifier = - Modifier.testTag(CreateAlertScreen.DROPDOWN_ITEM + location.name).semantics { - contentDescription = CreateAlertScreen.DROPDOWN_ITEM - }, - colors = getMenuItemColors(), - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } - - if (locationSuggestions.size > MAX_LOCATION_SUGGESTIONS) { - DropdownMenuItem( - text = { Text(text = "More...", style = MaterialTheme.typography.labelLarge) }, - onClick = { /* TODO show more results */}, - colors = getMenuItemColors(), - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } - } - } + val urgencyIsSelected = + urgencyField( + urgency = URGENCY_DROPDOWN_DEFAULT_VALUE, + onValueChange = {}) // TODO: onValueChange should fill the urgency parameter of the + // alert + + // Location field + LocationField( + location = selectedLocation, + locationViewModel = locationViewModel, + onLocationSelected = { selectedLocation = it }) // Message field - var isFocused by remember { mutableStateOf(false) } - OutlinedTextField( - modifier = - Modifier.fillMaxWidth() - .wrapContentHeight() - .testTag(CreateAlertScreen.MESSAGE_FIELD) - .onFocusEvent { focusState -> isFocused = focusState.isFocused }, - value = message, - onValueChange = { message = it }, - textStyle = MaterialTheme.typography.labelLarge, - label = { - Text( - text = MESSAGE_FIELD_LABEL, - style = - if (isFocused || message.isNotEmpty()) MaterialTheme.typography.labelMedium - else MaterialTheme.typography.labelLarge) - }, - placeholder = { - Text(text = MESSAGE_FIELD_PLACEHOLDER, style = MaterialTheme.typography.labelLarge) - }, - minLines = 3, - colors = getOutlinedTextFieldColors(), - ) + MessageField(text = message, onValueChange = { message = it }) // "Ask for Help" button - Button( - modifier = Modifier.wrapContentSize().testTag(CreateAlertScreen.SUBMIT_BUTTON), + ActionButton( + buttonText = SUBMISSION_BUTTON_TEXT, onClick = { val (isValid, errorMessage) = validateFields(productIsSelected, urgencyIsSelected, selectedLocation, message) @@ -288,97 +138,8 @@ fun CreateAlertScreen( } }, colors = getFilledPrimaryContainerButtonColors(), - ) { - Text(SUBMISSION_BUTTON_TEXT, style = MaterialTheme.typography.headlineMedium) - } - } - } -} - -/** - * Composable function for an exposed dropdown menu. - * - * @param itemsList The list of items to display in the dropdown menu. - * @param label The label for the dropdown menu. - * @param defaultValue The default value to display in the dropdown menu. - * @param setIsSelected A function to set the selection state. - * @param testTag The test tag for the dropdown menu. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ExposedDropdownMenuSample( - itemsList: List, - label: String, - defaultValue: String, - setIsSelected: (Boolean) -> Unit, - testTag: String, -) { - var expanded by remember { mutableStateOf(false) } - var text by remember { mutableStateOf(defaultValue) } - - ExposedDropdownMenuBox( - modifier = Modifier.wrapContentSize().testTag(testTag), - expanded = expanded, - onExpandedChange = { expanded = it }, - ) { - TextField( - modifier = Modifier.fillMaxWidth().wrapContentHeight().menuAnchor(), - textStyle = MaterialTheme.typography.labelLarge, - label = { Text(text = label, style = MaterialTheme.typography.labelMedium) }, - value = text, - onValueChange = {}, - singleLine = true, - readOnly = true, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon( - expanded = expanded, Modifier.size(MaterialTheme.dimens.iconSize)) - }, - colors = getMenuTextFieldColors(), - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.wrapContentSize(), - containerColor = MaterialTheme.colorScheme.primaryContainer, - ) { - itemsList.forEach { option -> - DropdownMenuItem( - modifier = Modifier.fillMaxWidth().testTag(CreateAlertScreen.DROPDOWN_ITEM + option), - text = { Text(text = option, style = MaterialTheme.typography.labelLarge) }, - onClick = { - text = option - expanded = false - setIsSelected(true) - }, - colors = getMenuItemColors(), - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } + testTag = CreateAlertScreen.SUBMIT_BUTTON, + ) } } } - -/** - * Validates the fields of the CreateAlert screen. - * - * @param productIsSelected Whether a product is selected. - * @param urgencyIsSelected Whether an urgency level is selected. - * @param selectedLocation The selected location. - * @param message The message entered by the user. - * @return A pair containing a boolean indicating whether the fields are valid and an error message - * if they are not. - */ -private fun validateFields( - productIsSelected: Boolean, - urgencyIsSelected: Boolean, - selectedLocation: Location?, - message: String, -): Pair { - return when { - !productIsSelected -> Pair(false, "Please select a product") - !urgencyIsSelected -> Pair(false, "Please select an urgency level") - selectedLocation == null -> Pair(false, "Please select a location") - message.isEmpty() -> Pair(false, "Please write your message") - else -> Pair(true, "") - } -} diff --git a/app/src/main/java/com/android/periodpals/ui/alert/EditAlert.kt b/app/src/main/java/com/android/periodpals/ui/alert/EditAlert.kt new file mode 100644 index 000000000..e4c34dab2 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/ui/alert/EditAlert.kt @@ -0,0 +1,188 @@ +package com.android.periodpals.ui.alert + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import com.android.periodpals.model.alert.Alert +import com.android.periodpals.model.alert.Product +import com.android.periodpals.model.alert.Status +import com.android.periodpals.model.alert.Urgency +import com.android.periodpals.model.location.Location +import com.android.periodpals.model.location.LocationViewModel +import com.android.periodpals.resources.C.Tag.CreateAlertScreen +import com.android.periodpals.resources.C.Tag.EditAlertScreen +import com.android.periodpals.resources.C.Tag.EditAlertScreen.DELETE_BUTTON +import com.android.periodpals.resources.C.Tag.EditAlertScreen.RESOLVE_BUTTON +import com.android.periodpals.resources.C.Tag.EditAlertScreen.SAVE_BUTTON +import com.android.periodpals.resources.ComponentColor.getFilledPrimaryContainerButtonColors +import com.android.periodpals.ui.components.ActionButton +import com.android.periodpals.ui.components.LocationField +import com.android.periodpals.ui.components.MessageField +import com.android.periodpals.ui.components.productField +import com.android.periodpals.ui.components.urgencyField +import com.android.periodpals.ui.components.validateFields +import com.android.periodpals.ui.navigation.NavigationActions +import com.android.periodpals.ui.navigation.Screen +import com.android.periodpals.ui.navigation.TopAppBar +import com.android.periodpals.ui.theme.dimens + +private const val SCREEN_TITLE = "Edit Your Alert" +private const val INSTRUCTION_TEXT = + "Edit, delete or resolve your push notification alert for nearby pals. You can leave a review for the sender when you resolve." + +private const val DELETE_BUTTON_TEXT = "Delete" +private const val SAVE_BUTTON_TEXT = "Save" +private const val RESOLVE_BUTTON_TEXT = "Resolve" + +private const val SUCCESSFUL_UPDATE_TOAST_MESSAGE = "Alert updated" +private const val NOT_IMPLEMENTED_YET_TOAST_MESSAGE = "This feature is not implemented yet" + +/** + * Composable function to display the Edit Alert screen. + * + * @param navigationActions Actions to handle navigation events. + * @param locationViewModel ViewModel to manage location data. + * @param alert The alert object containing the details to be edited. + */ +@Composable +fun EditAlertScreen( + navigationActions: NavigationActions, + locationViewModel: LocationViewModel, + alert: Alert = + Alert( + id = "1", + name = "User", + uid = "1", + product = Product.PAD, + urgency = Urgency.MEDIUM, + location = " ", + message = "Hello!", + status = Status.CREATED, + createdAt = ""), // TODO: remove this mock alert, for now it is used to visualize UI +) { + val context = LocalContext.current + var selectedLocation by remember { + mutableStateOf(null) + } // TODO: replace `null` with mutableStateOf(alert.location) with parsed location + var message by remember { mutableStateOf(alert.message) } + + Scaffold( + modifier = Modifier.fillMaxSize().testTag(EditAlertScreen.SCREEN), + topBar = { + TopAppBar( + title = SCREEN_TITLE, + backButton = true, + onBackButtonClick = { navigationActions.navigateTo(Screen.ALERT) }) + }, + ) { paddingValues -> + Column( + modifier = + Modifier.fillMaxSize() + .padding(paddingValues) + .padding( + horizontal = MaterialTheme.dimens.medium3, + vertical = MaterialTheme.dimens.small3, + ) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = + Arrangement.spacedBy(MaterialTheme.dimens.small2, Alignment.CenterVertically), + ) { + + // Instruction text + Text( + text = INSTRUCTION_TEXT, + modifier = Modifier.testTag(CreateAlertScreen.INSTRUCTION_TEXT), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + + // Product dropdown + val productIsSelected = + productField( + product = alert.product.name, + onValueChange = {}) // TODO: onValueChange should update the product parameter of the + // alert + + // Urgency dropdown + val urgencyIsSelected = + urgencyField( + urgency = alert.urgency.name, + onValueChange = {}) // TODO: onValueChange should update the urgency parameter of the + // alert + + // Location field + LocationField( + location = selectedLocation, + locationViewModel = locationViewModel, + onLocationSelected = { selectedLocation = it }) + + // Message field + MessageField(text = message, onValueChange = { message = it }) + + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimens.small2), + verticalAlignment = Alignment.CenterVertically, + ) { + ActionButton( + buttonText = DELETE_BUTTON_TEXT, + onClick = { + // TODO: delete alert + Toast.makeText(context, NOT_IMPLEMENTED_YET_TOAST_MESSAGE, Toast.LENGTH_SHORT).show() + navigationActions.navigateTo(Screen.ALERT_LIST) + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + testTag = DELETE_BUTTON) + + ActionButton( + buttonText = SAVE_BUTTON_TEXT, + onClick = { + val (isValid, errorMessage) = + validateFields(productIsSelected, urgencyIsSelected, selectedLocation, message) + if (!isValid) { + Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, SUCCESSFUL_UPDATE_TOAST_MESSAGE, Toast.LENGTH_SHORT).show() + // TODO: update alert using view model + navigationActions.navigateTo(Screen.ALERT_LIST) + } + }, + colors = getFilledPrimaryContainerButtonColors(), + testTag = SAVE_BUTTON) + + ActionButton( + buttonText = RESOLVE_BUTTON_TEXT, + onClick = { + // TODO: resolve alert + Toast.makeText(context, NOT_IMPLEMENTED_YET_TOAST_MESSAGE, Toast.LENGTH_SHORT).show() + navigationActions.navigateTo(Screen.ALERT_LIST) + }, + colors = getFilledPrimaryContainerButtonColors(), + testTag = RESOLVE_BUTTON) + } + } + } +} diff --git a/app/src/main/java/com/android/periodpals/ui/components/AlertComponents.kt b/app/src/main/java/com/android/periodpals/ui/components/AlertComponents.kt new file mode 100644 index 000000000..227fcff95 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/ui/components/AlertComponents.kt @@ -0,0 +1,345 @@ +package com.android.periodpals.ui.components + +import android.util.Log +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.GpsFixed +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.android.periodpals.model.location.Location +import com.android.periodpals.model.location.LocationViewModel +import com.android.periodpals.resources.C.Tag.CreateAlertScreen +import com.android.periodpals.resources.ComponentColor.getMenuItemColors +import com.android.periodpals.resources.ComponentColor.getMenuOutlinedTextFieldColors +import com.android.periodpals.resources.ComponentColor.getMenuTextFieldColors +import com.android.periodpals.resources.ComponentColor.getOutlinedTextFieldColors +import com.android.periodpals.ui.theme.dimens + +private val PRODUCT_DROPDOWN_CHOICES = listOf("Tampons", "Pads", "No Preference") +private const val PRODUCT_DROPDOWN_LABEL = "Product Needed" + +private val URGENCY_DROPDOWN_CHOICES = listOf("!!! High", "!! Medium", "! Low") +private const val URGENCY_DROPDOWN_LABEL = "Urgency Level" + +private const val LOCATION_FIELD_LABEL = "Location" +private const val LOCATION_FIELD_PLACEHOLDER = "Enter your location" + +private const val MESSAGE_FIELD_LABEL = "Message" +private const val MESSAGE_FIELD_PLACEHOLDER = "Write a message for the other users" + +private const val MAX_NAME_LEN = 30 +private const val MAX_LOCATION_SUGGESTIONS = 3 + +private const val CURRENT_LOCATION_TEXT = "Current Location" + +/** + * Composable function for displaying a product selection dropdown menu. + * + * @param product The default product value to display in the dropdown menu. + * @param onValueChange A callback function to handle the change in the selected product value. + * @return A boolean indicating whether a product is selected. + */ +@Composable +fun productField(product: String, onValueChange: (String) -> Unit): Boolean { + val (productIsSelected, setProductIsSelected) = remember { mutableStateOf(false) } + ExposedDropdownMenuSample( + itemsList = PRODUCT_DROPDOWN_CHOICES, + label = PRODUCT_DROPDOWN_LABEL, + defaultValue = product, + setIsSelected = setProductIsSelected, + onValueChange = onValueChange, // TODO: fill product value in alert + testTag = CreateAlertScreen.PRODUCT_FIELD, + ) + return productIsSelected +} + +/** + * Composable function for displaying an urgency selection dropdown menu. + * + * @param urgency The default urgency value to display in the dropdown menu. + * @param onValueChange A callback function to handle the change in the selected urgency value. + * @return A boolean indicating whether an urgency level is selected. + */ +@Composable +fun urgencyField(urgency: String, onValueChange: (String) -> Unit): Boolean { + val (urgencyIsSelected, setUrgencyIsSelected) = remember { mutableStateOf(false) } + ExposedDropdownMenuSample( + itemsList = URGENCY_DROPDOWN_CHOICES, + label = URGENCY_DROPDOWN_LABEL, + defaultValue = urgency, + setIsSelected = setUrgencyIsSelected, + onValueChange = onValueChange, // TODO: fill urgency value in alert + testTag = CreateAlertScreen.URGENCY_FIELD, + ) + return urgencyIsSelected +} + +/** + * Composable function for displaying a location selection field with dropdown menu. + * + * @param location The selected location. + * @param locationViewModel The view model for location suggestions. + * @param onLocationSelected A callback function to handle the selected location. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocationField( + location: Location?, + locationViewModel: LocationViewModel, + onLocationSelected: (Location) -> Unit +) { + val locationSuggestions by locationViewModel.locationSuggestions.collectAsState() + var name by remember { mutableStateOf(location?.name ?: "") } + + // State for dropdown visibility + var showDropdown by remember { mutableStateOf(false) } + + // Location Input with dropdown using ExposedDropdownMenuBox + ExposedDropdownMenuBox( + expanded = showDropdown && locationSuggestions.isNotEmpty(), + onExpandedChange = { showDropdown = it }, // Toggle dropdown visibility + modifier = Modifier.wrapContentSize(), + ) { + OutlinedTextField( + modifier = + Modifier.menuAnchor() // Anchor the dropdown to this text field + .wrapContentHeight() + .fillMaxWidth() + .testTag(CreateAlertScreen.LOCATION_FIELD), + textStyle = MaterialTheme.typography.labelLarge, + value = name, + onValueChange = { + name = it + locationViewModel.setQuery(it) + showDropdown = true // Show dropdown when user starts typing + }, + label = { Text(text = LOCATION_FIELD_LABEL, style = MaterialTheme.typography.labelMedium) }, + placeholder = { + Text(text = LOCATION_FIELD_PLACEHOLDER, style = MaterialTheme.typography.labelMedium) + }, + singleLine = true, + colors = getMenuOutlinedTextFieldColors(), + ) + + // Dropdown menu for location suggestions + ExposedDropdownMenu( + expanded = showDropdown && locationSuggestions.isNotEmpty(), + onDismissRequest = { showDropdown = false }, + modifier = Modifier.wrapContentSize(), + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) { + DropdownMenuItem( + text = { Text(CURRENT_LOCATION_TEXT) }, + onClick = { + // TODO : Logic for fetching and setting current location + showDropdown = false // For now close dropdown on selection + }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.GpsFixed, + contentDescription = "GPS icon", + modifier = Modifier.size(MaterialTheme.dimens.iconSize)) + }, + colors = getMenuItemColors(), + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + Log.d("CreateAlertScreen", "Location suggestions: $locationSuggestions") + locationSuggestions.take(MAX_LOCATION_SUGGESTIONS).forEach { location -> + DropdownMenuItem( + text = { + Text( + text = + location.name.take(MAX_NAME_LEN) + + if (location.name.length > MAX_NAME_LEN) "..." + else "", // Limit name length + maxLines = 1, // Ensure name doesn't overflow + style = MaterialTheme.typography.labelLarge) + }, + onClick = { + Log.d("CreateAlertScreen", "Selected location: ${location.name}") + locationViewModel.setQuery(location.name) + name = location.name + onLocationSelected(location) + showDropdown = false // Close dropdown on selection + }, + modifier = + Modifier.testTag(CreateAlertScreen.DROPDOWN_ITEM + location.name).semantics { + contentDescription = CreateAlertScreen.DROPDOWN_ITEM + }, + colors = getMenuItemColors(), + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + + if (locationSuggestions.size > MAX_LOCATION_SUGGESTIONS) { + DropdownMenuItem( + text = { Text(text = "More...", style = MaterialTheme.typography.labelLarge) }, + onClick = { /* TODO show more results */}, + colors = getMenuItemColors(), + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} + +/** + * Composable function for displaying a message field. + * + * @param text The text to display in the message field. + * @param onValueChange A callback function to handle the change in the message field. + */ +@Composable +fun MessageField(text: String, onValueChange: (String) -> Unit) { + var isFocused by remember { mutableStateOf(false) } + OutlinedTextField( + modifier = + Modifier.fillMaxWidth() + .wrapContentHeight() + .testTag(CreateAlertScreen.MESSAGE_FIELD) + .onFocusEvent { focusState -> isFocused = focusState.isFocused }, + value = text, + onValueChange = onValueChange, + textStyle = MaterialTheme.typography.labelLarge, + label = { + Text( + text = MESSAGE_FIELD_LABEL, + style = + if (isFocused || text.isNotEmpty()) MaterialTheme.typography.labelMedium + else MaterialTheme.typography.labelLarge) + }, + placeholder = { + Text(text = MESSAGE_FIELD_PLACEHOLDER, style = MaterialTheme.typography.labelLarge) + }, + minLines = 3, + colors = getOutlinedTextFieldColors(), + ) +} + +/** + * Composable function for displaying an action button. + * + * @param buttonText The text to display on the button. + * @param onClick The callback function to handle button clicks. + * @param colors The colors to apply to the button. + * @param testTag The test tag for the button. + */ +@Composable +fun ActionButton(buttonText: String, onClick: () -> Unit, colors: ButtonColors, testTag: String) { + Button( + onClick = onClick, modifier = Modifier.wrapContentSize().testTag(testTag), colors = colors) { + Text(text = buttonText, style = MaterialTheme.typography.headlineSmall) + } +} + +/** + * Composable function for an exposed dropdown menu. + * + * @param itemsList The list of items to display in the dropdown menu. + * @param label The label for the dropdown menu. + * @param defaultValue The default value to display in the dropdown menu. + * @param setIsSelected A function to set the selection state. + * @param testTag The test tag for the dropdown menu. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ExposedDropdownMenuSample( + itemsList: List, + label: String, + defaultValue: String, + setIsSelected: (Boolean) -> Unit, + onValueChange: (String) -> Unit, // fill Alert values + testTag: String, +) { + var expanded by remember { mutableStateOf(false) } + var text by remember { mutableStateOf(defaultValue) } + + ExposedDropdownMenuBox( + modifier = Modifier.wrapContentSize().testTag(testTag), + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextField( + modifier = Modifier.fillMaxWidth().wrapContentHeight().menuAnchor(), + textStyle = MaterialTheme.typography.labelLarge, + label = { Text(text = label, style = MaterialTheme.typography.labelMedium) }, + value = text, + onValueChange = onValueChange, + singleLine = true, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded, Modifier.size(MaterialTheme.dimens.iconSize)) + }, + colors = getMenuTextFieldColors(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.wrapContentSize(), + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) { + itemsList.forEach { option -> + DropdownMenuItem( + modifier = Modifier.fillMaxWidth().testTag(CreateAlertScreen.DROPDOWN_ITEM + option), + text = { Text(text = option, style = MaterialTheme.typography.labelLarge) }, + onClick = { + text = option + expanded = false + setIsSelected(true) + }, + colors = getMenuItemColors(), + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} + +/** + * Validates the fields of the CreateAlert screen. + * + * @param productIsSelected Whether a product is selected. + * @param urgencyIsSelected Whether an urgency level is selected. + * @param selectedLocation The selected location. + * @param message The message entered by the user. + * @return A pair containing a boolean indicating whether the fields are valid and an error message + * if they are not. + */ +fun validateFields( + productIsSelected: Boolean, + urgencyIsSelected: Boolean, + selectedLocation: Location?, + message: String, +): Pair { + return when { + !productIsSelected -> Pair(false, "Please select a product") + !urgencyIsSelected -> Pair(false, "Please select an urgency level") + selectedLocation == null -> Pair(false, "Please select a location") + message.isEmpty() -> Pair(false, "Please write your message") + else -> Pair(true, "") + } +} diff --git a/app/src/test/java/com/android/periodpals/ui/alert/EditAlertScreenTest.kt b/app/src/test/java/com/android/periodpals/ui/alert/EditAlertScreenTest.kt new file mode 100644 index 000000000..3900f2312 --- /dev/null +++ b/app/src/test/java/com/android/periodpals/ui/alert/EditAlertScreenTest.kt @@ -0,0 +1,241 @@ +package com.android.periodpals.ui.alert + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import com.android.periodpals.model.alert.Alert +import com.android.periodpals.model.alert.Product +import com.android.periodpals.model.alert.Urgency +import com.android.periodpals.model.location.Location +import com.android.periodpals.model.location.LocationViewModel +import com.android.periodpals.resources.C.Tag +import com.android.periodpals.resources.C.Tag.BottomNavigationMenu +import com.android.periodpals.resources.C.Tag.TopAppBar +import com.android.periodpals.ui.navigation.NavigationActions +import com.android.periodpals.ui.navigation.Route +import com.android.periodpals.ui.navigation.Screen +import com.android.periodpals.ui.navigation.TopLevelDestination +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class EditAlertScreenTest { + private lateinit var navigationActions: NavigationActions + private lateinit var locationViewModel: LocationViewModel + private lateinit var alert: Alert + @get:Rule val composeTestRule = createComposeRule() + + companion object { + private const val PRODUCT = "Pads" + private const val URGENCY = "!! Medium" + private const val LOCATION = "Lausanne" + private val LOCATION_SUGGESTION1 = + Location(46.5218269, 6.6327025, "Lausanne, District de Lausanne") + private val LOCATION_SUGGESTION2 = Location(46.2017559, 6.1466014, "Geneva, Switzerland") + private val LOCATION_SUGGESTION3 = Location(46.1683026, 5.9059776, "Farges, Gex, Ain") + private const val MESSAGE = "I need help finding a tampon" + private const val DELETE_BUTTON_TEXT = "Delete" + private const val SAVE_BUTTON_TEXT = "Save" + private const val RESOLVE_BUTTON_TEXT = "Resolve" + } + + @Before + fun setUp() { + navigationActions = mock(NavigationActions::class.java) + locationViewModel = mock(LocationViewModel::class.java) + alert = mock(Alert::class.java) + + // Set up initial state for the alert object + `when`(alert.product).thenReturn(Product.TAMPON) + `when`(alert.urgency).thenReturn(Urgency.HIGH) + `when`(alert.message).thenReturn("hello") + `when`(alert.location).thenReturn("Initial location") + + `when`(navigationActions.currentRoute()).thenReturn(Route.ALERT_LIST) + `when`(locationViewModel.locationSuggestions) + .thenReturn( + MutableStateFlow( + listOf(LOCATION_SUGGESTION1, LOCATION_SUGGESTION2, LOCATION_SUGGESTION3))) + `when`(locationViewModel.query).thenReturn(MutableStateFlow(LOCATION_SUGGESTION1.name)) + } + + @Test + fun allComponentsAreDisplayed() { + composeTestRule.setContent { EditAlertScreen(navigationActions, locationViewModel, alert) } + + composeTestRule.onNodeWithTag(Tag.EditAlertScreen.SCREEN).assertIsDisplayed() + composeTestRule.onNodeWithTag(TopAppBar.TOP_BAR).assertIsDisplayed() + composeTestRule + .onNodeWithTag(TopAppBar.TITLE_TEXT) + .assertIsDisplayed() + .assertTextEquals("Edit Your Alert") + composeTestRule.onNodeWithTag(TopAppBar.GO_BACK_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TopAppBar.EDIT_BUTTON).assertIsNotDisplayed() + composeTestRule + .onNodeWithTag(BottomNavigationMenu.BOTTOM_NAVIGATION_MENU) + .assertIsNotDisplayed() + + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.INSTRUCTION_TEXT) + .performScrollTo() + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.PRODUCT_FIELD) + .performScrollTo() + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.URGENCY_FIELD) + .performScrollTo() + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.LOCATION_FIELD) + .performScrollTo() + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.MESSAGE_FIELD) + .performScrollTo() + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(Tag.EditAlertScreen.DELETE_BUTTON) + .performScrollTo() + .assertIsDisplayed() + .assertTextEquals(DELETE_BUTTON_TEXT) + composeTestRule + .onNodeWithTag(Tag.EditAlertScreen.SAVE_BUTTON) + .performScrollTo() + .assertIsDisplayed() + .assertTextEquals(SAVE_BUTTON_TEXT) + composeTestRule + .onNodeWithTag(Tag.EditAlertScreen.RESOLVE_BUTTON) + .performScrollTo() + .assertIsDisplayed() + .assertTextEquals(RESOLVE_BUTTON_TEXT) + } + + @Test + fun updateAlertSuccessful() { + composeTestRule.setContent { EditAlertScreen(navigationActions, locationViewModel, alert) } + + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.PRODUCT_FIELD) + .performScrollTo() + .performClick() + composeTestRule.onNodeWithText(PRODUCT).performScrollTo().performClick() + + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.URGENCY_FIELD) + .performScrollTo() + .performClick() + composeTestRule.onNodeWithText(URGENCY).performScrollTo().performClick() + + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.LOCATION_FIELD) + .performScrollTo() + .performTextClearance() + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.LOCATION_FIELD) + .performScrollTo() + .performTextInput(LOCATION) + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.DROPDOWN_ITEM + LOCATION_SUGGESTION1.name) + .performScrollTo() + .performClick() + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.LOCATION_FIELD) + .performScrollTo() + .assertTextContains(LOCATION_SUGGESTION1.name) + + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.MESSAGE_FIELD) + .performScrollTo() + .performTextClearance() + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.MESSAGE_FIELD) + .performScrollTo() + .performTextInput(MESSAGE) + + composeTestRule.onNodeWithTag(Tag.EditAlertScreen.SAVE_BUTTON).performScrollTo().performClick() + verify(navigationActions).navigateTo(Screen.ALERT_LIST) + } + + @Test + fun updateAlertInvalidLocation() { + composeTestRule.setContent { EditAlertScreen(navigationActions, locationViewModel, alert) } + + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.LOCATION_FIELD) + .performScrollTo() + .performTextClearance() + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.LOCATION_FIELD) + .performScrollTo() + .performTextInput("") + + composeTestRule + .onNodeWithTag(Tag.EditAlertScreen.SAVE_BUTTON) + .performScrollTo() + .assertIsDisplayed() + .performClick() + verify(navigationActions, never()).navigateTo(any()) + verify(navigationActions, never()).navigateTo(any()) + } + + @Test + fun updateAlertInvalidMessage() { + composeTestRule.setContent { EditAlertScreen(navigationActions, locationViewModel, alert) } + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.MESSAGE_FIELD) + .performScrollTo() + .performTextClearance() + composeTestRule + .onNodeWithTag(Tag.CreateAlertScreen.MESSAGE_FIELD) + .performScrollTo() + .performTextInput("") + composeTestRule + .onNodeWithTag(Tag.EditAlertScreen.SAVE_BUTTON) + .performScrollTo() + .assertIsDisplayed() + .performClick() + verify(navigationActions, never()).navigateTo(any()) + verify(navigationActions, never()).navigateTo(any()) + } + + @Test + fun deleteAlertSuccessfully() { + composeTestRule.setContent { EditAlertScreen(navigationActions, locationViewModel, alert) } + + composeTestRule + .onNodeWithTag(Tag.EditAlertScreen.DELETE_BUTTON) + .performScrollTo() + .performClick() + verify(navigationActions).navigateTo(Screen.ALERT_LIST) + } + + @Test + fun resolveAlertSuccessfully() { + composeTestRule.setContent { EditAlertScreen(navigationActions, locationViewModel, alert) } + + composeTestRule + .onNodeWithTag(Tag.EditAlertScreen.RESOLVE_BUTTON) + .performScrollTo() + .performClick() + verify(navigationActions).navigateTo(Screen.ALERT_LIST) + } +}