diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/favorites/NotificationSettingsWidgetTest.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/favorites/NotificationSettingsWidgetTest.kt index a13117e88..2a7c4ef64 100644 --- a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/favorites/NotificationSettingsWidgetTest.kt +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/favorites/NotificationSettingsWidgetTest.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.isOff +import androidx.compose.ui.test.isOn import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText @@ -28,17 +30,15 @@ import com.google.accompanist.permissions.PermissionStatus import com.mbta.tid.mbta_app.android.loadKoinMocks import com.mbta.tid.mbta_app.android.testUtils.assertCanBeDisplayed import com.mbta.tid.mbta_app.android.testUtils.hasTextMatching -import com.mbta.tid.mbta_app.android.testUtils.waitUntilDefaultTimeout import com.mbta.tid.mbta_app.android.testUtils.waitUntilExactlyOneExistsDefaultTimeout import com.mbta.tid.mbta_app.android.util.ConstantPermissionState import com.mbta.tid.mbta_app.model.FavoriteSettings +import com.mbta.tid.mbta_app.repositories.MockSentryRepository import com.mbta.tid.mbta_app.repositories.MockSettingsRepository import com.mbta.tid.mbta_app.repositories.Settings import com.mbta.tid.mbta_app.utils.EasternTimeInstant -import kotlin.test.assertEquals -import kotlinx.datetime.DayOfWeek +import com.mbta.tid.mbta_app.viewModel.NotificationSettingsViewModel import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.LocalTime import org.junit.Rule import org.junit.Test import org.koin.test.KoinTest @@ -62,20 +62,18 @@ class NotificationSettingsWidgetTest : KoinTest { ) } - lateinit var settings: MutableState + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) + composeTestRule.setContent { - settings = remember { mutableStateOf(FavoriteSettings.Notifications.disabled) } - var settings by settings NotificationSettingsWidget( - settings, - setSettings = { settings = it }, + viewModel = viewModel, notificationPermissionState = permissionGranted, hasRequestedPermission = true, ) } composeTestRule.onNodeWithText("Get disruption notifications").performClick() - composeTestRule.waitUntilDefaultTimeout { 1 == settings.value.windows.size } composeTestRule.waitUntilExactlyOneExistsDefaultTimeout( (hasTextMatching(Regex("8:00\\sAM", RegexOption.IGNORE_CASE))) ) @@ -91,7 +89,6 @@ class NotificationSettingsWidgetTest : KoinTest { composeTestRule.onNodeWithText("Saturday").assertIsOff() composeTestRule.onNodeWithContentDescription("Delete").assertDoesNotExist() composeTestRule.onNodeWithText("Add another time period").performClick() - assertEquals(2, settings.value.windows.size) composeTestRule.waitUntilExactlyOneExistsDefaultTimeout( (hasTextMatching(Regex("12:00\\sPM", RegexOption.IGNORE_CASE))) ) @@ -117,13 +114,12 @@ class NotificationSettingsWidgetTest : KoinTest { ) } - lateinit var settings: MutableState + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) + composeTestRule.setContent { - settings = remember { mutableStateOf(FavoriteSettings.Notifications.disabled) } - var settings by settings NotificationSettingsWidget( - settings, - setSettings = { settings = it }, + viewModel, notificationPermissionState = permissionGranted, hasRequestedPermission = true, ) @@ -145,7 +141,9 @@ class NotificationSettingsWidgetTest : KoinTest { composeTestRule.onNodeWithContentDescription("Select minutes").performClick() composeTestRule.onNodeWithContentDescription("45 minutes").performClick() composeTestRule.onNodeWithText("Okay").performClick() - assertEquals(LocalTime(7, 45), settings.value.windows.single().startTime) + composeTestRule.waitUntilExactlyOneExistsDefaultTimeout( + hasTextMatching(Regex("7:45\\sAM", RegexOption.IGNORE_CASE)) + ) composeTestRule .onNode(hasTextMatching(Regex("9:00\\sAM", RegexOption.IGNORE_CASE))) .performClick() @@ -154,48 +152,51 @@ class NotificationSettingsWidgetTest : KoinTest { composeTestRule.onNodeWithContentDescription("for hour").performTextReplacement("9") composeTestRule.onNodeWithContentDescription("for minutes").performTextReplacement("10") composeTestRule.onNodeWithText("Okay").performClick() - assertEquals(LocalTime(9, 10), settings.value.windows.single().endTime) + composeTestRule.waitUntilExactlyOneExistsDefaultTimeout( + hasTextMatching(Regex("9:10\\sAM", RegexOption.IGNORE_CASE)) + ) } @Test fun testChangeDays() { - lateinit var settings: MutableState + val now = EasternTimeInstant(LocalDateTime(2026, 8, 27, 12, 30, 0)) + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) + composeTestRule.setContent { - settings = remember { mutableStateOf(FavoriteSettings.Notifications.disabled) } - var settings by settings NotificationSettingsWidget( - settings, - setSettings = { settings = it }, + viewModel = viewModel, notificationPermissionState = permissionGranted, hasRequestedPermission = true, + now = now, ) } composeTestRule.onNodeWithText("Get disruption notifications").performClick() + composeTestRule.waitUntilExactlyOneExistsDefaultTimeout( + hasText("Get disruption notifications").and(isEnabled()) + ) composeTestRule.onNodeWithText("Sunday").performClick() + composeTestRule.waitUntilExactlyOneExistsDefaultTimeout(hasText("Sunday").and(isOn())) composeTestRule.onNodeWithText("Wednesday").performClick() - composeTestRule.onNodeWithText("Friday").performClick() - assertEquals( - setOf(DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.THURSDAY), - settings.value.windows.single().daysOfWeek, - ) + composeTestRule.waitUntilExactlyOneExistsDefaultTimeout(hasText("Wednesday").and(isOff())) } @Test - fun testValidatesTime() { + fun testValidatesStartTime() { loadKoinMocks { settings = MockSettingsRepository( settings = mapOf(Settings.NotificationPresetWindows to false) ) } - lateinit var settings: MutableState + + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) + composeTestRule.setContent { - settings = remember { mutableStateOf(FavoriteSettings.Notifications.disabled) } - var settings by settings NotificationSettingsWidget( - settings, - setSettings = { settings = it }, + viewModel, notificationPermissionState = permissionGranted, hasRequestedPermission = true, ) @@ -215,12 +216,45 @@ class NotificationSettingsWidgetTest : KoinTest { composeTestRule.onNodeWithContentDescription("Select minutes").performClick() composeTestRule.onNodeWithContentDescription("45 minutes").performClick() composeTestRule.onNodeWithText("Okay").performClick() - assertEquals(LocalTime(10, 45), settings.value.windows.single().startTime) - assertEquals(LocalTime(11, 45), settings.value.windows.single().endTime) + composeTestRule.waitUntilExactlyOneExistsDefaultTimeout( + hasTextMatching(Regex("10:45\\sAM", RegexOption.IGNORE_CASE)) + ) + composeTestRule.waitUntilExactlyOneExistsDefaultTimeout( + hasTextMatching(Regex("11:45\\sAM", RegexOption.IGNORE_CASE)) + ) + } + + @Test + fun testValidatesEndTime() { + loadKoinMocks { + settings = + MockSettingsRepository( + settings = mapOf(Settings.NotificationPresetWindows to false) + ) + } + + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) + + composeTestRule.setContent { + NotificationSettingsWidget( + viewModel, + notificationPermissionState = permissionGranted, + hasRequestedPermission = true, + ) + } + + composeTestRule.onNodeWithText("Get disruption notifications").performClick() + composeTestRule.waitUntilExactlyOneExistsDefaultTimeout( + hasText("Get disruption notifications").and(isEnabled()) + ) + composeTestRule.waitUntilExactlyOneExistsDefaultTimeout( + hasTextMatching(Regex("8:00\\sAM", RegexOption.IGNORE_CASE)) + ) composeTestRule - .onNode(hasTextMatching(Regex("11:45\\sAM", RegexOption.IGNORE_CASE))) + .onNode(hasTextMatching(Regex("9:00\\sAM", RegexOption.IGNORE_CASE))) .performClick() - composeTestRule.onNodeWithContentDescription("10 o'clock").performClick() + composeTestRule.onNodeWithContentDescription("7 o'clock").performClick() composeTestRule.onNodeWithContentDescription("Select minutes").performClick() composeTestRule.onNodeWithContentDescription("40 minutes").performClick() composeTestRule.onNodeWithText("Okay").assertIsNotEnabled() @@ -229,13 +263,14 @@ class NotificationSettingsWidgetTest : KoinTest { @Test fun testPermissionDenied() { lateinit var hasRequestedPermission: MutableState + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) composeTestRule.setContent { hasRequestedPermission = remember { mutableStateOf(false) } var hasRequestedPermission by hasRequestedPermission NotificationSettingsWidget( - FavoriteSettings.Notifications.disabled, - setSettings = {}, + viewModel, notificationPermissionState = ConstantPermissionState( Manifest.permission.POST_NOTIFICATIONS, @@ -259,11 +294,13 @@ class NotificationSettingsWidgetTest : KoinTest { settings = mapOf(Settings.NotificationPresetWindows to false) ) } + + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) + composeTestRule.setContent { - var settings by remember { mutableStateOf(FavoriteSettings.Notifications.disabled) } NotificationSettingsWidget( - settings, - setSettings = { settings = it }, + viewModel, notificationPermissionState = permissionGranted, hasRequestedPermission = true, ) @@ -284,11 +321,13 @@ class NotificationSettingsWidgetTest : KoinTest { settings = MockSettingsRepository(settings = mapOf(Settings.NotificationPresetWindows to true)) } + + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) + composeTestRule.setContent { - var settings by remember { mutableStateOf(FavoriteSettings.Notifications.disabled) } NotificationSettingsWidget( - settings, - setSettings = { settings = it }, + viewModel, notificationPermissionState = permissionGranted, hasRequestedPermission = true, ) @@ -309,11 +348,12 @@ class NotificationSettingsWidgetTest : KoinTest { settings = MockSettingsRepository(settings = mapOf(Settings.NotificationPresetWindows to true)) } + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) + composeTestRule.setContent { - var settings by remember { mutableStateOf(FavoriteSettings.Notifications.disabled) } NotificationSettingsWidget( - settings, - setSettings = { settings = it }, + viewModel, notificationPermissionState = permissionGranted, hasRequestedPermission = true, ) @@ -341,11 +381,12 @@ class NotificationSettingsWidgetTest : KoinTest { settings = MockSettingsRepository(settings = mapOf(Settings.NotificationPresetWindows to true)) } + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) + composeTestRule.setContent { - var settings by remember { mutableStateOf(FavoriteSettings.Notifications.disabled) } NotificationSettingsWidget( - settings, - setSettings = { settings = it }, + viewModel, notificationPermissionState = permissionGranted, hasRequestedPermission = true, ) @@ -367,13 +408,13 @@ class NotificationSettingsWidgetTest : KoinTest { settings = MockSettingsRepository(settings = mapOf(Settings.NotificationPresetWindows to true)) } - lateinit var settings: MutableState + + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) + composeTestRule.setContent { - settings = remember { mutableStateOf(FavoriteSettings.Notifications.disabled) } - var notificationSettings by settings NotificationSettingsWidget( - notificationSettings, - setSettings = { notificationSettings = it }, + viewModel, notificationPermissionState = permissionGranted, hasRequestedPermission = true, now = EasternTimeInstant(LocalDateTime(2026, 8, 27, 12, 30, 0)), @@ -392,11 +433,12 @@ class NotificationSettingsWidgetTest : KoinTest { settings = MockSettingsRepository(settings = mapOf(Settings.NotificationPresetWindows to true)) } + + val viewModel = NotificationSettingsViewModel(MockSentryRepository()) + viewModel.loadSavedSettings(FavoriteSettings.Notifications.disabled) composeTestRule.setContent { - var settings by remember { mutableStateOf(FavoriteSettings.Notifications.disabled) } NotificationSettingsWidget( - settings, - setSettings = { settings = it }, + viewModel, notificationPermissionState = permissionGranted, hasRequestedPermission = true, now = EasternTimeInstant(LocalDateTime(2026, 8, 27, 4, 30, 0)), diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/favorites/PresetWindowSelectorTest.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/favorites/PresetWindowSelectorTest.kt index 7182aae1e..001019023 100644 --- a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/favorites/PresetWindowSelectorTest.kt +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/favorites/PresetWindowSelectorTest.kt @@ -5,14 +5,8 @@ import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import com.mbta.tid.mbta_app.model.FavoriteSettings.Notifications.Window -import com.mbta.tid.mbta_app.model.PresetSelection -import com.mbta.tid.mbta_app.model.PresetWindow -import com.mbta.tid.mbta_app.utils.EasternTimeInstant +import com.mbta.tid.mbta_app.model.Preset import kotlin.test.assertEquals -import kotlinx.datetime.DayOfWeek -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.LocalTime import org.junit.Rule import org.junit.Test @@ -22,29 +16,17 @@ class PresetWindowSelectorTest { @Test fun testPresetWindowsVisible() { - var selectedWindows: List? = null + var selectedPreset: Preset? = null composeTestRule.setContent { PresetWindowSelector( presetRows = listOf( - listOf( - PresetWindow( - window = Window.morningDefault(Window.weekdays), - label = "Morning", - ) - ), - listOf( - PresetWindow( - window = Window.middayDefault(Window.weekdays), - label = "Midday", - ) - ), + listOf(Preset.Morning), + listOf(Preset.Midday), ), - selectedPreset = PresetSelection.Preset(rowIndex = 1, columnIndex = 0), - now = EasternTimeInstant(LocalDateTime(2026, 8, 27, 4, 30, 0)), - ) { windows -> - selectedWindows = windows - } + selectedPreset = Preset.Midday, + onSelect = { preset -> selectedPreset = preset }, + ) } composeTestRule.onNodeWithText("Morning").assertIsNotSelected() @@ -52,141 +34,29 @@ class PresetWindowSelectorTest { composeTestRule.onNodeWithText("Custom").assertIsNotSelected() composeTestRule.onNodeWithText("Morning").performClick() - assertEquals(listOf(Window.morningDefault(Window.weekdays)), selectedWindows) - } - - @Test - fun testCustomDefaultsToNow() { - var selectedWindows: List? = null - composeTestRule.setContent { - PresetWindowSelector( - presetRows = - listOf( - listOf( - PresetWindow( - window = Window.morningDefault(Window.weekdays), - label = "Morning", - ) - ), - listOf( - PresetWindow( - window = Window.middayDefault(Window.weekdays), - label = "Midday", - ) - ), - ), - selectedPreset = PresetSelection.Preset(rowIndex = 1, columnIndex = 0), - now = EasternTimeInstant(LocalDateTime(2026, 8, 27, 4, 30, 0)), - ) { windows -> - selectedWindows = windows - } - } - composeTestRule.onNodeWithText("Custom").performClick() - assertEquals( - selectedWindows, - listOf( - Window( - startTime = LocalTime(4, 0, 0), - endTime = LocalTime(5, 0, 0), - daysOfWeek = - setOf( - DayOfWeek.MONDAY, - DayOfWeek.TUESDAY, - DayOfWeek.WEDNESDAY, - DayOfWeek.THURSDAY, - DayOfWeek.FRIDAY, - ), - ) - ), - ) + assertEquals(Preset.Morning, selectedPreset) } @Test - fun testCustomDefaultsToNowLateNight() { - var selectedWindows: List? = null + fun testCustomSetsNullPreset() { + var selectedPreset: Preset? = Preset.Midday composeTestRule.setContent { PresetWindowSelector( presetRows = listOf( - listOf( - PresetWindow( - window = Window.morningDefault(Window.weekdays), - label = "Morning", - ) - ), - listOf( - PresetWindow( - window = Window.middayDefault(Window.weekdays), - label = "Midday", - ) - ), + listOf(Preset.Morning), + listOf(Preset.Midday), ), - selectedPreset = PresetSelection.Preset(rowIndex = 1, columnIndex = 0), - now = EasternTimeInstant(LocalDateTime(2026, 8, 27, 23, 30, 0)), - ) { windows -> - selectedWindows = windows - } + selectedPreset = Preset.Midday, + onSelect = { preset -> selectedPreset = preset }, + ) } composeTestRule.onNodeWithText("Custom").performClick() assertEquals( - selectedWindows, - listOf( - Window( - startTime = LocalTime(23, 0, 0), - endTime = LocalTime(23, 59, 0), - daysOfWeek = - setOf( - DayOfWeek.MONDAY, - DayOfWeek.TUESDAY, - DayOfWeek.WEDNESDAY, - DayOfWeek.THURSDAY, - DayOfWeek.FRIDAY, - ), - ) - ), + null, + selectedPreset, ) } - - @Test - fun testCustomUsesProvidedCustomPreset() { - var selectedWindows: List? = null - val customPresetWindows = - listOf( - Window( - startTime = LocalTime(10, 15, 0), - endTime = LocalTime(11, 45, 0), - daysOfWeek = setOf(DayOfWeek.SUNDAY, DayOfWeek.TUESDAY), - ) - ) - - composeTestRule.setContent { - PresetWindowSelector( - presetRows = - listOf( - listOf( - PresetWindow( - window = Window.morningDefault(Window.weekdays), - label = "Morning", - ) - ), - listOf( - PresetWindow( - window = Window.middayDefault(Window.weekdays), - label = "Midday", - ) - ), - ), - selectedPreset = PresetSelection.Preset(rowIndex = 1, columnIndex = 0), - now = EasternTimeInstant(LocalDateTime(2026, 8, 27, 4, 30, 0)), - customPreset = customPresetWindows, - ) { windows -> - selectedWindows = windows - } - } - - composeTestRule.onNodeWithText("Custom").performClick() - assertEquals(customPresetWindows, selectedWindows) - } } diff --git a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/pages/SaveFavoritePageTests.kt b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/pages/SaveFavoritePageTests.kt index 8be1ebea3..e20a1a890 100644 --- a/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/pages/SaveFavoritePageTests.kt +++ b/androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/pages/SaveFavoritePageTests.kt @@ -193,7 +193,7 @@ class SaveFavoritePageTests { } @Test - fun testEditExisting() { + fun testEditExistingNotifications() { val objects = TestData.clone() val route = objects.getRoute("Orange") val stop = objects.getStop("place-welln") diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/favorites/NotificationSettingsWidget.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/favorites/NotificationSettingsWidget.kt index 2005a8933..9294b5061 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/favorites/NotificationSettingsWidget.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/favorites/NotificationSettingsWidget.kt @@ -42,9 +42,9 @@ import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -81,14 +81,17 @@ import com.mbta.tid.mbta_app.android.util.formattedTime import com.mbta.tid.mbta_app.android.util.modifiers.haloContainer import com.mbta.tid.mbta_app.model.FavoriteSettings import com.mbta.tid.mbta_app.model.FavoriteSettings.Notifications.Window -import com.mbta.tid.mbta_app.model.PresetSelection -import com.mbta.tid.mbta_app.model.PresetWindow +import com.mbta.tid.mbta_app.model.Preset +import com.mbta.tid.mbta_app.repositories.MockSentryRepository import com.mbta.tid.mbta_app.repositories.MockSettingsRepository import com.mbta.tid.mbta_app.repositories.Settings as UserSettings import com.mbta.tid.mbta_app.utils.EasternTimeInstant +import com.mbta.tid.mbta_app.viewModel.INotificationSettingsViewModel +import com.mbta.tid.mbta_app.viewModel.NotificationSettingsViewModel import kotlin.uuid.ExperimentalUuidApi import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalTime +import org.koin.compose.koinInject import org.koin.core.context.startKoin import org.koin.dsl.module import org.koin.mp.KoinPlatformTools @@ -96,8 +99,7 @@ import org.koin.mp.KoinPlatformTools @OptIn(ExperimentalUuidApi::class, ExperimentalPermissionsApi::class) @Composable fun NotificationSettingsWidget( - settings: FavoriteSettings.Notifications, - setSettings: (FavoriteSettings.Notifications) -> Unit, + viewModel: INotificationSettingsViewModel = koinInject(), notificationPermissionState: PermissionState, hasRequestedPermission: Boolean, now: EasternTimeInstant = EasternTimeInstant.now(), @@ -109,38 +111,35 @@ fun NotificationSettingsWidget( val context = LocalContext.current + val notificationSettingsState by viewModel.models.collectAsState() + + LaunchedEffect(null) { + viewModel.setNow(now) + } + LaunchedEffect(permissionStatus, hasRequestedPermission) { if (permissionDenied) { - setSettings(FavoriteSettings.Notifications.disabled) + viewModel.setEnabled(false) } } - val presetDaysOfWeek = Window.defaultDaysOfWeek(now) - val presetOptions = listOf( listOf( - PresetWindow.morningPreset(stringResource(R.string.morning), presetDaysOfWeek), - PresetWindow.middayPreset(stringResource(R.string.midday), presetDaysOfWeek), - PresetWindow.eveningPreset(stringResource(R.string.evening), presetDaysOfWeek), + Preset.Morning, + Preset.Midday, + Preset.Evening, ), - listOf(PresetWindow.allDayPreset(stringResource(R.string.all_day), presetDaysOfWeek)), + listOf(Preset.AllDay), ) - val presetSelection: PresetSelection = - remember(settings) { - PresetSelection.selectedPresetFromWindows(settings.windows, presetOptions) - } - var customPreset by remember { - mutableStateOf(listOf(Window.customFromCurrentTime(now))) - } - LaunchedEffect(presetSelection, settings.windows) { - if (presetSelection is PresetSelection.Custom && settings.windows.isNotEmpty()) { - customPreset = settings.windows - } + val presetWindowsEnabled = SettingsCache.get(UserSettings.NotificationPresetWindows) + + LaunchedEffect(presetWindowsEnabled) { + viewModel.setPresetsEnabledFlag(presetWindowsEnabled) } - val presetWindowsEnabled = SettingsCache.get(UserSettings.NotificationPresetWindows) + val settings = notificationSettingsState.settings ?: return Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column( @@ -158,11 +157,9 @@ fun NotificationSettingsWidget( ) { NotificationSwitch( settings = settings, - setSettings = setSettings, + onValueChange = viewModel::setEnabled, notificationPermissionState = notificationPermissionState, enabled = !showPermissionSettingsLink, - presetWindowsEnabled = presetWindowsEnabled, - now = now, ) AnimatedVisibility(showPermissionSettingsLink) { PermissionSettingsLink() } } @@ -171,42 +168,32 @@ fun NotificationSettingsWidget( if (presetWindowsEnabled) { PresetWindowSelector( presetRows = presetOptions, - selectedPreset = presetSelection, - customPreset = customPreset, - ) { windows -> - setSettings(settings.copy(windows = windows)) - } + selectedPreset = notificationSettingsState.selectedPreset, + onSelect = { preset -> + viewModel.setPreset(preset) + }, + ) } - for (window in - settings.windows.ifEmpty { - setOf(Window.default(emptyList(), presetWindowsEnabled, now)) - }) { + for (window in settings.windows) { WindowWidget( window, setWindow = { newWindow -> val windows = settings.windows.toMutableList() val index = windows.indexOf(window) if (index != -1) windows[index] = newWindow - setSettings(settings.copy(windows = windows)) + viewModel.setCustomWindows(windows) }, deleteWindow = { - setSettings(settings.copy(windows = settings.windows - window)) + viewModel.setCustomWindows(settings.windows - window) } .takeIf { settings.windows.size > 1 }, ) } - - val customWindow = - if (presetWindowsEnabled) { - Window.customFromCurrentTime(now) - } else { - Window.default(settings.windows, presetWindowsEnabled, now) - } Surface( onClick = { - setSettings(settings.copy(windows = settings.windows + customWindow)) + viewModel.addPlaceholderWindow() }, Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), @@ -475,11 +462,9 @@ private fun DaysOfWeekInput(daysOfWeek: Set, setDaysOfWeek: (Set Unit, + onValueChange: (Boolean) -> Unit, notificationPermissionState: PermissionState, enabled: Boolean, - presetWindowsEnabled: Boolean, - now: EasternTimeInstant = EasternTimeInstant.now(), ) { LabeledSwitch( @@ -507,15 +492,7 @@ private fun NotificationSwitch( value = settings.enabled, onValueChange = { notificationPermissionState.launchPermissionRequest() - setSettings( - settings.copy( - enabled = it, - windows = - settings.windows.ifEmpty { - listOf(Window.default(emptyList(), presetWindowsEnabled, now)) - }, - ) - ) + onValueChange(it) }, enabled = enabled, ) @@ -558,6 +535,19 @@ private fun PermissionSettingsLink() { @Composable private fun NotificationSettingsWidgetPreview() { + val enabledNotificationSettingsViewModel: INotificationSettingsViewModel by lazy { + NotificationSettingsViewModel(MockSentryRepository()).apply { + setEnabled(true) + setCustomWindows(listOf(Window.default(emptyList(), true, EasternTimeInstant.now()))) + } + } + + val disabledNotificationSettingsViewModel: INotificationSettingsViewModel by lazy { + NotificationSettingsViewModel(MockSentryRepository()).apply { + setEnabled(false) + } + } + if (KoinPlatformTools.defaultContext().getOrNull() == null) { startKoin { modules( @@ -573,14 +563,6 @@ private fun NotificationSettingsWidgetPreview() { ) } } - var settings by remember { - mutableStateOf( - FavoriteSettings.Notifications( - enabled = true, - windows = listOf(Window.default(emptyList(), true, EasternTimeInstant.now())), - ) - ) - } MyApplicationTheme { Column( Modifier.background(colorResource(R.color.fill2)) @@ -588,8 +570,7 @@ private fun NotificationSettingsWidgetPreview() { verticalArrangement = Arrangement.spacedBy(24.dp), ) { NotificationSettingsWidget( - settings, - { settings = it }, + enabledNotificationSettingsViewModel, ConstantPermissionState( Manifest.permission.POST_NOTIFICATIONS, PermissionStatus.Granted, @@ -598,8 +579,7 @@ private fun NotificationSettingsWidgetPreview() { ) HaloSeparator() NotificationSettingsWidget( - FavoriteSettings.Notifications.disabled, - {}, + disabledNotificationSettingsViewModel, ConstantPermissionState( Manifest.permission.POST_NOTIFICATIONS, PermissionStatus.Denied(false), diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/favorites/PresetWindowSelector.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/favorites/PresetWindowSelector.kt index 198ae296c..1cf0f7cde 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/favorites/PresetWindowSelector.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/favorites/PresetWindowSelector.kt @@ -19,18 +19,13 @@ import androidx.compose.ui.semantics.collectionItemInfo import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.mbta.tid.mbta_app.android.R -import com.mbta.tid.mbta_app.model.FavoriteSettings.Notifications.Window -import com.mbta.tid.mbta_app.model.PresetSelection -import com.mbta.tid.mbta_app.model.PresetWindow -import com.mbta.tid.mbta_app.utils.EasternTimeInstant +import com.mbta.tid.mbta_app.model.Preset @Composable fun PresetWindowSelector( - presetRows: List>, - selectedPreset: PresetSelection, - now: EasternTimeInstant = EasternTimeInstant.now(), - customPreset: List = listOf(Window.customFromCurrentTime(now)), - onSelect: (List) -> Unit, + presetRows: List>, + selectedPreset: Preset?, + onSelect: (Preset?) -> Unit, ) { val maxColumnCount = presetRows.firstOrNull()?.size ?: 0 @@ -47,14 +42,17 @@ fun PresetWindowSelector( presetRows.forEachIndexed { rowIndex, windows -> Row() { windows.forEachIndexed { presetIndex, preset -> - val isSelected = - selectedPreset is PresetSelection.Preset && - rowIndex == selectedPreset.rowIndex && - presetIndex == selectedPreset.columnIndex + val isSelected = selectedPreset == preset PresetButton( isSelected = isSelected, - onSelect = { onSelect(listOf(preset.window)) }, - label = preset.label, + onSelect = { onSelect(preset) }, + label = + when (preset) { + Preset.Morning -> stringResource(R.string.morning) + Preset.Midday -> stringResource(R.string.midday) + Preset.Evening -> stringResource(R.string.evening) + Preset.AllDay -> stringResource(R.string.all_day) + }, modifier = Modifier.weight(1f).semantics { collectionItemInfo = @@ -70,10 +68,10 @@ fun PresetWindowSelector( } } Row() { - val isSelected = selectedPreset is PresetSelection.Custom + val isSelected = selectedPreset == null PresetButton( isSelected = isSelected, - onSelect = { onSelect(customPreset) }, + onSelect = { onSelect(null) }, label = stringResource(R.string.custom), modifier = Modifier.weight(1f).semantics { diff --git a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/SaveFavoritePage.kt b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/SaveFavoritePage.kt index 34bf36b46..60f15fff7 100644 --- a/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/SaveFavoritePage.kt +++ b/androidApp/src/main/java/com/mbta/tid/mbta_app/android/pages/SaveFavoritePage.kt @@ -73,6 +73,7 @@ import com.mbta.tid.mbta_app.usecases.EditFavoritesContext import com.mbta.tid.mbta_app.usecases.FavoritesUsecases import com.mbta.tid.mbta_app.utils.TestData import com.mbta.tid.mbta_app.viewModel.IFavoritesViewModel +import com.mbta.tid.mbta_app.viewModel.INotificationSettingsViewModel import com.mbta.tid.mbta_app.viewModel.IToastViewModel import com.mbta.tid.mbta_app.viewModel.MockToastViewModel import com.mbta.tid.mbta_app.viewModel.ToastViewModel @@ -93,6 +94,7 @@ fun SaveFavoritePage( goBack: () -> Unit, analytics: Analytics = koinInject(), favoritesViewModel: IFavoritesViewModel = koinInject(), + notificationSettingsViewModel: INotificationSettingsViewModel = koinInject(), toastViewModel: IToastViewModel = koinInject(), ) { val resources = LocalResources.current @@ -144,6 +146,19 @@ fun SaveFavoritePage( } } + val notificationSettingsState by notificationSettingsViewModel.models.collectAsState() + + LaunchedEffect(existingSettings) { + notificationSettingsViewModel.loadSavedSettings(existingSettings.notifications) + } + + LaunchedEffect(notificationSettingsState) { + val notificationSettings = notificationSettingsState.settings + if (notificationSettings != null) { + updatedSettings = settings.copy(notifications = notificationSettings) + } + } + val includeAccessibility = SettingsCache.get(Settings.StationAccessibility) val notificationsEnabled = SettingsCache.get(Settings.Notifications) val currentLocale = stringResource(R.string.current_locale) @@ -277,8 +292,7 @@ fun SaveFavoritePage( stopDirections.singleOrNull()?.id == 1 - initialDirection, ) NotificationSettingsWidget( - settings = settings.notifications, - setSettings = { updatedSettings = settings.copy(notifications = it) }, + viewModel = notificationSettingsViewModel, notificationPermissionState = notificationPermissionState, hasRequestedPermission = hasRequestedPermission, ) diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 127fe541e..e68bc5bff 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -666,7 +666,7 @@ struct ContentView: View { fcmToken: fcmTokenContainer.token, ) }, - navCallbacks: navCallbacks, + navCallbacks: navCallbacks ) default: diff --git a/iosApp/iosApp/Pages/SaveFavorite/NotificationSettingsWidget.swift b/iosApp/iosApp/Pages/SaveFavorite/NotificationSettingsWidget.swift index ccbd9e8ab..5360bf8ab 100644 --- a/iosApp/iosApp/Pages/SaveFavorite/NotificationSettingsWidget.swift +++ b/iosApp/iosApp/Pages/SaveFavorite/NotificationSettingsWidget.swift @@ -23,18 +23,71 @@ private extension DateComponents { self = calendar.dateComponents(components, from: newValue) } } + + static func fromLocalTime(_ localTime: Kotlinx_datetimeLocalTime) -> Self { + .init( + hour: Int(localTime.hour), + minute: Int(localTime.minute), + second: Int(localTime.second) + ) + } + + func toLocalTime() -> Kotlinx_datetimeLocalTime { + .init( + hour: Int32(hour ?? 0), + minute: Int32(minute ?? 0), + second: Int32(second ?? 0), + nanosecond: Int32(nanosecond ?? 0) + ) + } } struct NotificationSettingsWidget: View { @ObserveInjection var inject - let settings: FavoriteSettings.Notifications - let setSettings: (FavoriteSettings.Notifications) -> Void + let vm: INotificationSettingsViewModel + let onUpdate: (FavoriteSettings.Notifications) -> Void var notificationPermissionManager: INotificationPermissionManager var authorizationStatus: UNAuthorizationStatus? { notificationPermissionManager.authorizationStatus } var now: EasternTimeInstant = .now() - @State var customPreset: [FavoriteSettings.NotificationsWindow] = [] + @State var vmState: NotificationSettingsViewModel.State? + + let inspection = Inspection() + + var body: some View { + VStack(spacing: 0) { + if let vmState { + NotificationSettingsWidgetPresetnationView(state: vmState, + setEnabled: { enabled in vm.setEnabled(enabled: enabled) }, + setPreset: { preset in vm.setPreset(preset: preset) }, + setCustomWindows: { custom in + vm.setCustomWindows(windows: custom) + }, + addPlaceholderWindow: { vm.addPlaceholderWindow() }, + notificationPermissionManager: notificationPermissionManager) + } + }.manageVM(vm, $vmState, now) + .onChange(of: vmState) { newState in + if let newState, let settings = newState.settings { + onUpdate(settings) + } + } + .onReceive(inspection.notice) { inspection.visit(self, $0) } + .enableInjection() + } +} + +struct NotificationSettingsWidgetPresetnationView: View { + let state: NotificationSettingsViewModel.State + var setEnabled: (Bool) -> Void = { _ in } + var setPreset: (Preset?) -> Void = { _ in } + var setCustomWindows: ([FavoriteSettings.NotificationsWindow]) -> Void = { _ in } + var addPlaceholderWindow: () -> Void = {} + + var notificationPermissionManager: INotificationPermissionManager + var authorizationStatus: UNAuthorizationStatus? { notificationPermissionManager.authorizationStatus } + var now: EasternTimeInstant = .now() @EnvironmentObject var settingsCache: SettingsCache var presetWindowsEnabled: Bool { settingsCache.get(.notificationPresetWindows) } @@ -43,332 +96,266 @@ struct NotificationSettingsWidget: View { FavoriteSettings.NotificationsWindow.companion.defaultDaysOfWeek(now: now) } - var presetOptions: [[PresetWindow]] { [ - [ - .init( - label: NSLocalizedString("Morning", comment: "Notification window preset label"), - window: FavoriteSettings.NotificationsWindow.companion.morningDefault(daysOfWeek: daysOfWeek) - ), - .init( - label: NSLocalizedString("Midday", comment: "Notification window preset label"), - window: FavoriteSettings.NotificationsWindow.companion.middayDefault(daysOfWeek: daysOfWeek) - ), - .init( - label: NSLocalizedString("Evening", comment: "Notification window preset label"), - window: FavoriteSettings.NotificationsWindow.companion.eveningDefault(daysOfWeek: daysOfWeek) - ) - ], - [ - .init( - label: NSLocalizedString("All day", comment: "Notification window preset label"), - window: FavoriteSettings.NotificationsWindow.companion.allDayDefault(daysOfWeek: daysOfWeek) - ) - ] + let presetOptions: [[Preset]] = [ + [.morning, .midday, .evening], + [.allDay] ] - } - - var presetSelection: PresetSelection { - PresetSelection.companion.selectedPresetFromWindows( - windows: settings.windows, - presetOptions: presetOptions - ) - } let inspection = Inspection() var body: some View { - let permissionDenied = authorizationStatus == .denied - VStack(spacing: 8) { - NotificationSwitch( - settings: settings, - setSettings: setSettings, - notificationPermissionManager: notificationPermissionManager, - now: now, - presetWindowsEnabled: presetWindowsEnabled - ) - - if settings.enabled { - if presetWindowsEnabled { - PresetWindowSelector( - presetRows: presetOptions, - selectedPreset: presetSelection, - now: now, - customPreset: customPreset, - onSelect: { selectedWindows in - setSettings(settings.doCopy( - enabled: settings.enabled, - windows: selectedWindows - )) - } + VStack(spacing: 0) { + if let settings = state.settings { + VStack(spacing: 8) { + NotificationSwitch( + settings: settings, + onValueChanged: { setEnabled($0) }, + notificationPermissionManager: notificationPermissionManager ) - .onAppear { - if presetSelection == PresetSelection.Custom() { - customPreset = settings.windows - } else { - customPreset = - [FavoriteSettings.NotificationsWindow.companion.customFromCurrentTime(now: now)] - } - } - .onChange(of: settings.windows) { newWindows in - if PresetSelection.companion.selectedPresetFromWindows( - windows: newWindows, - presetOptions: presetOptions - ) == PresetSelection.Custom() { - customPreset = newWindows - } - } - } - ForEach(settings.windows, id: \.id) { window in - WindowWidget( - window: window, - setWindow: { newWindow in - let windowIndex = settings.windows.firstIndex(of: window) - var newWindows = settings.windows - if let windowIndex { - newWindows[windowIndex] = newWindow - } - setSettings(settings.doCopy(enabled: settings.enabled, windows: newWindows)) - }, - deleteWindow: settings.windows.count > 1 ? { - let nextWindows = settings.windows.filter { $0.id != window.id } - setSettings( - settings.doCopy( - enabled: settings.enabled, - windows: nextWindows - ) + if settings.enabled { + if presetWindowsEnabled { + PresetWindowSelector( + presetRows: presetOptions, + selectedPreset: state.selectedPreset, + onSelect: { preset in + setPreset(preset) + } ) - } : nil - ) - } + } - let customWindow = - if presetWindowsEnabled { - FavoriteSettings.NotificationsWindow.companion.customFromCurrentTime(now: now) - } else { - FavoriteSettings.NotificationsWindow.companion.default( - existingWindows: settings.windows, - presetsEnabled: presetWindowsEnabled, - now: now - ) - } + ForEach(settings.windows, id: \.id) { window in + WindowWidget( + window: window, + setWindow: { newWindow in + let windowIndex = settings.windows.firstIndex(of: window) + var newWindows = settings.windows + if let windowIndex { + newWindows[windowIndex] = newWindow + } + setCustomWindows(newWindows) + }, + deleteWindow: settings.windows.count > 1 ? { + let nextWindows = settings.windows.filter { $0.id != window.id } + setCustomWindows(nextWindows) + } : nil + ) + } - Button(action: { - let nextWindows = settings.windows + [customWindow] - setSettings(settings.doCopy( - enabled: settings.enabled, - windows: nextWindows - )) - }) { - HStack(spacing: 12) { - Image(.plus) - .resizable() - .padding(4) - .background(Color.text.opacity(0.6), in: .circle) - .foregroundStyle(Color.fill3) - .frame(width: 24, height: 24) - Text("Add another time period") - Spacer() + Button(action: { + addPlaceholderWindow() + }) { + HStack(spacing: 12) { + Image(.plus) + .resizable() + .padding(4) + .background(Color.text.opacity(0.6), in: .circle) + .foregroundStyle(Color.fill3) + .frame(width: 24, height: 24) + Text("Add another time period") + Spacer() + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.fill3) + .withRoundedBorder() + .foregroundStyle(Color.text.opacity(0.6)) } } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(Color.fill3) - .withRoundedBorder() - .foregroundStyle(Color.text.opacity(0.6)) } } .onReceive(inspection.notice) { inspection.visit(self, $0) } .enableInjection() } +} - struct WindowWidget: View { - @ObserveInjection var inject - let window: FavoriteSettings.NotificationsWindow - let setWindow: (FavoriteSettings.NotificationsWindow) -> Void - let deleteWindow: (() -> Void)? +struct WindowWidget: View { + @ObserveInjection var inject + let window: FavoriteSettings.NotificationsWindow + let setWindow: (FavoriteSettings.NotificationsWindow) -> Void + let deleteWindow: (() -> Void)? - var body: some View { - HStack(spacing: 0) { - if let deleteWindow { - Button(action: deleteWindow) { - Image(.faDelete).accessibilityLabel(Text("Delete")) - } - .foregroundStyle(Color.error) - .frame(minWidth: 44) + var body: some View { + HStack(spacing: 0) { + if let deleteWindow { + Button(action: deleteWindow) { + Image(.faDelete).accessibilityLabel(Text("Delete")) } - VStack { - HStack(spacing: 0) { - TimeInput( - label: Text("Select start time"), - time: DateComponents.fromLocalTime(window.startTime), - setTime: { time in - let startTime = time.toLocalTime() - setWindow(window.doCopy( - startTime: startTime, - endTime: FavoriteSettings.NotificationsWindow.companion - .safeEndTime(startTime: startTime, endTime: window.endTime), - daysOfWeek: window.daysOfWeek - )) - }, - minimumTime: nil - ).frame(maxWidth: .infinity) - Text("to") - TimeInput( - label: Text("Select end time"), - time: DateComponents.fromLocalTime(window.endTime), - setTime: { time in setWindow(window.doCopy( - startTime: window.startTime, - endTime: time.toLocalTime(), + .foregroundStyle(Color.error) + .frame(minWidth: 44) + } + VStack { + HStack(spacing: 0) { + TimeInput( + label: Text("Select start time"), + time: DateComponents.fromLocalTime(window.startTime), + setTime: { time in + let startTime = time.toLocalTime() + setWindow(window.doCopy( + startTime: startTime, + endTime: FavoriteSettings.NotificationsWindow.companion + .safeEndTime(startTime: startTime, endTime: window.endTime), daysOfWeek: window.daysOfWeek - )) }, - minimumTime: DateComponents - .fromLocalTime(FavoriteSettings.NotificationsWindow.companion - .minimumEndTime(startTime: window.startTime)) - ).frame(maxWidth: .infinity) - } - DaysOfWeekInput( - daysOfWeek: window.daysOfWeek, - setDaysOfWeek: { newDays in setWindow(window.doCopy( + )) + }, + minimumTime: nil + ).frame(maxWidth: .infinity) + Text("to") + TimeInput( + label: Text("Select end time"), + time: DateComponents.fromLocalTime(window.endTime), + setTime: { time in setWindow(window.doCopy( startTime: window.startTime, - endTime: window.endTime, - daysOfWeek: newDays - )) } - ) + endTime: time.toLocalTime(), + daysOfWeek: window.daysOfWeek + )) }, + minimumTime: DateComponents + .fromLocalTime(FavoriteSettings.NotificationsWindow.companion + .minimumEndTime(startTime: window.startTime)) + ).frame(maxWidth: .infinity) } - .background(Color.fill3) - .clipShape(RoundedRectangle(cornerRadius: 7)) - .padding(1) + DaysOfWeekInput( + daysOfWeek: window.daysOfWeek, + setDaysOfWeek: { newDays in setWindow(window.doCopy( + startTime: window.startTime, + endTime: window.endTime, + daysOfWeek: newDays + )) } + ) } - .background(Color.halo) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .enableInjection() + .background(Color.fill3) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .padding(1) } + .background(Color.halo) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .enableInjection() } +} - struct TimeInput: View { - @ObserveInjection var inject - let label: Text - let time: DateComponents - let setTime: (DateComponents) -> Void - let minimumTime: DateComponents? - - init( - label: Text, - time: DateComponents, - setTime: @escaping (DateComponents) -> Void, - minimumTime: DateComponents? = nil - ) { - self.label = label - self.time = time - self.setTime = setTime - self.minimumTime = minimumTime - } +struct TimeInput: View { + @ObserveInjection var inject + let label: Text + let time: DateComponents + let setTime: (DateComponents) -> Void + let minimumTime: DateComponents? + + init( + label: Text, + time: DateComponents, + setTime: @escaping (DateComponents) -> Void, + minimumTime: DateComponents? = nil + ) { + self.label = label + self.time = time + self.setTime = setTime + self.minimumTime = minimumTime + } - var dateRange: ClosedRange { - let calendar = Calendar(identifier: .iso8601) - let beforeDayStart = calendar.startOfDay(for: .now).addingTimeInterval(-0.01) - let minimum: DateComponents = minimumTime ?? .init(hour: 0, minute: 0, second: 0) - let start = calendar.nextDate( - after: beforeDayStart, - matching: minimum, - matchingPolicy: .strict - )! - let end = calendar.nextDate( - after: start, - matching: .init(hour: 23, minute: 59, second: 59), - matchingPolicy: .strict - )! - return start ... end - } + var dateRange: ClosedRange { + let calendar = Calendar(identifier: .iso8601) + let beforeDayStart = calendar.startOfDay(for: .now).addingTimeInterval(-0.01) + let minimum: DateComponents = minimumTime ?? .init(hour: 0, minute: 0, second: 0) + let start = calendar.nextDate( + after: beforeDayStart, + matching: minimum, + matchingPolicy: .strict + )! + let end = calendar.nextDate( + after: start, + matching: .init(hour: 23, minute: 59, second: 59), + matchingPolicy: .strict + )! + return start ... end + } - var body: some View { - let timeBinding = Binding( - get: { - time - }, - set: { newValue in - setTime(newValue) - } - ) - - DatePicker(selection: timeBinding.nextDate, in: dateRange, displayedComponents: [.hourAndMinute]) { label } - .labelsHidden() - .datePickerStyle(.compact) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .enableInjection() - } + var body: some View { + let timeBinding = Binding( + get: { + time + }, + set: { newValue in + setTime(newValue) + } + ) + + DatePicker(selection: timeBinding.nextDate, in: dateRange, displayedComponents: [.hourAndMinute]) { label } + .labelsHidden() + .datePickerStyle(.compact) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .enableInjection() } +} - struct DaysOfWeekInput: View { - @ObserveInjection var inject - let daysOfWeek: Set - let setDaysOfWeek: (Set) -> Void +struct DaysOfWeekInput: View { + @ObserveInjection var inject + let daysOfWeek: Set + let setDaysOfWeek: (Set) -> Void - static var days: [Kotlinx_datetimeDayOfWeek] { - [.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday] - } + static var days: [Kotlinx_datetimeDayOfWeek] { + [.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday] + } - static var calendar: Calendar { - var result = Calendar(identifier: .iso8601) - result.locale = .autoupdatingCurrent - return result - } + static var calendar: Calendar { + var result = Calendar(identifier: .iso8601) + result.locale = .autoupdatingCurrent + return result + } - var body: some View { - let calendar = Self.calendar - HStack(alignment: .top, spacing: 2) { - ForEach(Self.days, id: \.ordinal) { day in - let isIncluded = daysOfWeek.contains(day) - VStack(spacing: 0) { - Text(calendar.shortStandaloneWeekdaySymbols[day.indexSundayFirst]) - .lineLimit(1) - .font(Typography.footnoteSemibold) - if isIncluded { - Image(.faCheck) - } else { - Image(.faCheck).hidden() - } - } - .frame(maxWidth: .infinity) - .padding(.top, 8) - .onTapGesture { - setDaysOfWeek(daysOfWeek.symmetricDifference([day])) - } - .background(isIncluded ? Color.key : Color.fill1) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .foregroundStyle(isIncluded ? Color.fill3 : Color.text.opacity(0.6)) - .accessibilityElement(children: .ignore) - .accessibilityChildren { - // .accessibilityAddTraits(.isToggle) is iOS 17+ only, so we use a real toggle - // labelled with the full name of the day - Toggle( - isOn: .init(get: { isIncluded }, set: { _ in - setDaysOfWeek(daysOfWeek.symmetricDifference([day])) - }), - label: { - Text(calendar.standaloneWeekdaySymbols[day.indexSundayFirst]) - } - ) + var body: some View { + let calendar = Self.calendar + HStack(alignment: .top, spacing: 2) { + ForEach(Self.days, id: \.ordinal) { day in + let isIncluded = daysOfWeek.contains(day) + VStack(spacing: 0) { + Text(calendar.shortStandaloneWeekdaySymbols[day.indexSundayFirst]) + .lineLimit(1) + .font(Typography.footnoteSemibold) + if isIncluded { + Image(.faCheck) + } else { + Image(.faCheck).hidden() } } + .frame(maxWidth: .infinity) + .padding(.top, 8) + .onTapGesture { + setDaysOfWeek(daysOfWeek.symmetricDifference([day])) + } + .background(isIncluded ? Color.key : Color.fill1) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .foregroundStyle(isIncluded ? Color.fill3 : Color.text.opacity(0.6)) + .accessibilityElement(children: .ignore) + .accessibilityChildren { + // .accessibilityAddTraits(.isToggle) is iOS 17+ only, so we use a real toggle + // labelled with the full name of the day + Toggle( + isOn: .init(get: { isIncluded }, set: { _ in + setDaysOfWeek(daysOfWeek.symmetricDifference([day])) + }), + label: { + Text(calendar.standaloneWeekdaySymbols[day.indexSundayFirst]) + } + ) + } } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .enableInjection() } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .enableInjection() } } struct NotificationSwitch: View { let settings: FavoriteSettings.Notifications - let setSettings: (FavoriteSettings.Notifications) -> Void + let onValueChanged: (Bool) -> Void let notificationPermissionManager: INotificationPermissionManager - let now: EasternTimeInstant - let presetWindowsEnabled: Bool - var authorizationStatus: UNAuthorizationStatus? { notificationPermissionManager.authorizationStatus } + let inspection = Inspection() + + @State var authorizationStatus: UNAuthorizationStatus? var body: some View { let enabledBinding = Binding( @@ -376,7 +363,7 @@ struct NotificationSwitch: View { settings.enabled }, set: { newValue in - setSettings(settings.doCopy(enabled: newValue, windows: settings.windows)) + onValueChanged(newValue) } ) @@ -434,45 +421,42 @@ struct NotificationSwitch: View { if enabled { let notificationPermission = await notificationPermissionManager.requestPermission() guard notificationPermission else { - setSettings(FavoriteSettings.Notifications.companion.disabled) + enabledBinding.wrappedValue = false return } - if settings.windows.count == 0 { - setSettings(settings.doCopy( - enabled: enabled, - windows: [FavoriteSettings.NotificationsWindow.companion.default( - existingWindows: [], - presetsEnabled: presetWindowsEnabled, - now: now - )] - )) - } } } } + .onAppear { + authorizationStatus = notificationPermissionManager.authorizationStatus + } + .onChange(of: notificationPermissionManager.authorizationStatus) { newStatus in + authorizationStatus = newStatus + } + .onReceive(inspection.notice) { inspection.visit(self, $0) } } } struct NotificationSettingsWidget_Previews: PreviewProvider { struct Holder: View { @ObserveInjection var inject - @State var settings: FavoriteSettings = .init(notifications: .init( - enabled: true, - windows: [FavoriteSettings.NotificationsWindow.companion.default( - existingWindows: [], - presetsEnabled: false, - now: EasternTimeInstant.now() - )] - )) + let windows = [FavoriteSettings.NotificationsWindow.companion.default( + existingWindows: [], + presetsEnabled: false, + now: EasternTimeInstant.now() + )] + + var vm = NotificationSettingsViewModel(sentryRepository: MockSentryRepository()) var body: some View { NotificationSettingsWidget( - settings: settings.notifications, - setSettings: { updatedSettings in - settings = settings.doCopy(notifications: updatedSettings) - }, + vm: vm, + onUpdate: { _ in }, notificationPermissionManager: MockNotificationPermissionManager() - ) + ).onAppear { + vm.setEnabled(enabled: true) + vm.setCustomWindows(windows: windows) + } .enableInjection() } } @@ -485,25 +469,6 @@ struct NotificationSettingsWidget_Previews: PreviewProvider { } } -extension DateComponents { - static func fromLocalTime(_ localTime: Kotlinx_datetimeLocalTime) -> Self { - .init( - hour: Int(localTime.hour), - minute: Int(localTime.minute), - second: Int(localTime.second) - ) - } - - func toLocalTime() -> Kotlinx_datetimeLocalTime { - .init( - hour: Int32(hour ?? 0), - minute: Int32(minute ?? 0), - second: Int32(second ?? 0), - nanosecond: Int32(nanosecond ?? 0) - ) - } -} - extension Kotlinx_datetimeLocalTime: @retroactive Comparable { public static func < (lhs: Kotlinx_datetimeLocalTime, rhs: Kotlinx_datetimeLocalTime) -> Bool { // Call the bridged Kotlin compareTo method diff --git a/iosApp/iosApp/Pages/SaveFavorite/PresetWindowSelector.swift b/iosApp/iosApp/Pages/SaveFavorite/PresetWindowSelector.swift index 1a0a4c987..deceab2f0 100644 --- a/iosApp/iosApp/Pages/SaveFavorite/PresetWindowSelector.swift +++ b/iosApp/iosApp/Pages/SaveFavorite/PresetWindowSelector.swift @@ -8,41 +8,28 @@ import SwiftUI struct PresetWindowSelector: View { @ObserveInjection var inject - let presetRows: [[PresetWindow]] - let selectedPreset: PresetSelection - let now: EasternTimeInstant - let customPreset: [FavoriteSettings.NotificationsWindow] - let onSelect: ([FavoriteSettings.NotificationsWindow]) -> Void - - init( - presetRows: [[PresetWindow]], - selectedPreset: PresetSelection, - now: EasternTimeInstant = .now(), - customPreset: [FavoriteSettings.NotificationsWindow], - onSelect: @escaping ([FavoriteSettings.NotificationsWindow]) -> Void - ) { - self.presetRows = presetRows - self.selectedPreset = selectedPreset - self.now = now - self.customPreset = customPreset - self.onSelect = onSelect - } + let presetRows: [[Preset]] + let selectedPreset: Preset? + let onSelect: (Preset?) -> Void var body: some View { let presetGrid = VStack { - ForEach(Array(presetRows.enumerated()), id: \.offset) { rowIndex, windows in + ForEach(presetRows, id: \.hashValue) { presets in HStack { - ForEach(Array(windows.enumerated()), id: \.element.label) { presetIndex, preset in - let isSelected = { - if case let .preset(preset) = onEnum(of: selectedPreset) { - return preset.rowIndex == rowIndex && preset.columnIndex == presetIndex - } - return false - }() + ForEach(presets, id: \.self) { preset in + let isSelected = preset == selectedPreset + let label = switch preset { + case .morning: NSLocalizedString("Morning", comment: "Notification window preset label") + case .midday: NSLocalizedString("Midday", comment: "Notification window preset label") + case .evening: NSLocalizedString("Evening", comment: "Notification window preset label") + case .allDay: NSLocalizedString("All day", comment: "Notification window preset label") + } PresetButton( isSelected: isSelected, - onSelect: { onSelect([preset.window]) }, - label: preset.label + onSelect: { + onSelect(preset) + }, + label: label ) .frame(maxWidth: .infinity) } @@ -51,9 +38,9 @@ struct PresetWindowSelector: View { HStack { PresetButton( - isSelected: selectedPreset == PresetSelection.Custom(), + isSelected: selectedPreset == nil, onSelect: { - onSelect(customPreset) + onSelect(nil) }, label: NSLocalizedString( "Custom", diff --git a/iosApp/iosApp/Pages/SaveFavorite/SaveFavoritePage.swift b/iosApp/iosApp/Pages/SaveFavorite/SaveFavoritePage.swift index 8a0de3012..bfbaecd1b 100644 --- a/iosApp/iosApp/Pages/SaveFavorite/SaveFavoritePage.swift +++ b/iosApp/iosApp/Pages/SaveFavorite/SaveFavoritePage.swift @@ -28,6 +28,8 @@ struct SaveFavoritePage: View { @State var favoritesLoaded: Bool = false @State var wasAdding: Bool = false + @State var notificationSettingsVM: INotificationSettingsViewModel + let inspection = Inspection() init( @@ -38,6 +40,7 @@ struct SaveFavoritePage: View { updateFavorites: @escaping ([RouteStopDirection: FavoriteSettings?]) -> Void, navCallbacks: NavigationCallbacks, toastVM: IToastViewModel = ViewModelDI().toast, + notificationSettingsVM: INotificationSettingsViewModel = ViewModelDI().notificationSettings, notificationPermissionManager: INotificationPermissionManager = NotificationPermissionManager(), ) { self.routeId = routeId @@ -46,8 +49,10 @@ struct SaveFavoritePage: View { self.context = context self.updateFavorites = updateFavorites self.navCallbacks = navCallbacks + self.notificationSettingsVM = notificationSettingsVM self.toastVM = toastVM self.notificationPermissionManager = notificationPermissionManager + self.notificationSettingsVM = notificationSettingsVM pendingSettings = .init() selectedDirection = initialSelectedDirection @@ -179,9 +184,9 @@ struct SaveFavoritePage: View { } : nil, ) NotificationSettingsWidget( - settings: pendingSettings.notifications, - setSettings: { updatedSettings in - pendingSettings = pendingSettings.doCopy(notifications: updatedSettings) + vm: notificationSettingsVM, + onUpdate: { newNotificationSettings in + pendingSettings = pendingSettings.doCopy(notifications: newNotificationSettings) }, notificationPermissionManager: notificationPermissionManager, ) @@ -197,7 +202,12 @@ struct SaveFavoritePage: View { } } } - .onAppear { resetPendingSettings() } + .onAppear { resetPendingSettings() + notificationSettingsVM.loadSavedSettings(settings: pendingSettings.notifications) + } + .onChange(of: pendingSettings) { newSettings in + notificationSettingsVM.loadSavedSettings(settings: newSettings.notifications) + } .onChange(of: selectedDirection) { _ in resetPendingSettings() } .onChange(of: favorites) { _ in resetPendingSettings() diff --git a/iosApp/iosApp/Utils/Modifiers/NotificationSettingsManageVMModifier.swift b/iosApp/iosApp/Utils/Modifiers/NotificationSettingsManageVMModifier.swift new file mode 100644 index 000000000..d780b7123 --- /dev/null +++ b/iosApp/iosApp/Utils/Modifiers/NotificationSettingsManageVMModifier.swift @@ -0,0 +1,51 @@ +// +// NotificationSettingsManageVMModifier.swift +// iosApp +// +// Created by Kayla Brady on 9/4/26. +// Copyright © 2026 MBTA. All rights reserved. +// + +import Shared +import SwiftUI + +struct NotificationSettingsManageVMModifier: ViewModifier { + var viewModel: INotificationSettingsViewModel + @Binding var vmState: NotificationSettingsViewModel.State? + var now: EasternTimeInstant + + @EnvironmentObject var settingsCache: SettingsCache + var presetWindowsEnabled: Bool { settingsCache.get(.notificationPresetWindows) } + + func body(content: Content) -> some View { + content + .task { + for await models in viewModel.models { + vmState = models + } + } + .onAppear { + viewModel.setNow(now: now) + viewModel.setPresetsEnabledFlag(enabled: presetWindowsEnabled) + } + .onChange(of: presetWindowsEnabled) { newVal in + viewModel.setPresetsEnabledFlag(enabled: newVal) + } + .enableInjection() + } +} + +public extension View { + func manageVM( + _ viewModel: INotificationSettingsViewModel, + _ state: Binding, + _ now: EasternTimeInstant? = nil + ) -> some View { + let now = now ?? EasternTimeInstant.now() + return modifier(NotificationSettingsManageVMModifier( + viewModel: viewModel, + vmState: state, + now: now, + )) + } +} diff --git a/iosApp/iosApp/Utils/NotificationPermissionUtil.swift b/iosApp/iosApp/Utils/NotificationPermissionUtil.swift index f479c9ea6..c455c0884 100644 --- a/iosApp/iosApp/Utils/NotificationPermissionUtil.swift +++ b/iosApp/iosApp/Utils/NotificationPermissionUtil.swift @@ -39,6 +39,7 @@ class NotificationPermissionManager: INotificationPermissionManager { AnalyticsProvider.shared.notificationsPermissionGranted() } else { AnalyticsProvider.shared.notificationsPermissionDenied() + authorizationStatus = .denied } return result } catch { diff --git a/iosApp/iosAppTests/Pages/SaveFavorite/NotificationSettingsWidgetTests.swift b/iosApp/iosAppTests/Pages/SaveFavorite/NotificationSettingsWidgetTests.swift index cfb07eefe..4c15efa9a 100644 --- a/iosApp/iosAppTests/Pages/SaveFavorite/NotificationSettingsWidgetTests.swift +++ b/iosApp/iosAppTests/Pages/SaveFavorite/NotificationSettingsWidgetTests.swift @@ -12,69 +12,19 @@ import SwiftUI import ViewInspector import XCTest -private struct NotificationSettingsWidgetHost: View { - @ObserveInjection var inject - let now: EasternTimeInstant - let notificationPermissionManager: INotificationPermissionManager - let onSettingsChange: (FavoriteSettings.Notifications) -> Void - @State private var settings: FavoriteSettings.Notifications - - let inspection = Inspection() - - init( - initialSettings: FavoriteSettings.Notifications, - notificationPermissionManager: INotificationPermissionManager, - now: EasternTimeInstant, - onSettingsChange: @escaping (FavoriteSettings.Notifications) -> Void - ) { - _settings = State(initialValue: initialSettings) - self.notificationPermissionManager = notificationPermissionManager - self.now = now - self.onSettingsChange = onSettingsChange - } - - var body: some View { - NotificationSettingsWidget( - settings: settings, - setSettings: { newSettings in - settings = newSettings - onSettingsChange(newSettings) - }, - notificationPermissionManager: notificationPermissionManager, - now: now - ) - .withFixedSettings([.notificationPresetWindows: true]) - .onReceive(inspection.notice) { inspection.visit(self, $0) } - .enableInjection() - } -} - final class NotificationSettingsWidgetTests: XCTestCase { - @MainActor func testAddTimePeriod() async throws { - var settings: FavoriteSettings.Notifications = .companion.disabled - let sut = NotificationSettingsWidget( - settings: settings, - setSettings: { newSettings in settings = newSettings }, + @MainActor func testEnable() throws { + let settings: FavoriteSettings.Notifications = .companion.disabled + var enabled = false + + let sut = NotificationSettingsWidgetPresetnationView( + state: .init(settings: settings, selectedPreset: nil), + setEnabled: { enabled = $0 }, notificationPermissionManager: MockNotificationPermissionManager() ).withFixedSettings([:]) try sut.inspect().find(text: "Get disruption notifications").find(ViewType.Toggle.self, relation: .parent).tap() - XCTAssertEqual(settings, .init(enabled: true, windows: [])) - - try sut.inspect().findAndCallOnChange(newValue: true) - try await Task.sleep(for: .seconds(1)) - - XCTAssertEqual( - settings, - .init( - enabled: true, - windows: [.init( - startTime: .init(hour: 8, minute: 0, second: 0, nanosecond: 0), - endTime: .init(hour: 9, minute: 0, second: 0, nanosecond: 0), - daysOfWeek: [.monday, .tuesday, .wednesday, .thursday, .friday] - )] - ) - ) + XCTAssertTrue(enabled) } func testAddSecondTimePeriod() throws { @@ -83,10 +33,11 @@ final class NotificationSettingsWidgetTests: XCTestCase { endTime: .init(hour: 2, minute: 0, second: 0, nanosecond: 0), daysOfWeek: [.thursday] ) - var settings: FavoriteSettings.Notifications = .init(enabled: true, windows: [firstWindow]) - let sut = NotificationSettingsWidget( - settings: settings, - setSettings: { newSettings in settings = newSettings }, + + var addedWindow = false + let sut = NotificationSettingsWidgetPresetnationView( + state: .init(settings: .init(enabled: true, windows: [firstWindow]), selectedPreset: nil), + addPlaceholderWindow: { addedWindow = true }, notificationPermissionManager: MockNotificationPermissionManager() ).withFixedSettings([:]) @@ -103,56 +54,38 @@ final class NotificationSettingsWidgetTests: XCTestCase { XCTAssertThrowsError(try sut.inspect().find(viewWithAccessibilityLabel: "Delete")) // ViewInspector as of 0.10.3 does not support accessibilityChildren so we can’t check the days of the week try sut.inspect().find(button: "Add another time period").tap() - XCTAssertEqual( - settings, - .init( - enabled: true, - windows: [ - firstWindow, - .init( - startTime: .init(hour: 12, minute: 0, second: 0, nanosecond: 0), - endTime: .init(hour: 13, minute: 0, second: 0, nanosecond: 0), - daysOfWeek: [.saturday, .sunday] - ), - ] - ) - ) + XCTAssertTrue(addedWindow) } func testDeleteButtonWhenTwoTimePeriods() throws { - var settings: FavoriteSettings.Notifications = .init( + let settings: FavoriteSettings.Notifications = .init( enabled: true, - windows: [ - .companion.morningDefault(daysOfWeek: FavoriteSettings.NotificationsWindow.companion.weekdays), - .companion.eveningDefault(daysOfWeek: [.monday]) - ] + windows: [.init(preset: .morning, daysOfWeek: [.monday]), + .init(preset: .evening, daysOfWeek: [.monday])] ) - let sut = NotificationSettingsWidget( - settings: settings, - setSettings: { newSettings in settings = newSettings }, + + let sut = NotificationSettingsWidgetPresetnationView( + state: .init(settings: settings, selectedPreset: nil), notificationPermissionManager: MockNotificationPermissionManager() - ).withFixedSettings([:]) + ) + .withFixedSettings([:]) XCTAssertNotNil(try sut.inspect().find(viewWithAccessibilityLabel: "Delete")) } - func testChangeTime() throws { - let now = EasternTimeInstant(year: 2026, month: .august, day: 27, hour: 12, minute: 30, second: 0) - + func testChangeStartTime() throws { var settings: FavoriteSettings.Notifications = .init( enabled: true, - windows: [.init( - startTime: .init(hour: 8, minute: 0, second: 0, nanosecond: 0), - endTime: .init(hour: 9, minute: 0, second: 0, nanosecond: 0), - daysOfWeek: [.monday, .tuesday, .wednesday, .thursday, .friday] - )] + windows: [.init(preset: .morning, + daysOfWeek: [.monday])] ) - let sut = NotificationSettingsWidget( - settings: settings, - setSettings: { newSettings in settings = newSettings }, - notificationPermissionManager: MockNotificationPermissionManager(), - now: now - ).withFixedSettings([:]) + var customWindows: [FavoriteSettings.NotificationsWindow] = [] + + let sut = NotificationSettingsWidgetPresetnationView(state: .init(settings: settings, selectedPreset: nil), + setCustomWindows: { customWindows = $0 }, + notificationPermissionManager: MockNotificationPermissionManager( + )) + .withFixedSettings([:]) try sut.inspect().find( ViewType.DatePicker.self, @@ -163,44 +96,70 @@ final class NotificationSettingsWidgetTests: XCTestCase { matching: .init(hour: 7, minute: 45), matchingPolicy: .strict ))) - XCTAssertEqual(settings.windows[0].startTime, .init(hour: 7, minute: 45, second: 0, nanosecond: 0)) + + XCTAssertEqual( + customWindows, + [.init( + startTime: .init(hour: 7, minute: 45, second: 0, nanosecond: 0), + endTime: Preset.morning.endTime, + daysOfWeek: [.monday] + )] + ) + } + + func testChangeEndTime() throws { + var settings: FavoriteSettings.Notifications = .init( + enabled: true, + windows: [.init(preset: .morning, + daysOfWeek: [.monday])] + ) + var customWindows: [FavoriteSettings.NotificationsWindow] = [] + + let sut = NotificationSettingsWidgetPresetnationView(state: .init(settings: settings, selectedPreset: nil), + setCustomWindows: { customWindows = $0 }, + notificationPermissionManager: MockNotificationPermissionManager( + )) + .withFixedSettings([:]) + try sut.inspect().find( ViewType.DatePicker.self, where: { try $0.labelView().text().string() == "Select end time" } ) .select(date: XCTUnwrap(Calendar(identifier: .iso8601).nextDate( after: .now, - matching: .init(hour: 9, minute: 10), + matching: .init(hour: 13, minute: 45), matchingPolicy: .strict ))) - XCTAssertEqual(settings.windows[0].endTime, .init(hour: 9, minute: 10, second: 0, nanosecond: 0)) + + XCTAssertEqual( + customWindows, + [.init( + startTime: Preset.morning.startTime, + endTime: .init(hour: 13, minute: 45, second: 0, nanosecond: 0), + daysOfWeek: [.monday] + )] + ) } func testChangeDays() throws { - let now = EasternTimeInstant(year: 2026, month: .august, day: 27, hour: 12, minute: 30, second: 0) - var settings: FavoriteSettings.Notifications = .init( enabled: true, - windows: [.init( - startTime: .init(hour: 8, minute: 0, second: 0, nanosecond: 0), - endTime: .init(hour: 9, minute: 0, second: 0, nanosecond: 0), - daysOfWeek: [.monday, .tuesday, .wednesday, .thursday, .friday] - )] + windows: [.init(preset: .morning, + daysOfWeek: [.monday])] ) - let sut = NotificationSettingsWidget( - settings: settings, - setSettings: { newSettings in settings = newSettings }, - notificationPermissionManager: MockNotificationPermissionManager(), - now: now - ).withFixedSettings([:]) + var customWindows: [FavoriteSettings.NotificationsWindow] = [] + + let sut = NotificationSettingsWidgetPresetnationView(state: .init(settings: settings, selectedPreset: nil), + setCustomWindows: { customWindows = $0 }, + notificationPermissionManager: MockNotificationPermissionManager( + )) + .withFixedSettings([:]) try sut.inspect().find(text: "Sun").find(ViewType.VStack.self, relation: .parent).callOnTapGesture() - XCTAssertEqual(settings.windows[0].daysOfWeek, [.sunday, .monday, .tuesday, .wednesday, .thursday, .friday]) + XCTAssertEqual(customWindows[0].daysOfWeek, [.sunday, .monday]) } func testValidatesTime() throws { - let now = EasternTimeInstant(year: 2026, month: .august, day: 27, hour: 12, minute: 30, second: 0) - var settings: FavoriteSettings.Notifications = .init( enabled: true, windows: [.init( @@ -209,11 +168,12 @@ final class NotificationSettingsWidgetTests: XCTestCase { daysOfWeek: [.monday, .tuesday, .wednesday, .thursday, .friday] )] ) - let sut = NotificationSettingsWidget( - settings: settings, - setSettings: { newSettings in settings = newSettings }, - notificationPermissionManager: MockNotificationPermissionManager(), - now: now + var customWindows: [FavoriteSettings.NotificationsWindow] = [] + + let sut = NotificationSettingsWidgetPresetnationView( + state: .init(settings: settings, selectedPreset: nil), + setCustomWindows: { customWindows = $0 }, + notificationPermissionManager: MockNotificationPermissionManager() ).withFixedSettings([:]) let calendar = Calendar(identifier: .iso8601) @@ -227,83 +187,76 @@ final class NotificationSettingsWidgetTests: XCTestCase { matching: .init(hour: 10, minute: 45), matchingPolicy: .strict ))) - XCTAssertEqual(settings.windows[0].startTime, .init(hour: 10, minute: 45, second: 0, nanosecond: 0)) - XCTAssertEqual(settings.windows[0].endTime, .init(hour: 11, minute: 45, second: 0, nanosecond: 0)) + XCTAssertEqual(customWindows[0].startTime, .init(hour: 10, minute: 45, second: 0, nanosecond: 0)) + XCTAssertEqual(customWindows[0].endTime, .init(hour: 11, minute: 45, second: 0, nanosecond: 0)) // ViewInspector appears not to expose or enforce valid ranges, so can’t test minimum end time } func testRequestsPermission() throws { - let now = EasternTimeInstant(year: 2026, month: .august, day: 27, hour: 12, minute: 30, second: 0) - let permissionExp = expectation(description: "permission was requested") + var enabled = false + var settings: FavoriteSettings.Notifications = .companion.disabled let permissionManager = MockNotificationPermissionManager( initialAuthorizationStatus: .notDetermined, requestPermissionResponse: true, onRequestPermission: { permissionExp.fulfill() } ) - let sut = NotificationSettingsWidget(settings: settings, - setSettings: { newSettings in settings = newSettings }, - notificationPermissionManager: permissionManager, - now: now) + + let sut = NotificationSettingsWidgetPresetnationView(state: .init(settings: settings, selectedPreset: nil), + setEnabled: { enabled = $0 }, + notificationPermissionManager: permissionManager) + ViewHosting.host(view: sut.withFixedSettings([:])) try sut.inspect().find(text: "Get disruption notifications").find(ViewType.Toggle.self, relation: .parent).tap() try sut.inspect().findAndCallOnChange(newValue: true) wait(for: [permissionExp]) - XCTAssertEqual(.init(enabled: true, windows: [.init( - startTime: .init(hour: 8, minute: 0, second: 0, nanosecond: 0), - endTime: .init(hour: 9, minute: 0, second: 0, nanosecond: 0), - daysOfWeek: [.monday, .tuesday, .wednesday, .thursday, .friday] - )]), settings) + XCTAssertTrue(enabled) XCTAssertEqual(.authorized, permissionManager.authorizationStatus) } - func testPermissionDenied() throws { - let permissionExp = expectation(description: "permission was requested") + @MainActor + func testPermissionDenied() { let settingsLinkExp = expectation(description: "settings link was tapped") - var settings: FavoriteSettings.Notifications = .companion.disabled + let settings: FavoriteSettings.Notifications = .companion.disabled let permissionManager = MockNotificationPermissionManager( - initialAuthorizationStatus: .notDetermined, + initialAuthorizationStatus: .denied, requestPermissionResponse: false, - onRequestPermission: { permissionExp.fulfill() }, + onRequestPermission: {}, onOpenSettings: { settingsLinkExp.fulfill() } ) - let sut = NotificationSettingsWidget(settings: settings, - setSettings: { newSettings in settings = newSettings }, - notificationPermissionManager: permissionManager) - ViewHosting.host(view: sut.withFixedSettings([:])) + let sut = NotificationSwitch( + settings: settings, + onValueChanged: { _ in }, + notificationPermissionManager: permissionManager + ) - try sut.inspect().find(text: "Get disruption notifications").find(ViewType.Toggle.self, relation: .parent).tap() - try sut.inspect().findAndCallOnChange(newValue: true) - wait(for: [permissionExp]) + let exp = sut.inspection.inspect(after: 2.0) { view in + XCTAssert(try view.find(text: "Get disruption notifications").find(ViewType.Toggle.self, relation: .parent) + .isDisabled()) + + try view.find(button: "Allow Notifications in Settings").tap() + } - XCTAssertEqual(.companion.disabled, settings) - XCTAssertEqual(.denied, permissionManager.authorizationStatus) - XCTAssert(try sut.inspect().find(text: "Get disruption notifications") - .find(ViewType.Toggle.self, relation: .parent).isDisabled()) - try sut.inspect().find(button: "Allow Notifications in Settings").tap() - wait(for: [settingsLinkExp]) - - permissionManager.updateAuthorizationStatus(nextStatus: .authorized) - XCTAssertFalse(try sut.inspect().find(text: "Get disruption notifications") - .find(ViewType.Toggle.self, relation: .parent).isDisabled()) - XCTAssertThrowsError(try sut.inspect().find(button: "Allow Notifications in Settings")) + ViewHosting.host(view: sut) + + wait(for: [exp, settingsLinkExp], timeout: 5) } func testPresetButtonsAreNotVisibleWhenFeatureFlagDisabled() throws { let settings: FavoriteSettings.Notifications = .init( enabled: true, - windows: [FavoriteSettings.NotificationsWindow.companion.morningDefault(daysOfWeek: [.monday])] + windows: [FavoriteSettings.NotificationsWindow(preset: .morning, daysOfWeek: [.monday])] ) - let sut = NotificationSettingsWidget( - settings: settings, - setSettings: { _ in }, - notificationPermissionManager: MockNotificationPermissionManager() - ).withFixedSettings([.notificationPresetWindows: false]) + + let sut = NotificationSettingsWidgetPresetnationView(state: .init(settings: settings, selectedPreset: nil), + notificationPermissionManager: MockNotificationPermissionManager( + )) + .withFixedSettings([.notificationPresetWindows: false]) XCTAssertThrowsError(try sut.inspect().find(button: "Morning")) } @@ -311,11 +264,11 @@ final class NotificationSettingsWidgetTests: XCTestCase { func testPresetButtonsAreVisibleWhenFeatureFlagEnabled() throws { let settings: FavoriteSettings.Notifications = .init( enabled: true, - windows: [FavoriteSettings.NotificationsWindow.companion.morningDefault(daysOfWeek: [.monday])] + windows: [FavoriteSettings.NotificationsWindow(preset: .morning, daysOfWeek: [.monday])] ) - let sut = NotificationSettingsWidget( - settings: settings, - setSettings: { _ in }, + + let sut = NotificationSettingsWidgetPresetnationView( + state: .init(settings: settings, selectedPreset: nil), notificationPermissionManager: MockNotificationPermissionManager() ).withFixedSettings([.notificationPresetWindows: true]) @@ -325,29 +278,4 @@ final class NotificationSettingsWidgetTests: XCTestCase { XCTAssertNotNil(try sut.inspect().find(button: "All day")) XCTAssertNotNil(try sut.inspect().find(button: "Custom")) } - - @MainActor func testSelectsPresetMatchingCurrentTime() async throws { - let now = EasternTimeInstant(year: 2026, month: .august, day: 27, hour: 12, minute: 30, second: 0) - var settings: FavoriteSettings.Notifications = .companion.disabled - let widget = NotificationSettingsWidget( - settings: settings, - setSettings: { newSettings in settings = newSettings }, - notificationPermissionManager: MockNotificationPermissionManager(), - now: now - ) - let sut = widget.withFixedSettings([.notificationPresetWindows: true]) - - try sut.inspect().find(text: "Get disruption notifications").find(ViewType.Toggle.self, relation: .parent).tap() - try sut.inspect().findAndCallOnChange(newValue: true) - try await Task.sleep(for: .seconds(1)) - - XCTAssertEqual( - .init( - enabled: true, - windows: [FavoriteSettings.NotificationsWindow.companion - .middayDefault(daysOfWeek: FavoriteSettings.NotificationsWindow.companion.weekdays)] - ), - settings - ) - } } diff --git a/iosApp/iosAppTests/Pages/SaveFavorite/PresetWindowSelectorTests.swift b/iosApp/iosAppTests/Pages/SaveFavorite/PresetWindowSelectorTests.swift index b4c110d04..034d52b1b 100644 --- a/iosApp/iosAppTests/Pages/SaveFavorite/PresetWindowSelectorTests.swift +++ b/iosApp/iosAppTests/Pages/SaveFavorite/PresetWindowSelectorTests.swift @@ -14,25 +14,12 @@ import XCTest final class PresetWindowSelectorTests: XCTestCase { func testPresetWindowsVisible() { - var selectedWindows: [FavoriteSettings.NotificationsWindow]? + var selectedPreset: Preset? let sut = PresetWindowSelector( - presetRows: [[ - .init( - label: "Morning", - window: .companion - .morningDefault(daysOfWeek: FavoriteSettings.NotificationsWindow.companion.weekdays) - ), - .init( - label: "Midday", - window: .companion - .middayDefault(daysOfWeek: FavoriteSettings.NotificationsWindow.companion.weekdays) - ) - ]], - selectedPreset: .Preset(rowIndex: 1, columnIndex: 0), - customPreset: [FavoriteSettings.NotificationsWindow.companion - .eveningDefault(daysOfWeek: FavoriteSettings.NotificationsWindow.companion.weekend)], - onSelect: { windows in selectedWindows = windows } + presetRows: [[.morning, .midday]], + selectedPreset: .midday, + onSelect: { preset in selectedPreset = preset } ) XCTAssertNotNil(try sut.inspect().find(button: "Morning")) @@ -41,39 +28,19 @@ final class PresetWindowSelectorTests: XCTestCase { try? sut.inspect().find(button: "Morning").tap() - XCTAssertEqual( - selectedWindows, - [.companion.morningDefault(daysOfWeek: FavoriteSettings.NotificationsWindow.companion.weekdays)] - ) + XCTAssertEqual(selectedPreset, .morning) } - func testCustomUsesProvidedCustomPreset() { - var selectedWindows: [FavoriteSettings.NotificationsWindow] = [] - let customWindow = FavoriteSettings.NotificationsWindow( - startTime: .init(hour: 10, minute: 15, second: 0, nanosecond: 0), - endTime: .init(hour: 11, minute: 45, second: 0, nanosecond: 0), - daysOfWeek: [.sunday, .tuesday] - ) + func testCustomSelectsNil() { + var selectedPreset: Preset? = .morning let sut = PresetWindowSelector( - presetRows: [[ - .init( - label: "Morning", - window: .companion - .morningDefault(daysOfWeek: FavoriteSettings.NotificationsWindow.companion.weekdays) - ), - .init( - label: "Midday", - window: .companion - .middayDefault(daysOfWeek: FavoriteSettings.NotificationsWindow.companion.weekdays) - ) - ]], - selectedPreset: .Preset(rowIndex: 1, columnIndex: 0), - customPreset: [customWindow], - onSelect: { windows in selectedWindows = windows } + presetRows: [[.morning, .midday]], + selectedPreset: selectedPreset, + onSelect: { preset in selectedPreset = preset } ) try? sut.inspect().find(button: "Custom").tap() - XCTAssertEqual(selectedWindows, [customWindow]) + XCTAssertEqual(nil, selectedPreset) } } diff --git a/iosApp/iosAppTests/Pages/SaveFavorite/SaveFavoritePageTests.swift b/iosApp/iosAppTests/Pages/SaveFavorite/SaveFavoritePageTests.swift index b19ad3d8d..6a216ca4c 100644 --- a/iosApp/iosAppTests/Pages/SaveFavorite/SaveFavoritePageTests.swift +++ b/iosApp/iosAppTests/Pages/SaveFavorite/SaveFavoritePageTests.swift @@ -110,7 +110,7 @@ final class SaveFavoritePageTests: XCTestCase { } } - @MainActor func testNotifications() { + @MainActor func testsLoadsNotificationSettings() { let objects = TestData.clone() let route = objects.getRoute(id: "Orange") let stop = objects.getStop(id: "place-welln") @@ -118,6 +118,14 @@ final class SaveFavoritePageTests: XCTestCase { loadKoinMocks(objects: objects) + var settingsLoadedCalled = false + + let notificationSettingsVM: MockNotificationSettingsViewModel = .init(initialState: .init( + settings: FavoriteSettings.Notifications.companion.disabled, + selectedPreset: nil + )) + notificationSettingsVM.onLoadSavedSettings = { _ in settingsLoadedCalled = true } + let sut = SaveFavoritePage( routeId: route.id, stopId: stop.id, @@ -125,25 +133,16 @@ final class SaveFavoritePageTests: XCTestCase { context: .stopDetails, updateFavorites: { updatedFavorites = $0 }, navCallbacks: .init(onBack: nil, onClose: nil, backButtonPresentation: .floating), + notificationSettingsVM: notificationSettingsVM ) let exp1 = sut.inspection.inspect(after: 1) { view in XCTAssertNotNil(try view.find(text: "Add Favorite")) - try view.find(text: "Get disruption notifications").find(ViewType.Toggle.self, relation: .parent).tap() - try view.find(button: "Save").tap() - } - - let exp2 = sut.inspection.inspect(after: 2) { _ in - XCTAssertEqual(updatedFavorites, [ - .init(route: route.id, stop: stop.id, direction: 0): .init(notifications: .init( - enabled: true, - windows: [] - )), - ]) + XCTAssertTrue(settingsLoadedCalled) } ViewHosting.host(view: sut.withFixedSettings([:])) - wait(for: [exp1, exp2], timeout: 5) + wait(for: [exp1], timeout: 5) } } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Favorite.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Favorite.kt index 5764c4704..21af95f20 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Favorite.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Favorite.kt @@ -103,18 +103,6 @@ constructor(val notifications: Notifications = Notifications.disabled) { } } - public fun morningDefault(daysOfWeek: Set): Window = - Window(LocalTime(6, 0), LocalTime(10, 0), daysOfWeek) - - public fun middayDefault(daysOfWeek: Set): Window = - Window(LocalTime(10, 0), LocalTime(16, 0), daysOfWeek) - - public fun eveningDefault(daysOfWeek: Set): Window = - Window(LocalTime(16, 0), LocalTime(20, 0), daysOfWeek) - - public fun allDayDefault(daysOfWeek: Set): Window = - Window(LocalTime(0, 0), LocalTime(23, 59), daysOfWeek) - public fun defaultFromCurrentTime(now: EasternTimeInstant): Window { val daysOfWeek = defaultDaysOfWeek(now) val presets = diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/NotificationWindowPresets.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/NotificationWindowPresets.kt index 8437ae7a1..5857c1d1b 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/NotificationWindowPresets.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/NotificationWindowPresets.kt @@ -1,75 +1,8 @@ package com.mbta.tid.mbta_app.model import com.mbta.tid.mbta_app.model.FavoriteSettings.Notifications.Window -import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalTime -public class PresetWindow( - public val label: String, - public val window: Window, -) { - public companion object { - - public fun morningPreset(label: String, daysOfWeek: Set): PresetWindow = - PresetWindow( - label = label, - window = Window(Preset.Morning, daysOfWeek), - ) - - public fun middayPreset(label: String, daysOfWeek: Set): PresetWindow = - PresetWindow( - label = label, - window = Window(Preset.Midday, daysOfWeek), - ) - - public fun eveningPreset(label: String, daysOfWeek: Set): PresetWindow = - PresetWindow( - label = label, - window = Window(Preset.Evening, daysOfWeek), - ) - - public fun allDayPreset(label: String, daysOfWeek: Set): PresetWindow = - PresetWindow( - label = label, - window = Window(Preset.AllDay, daysOfWeek), - ) - } -} - -public sealed class PresetSelection { - public data class Preset(val rowIndex: Int, val columnIndex: Int) : PresetSelection() - - public object Custom : PresetSelection() - - public companion object { - public fun selectedPresetFromWindows( - windows: List, - presetOptions: List>, - ): PresetSelection = - when { - windows.size == 1 -> { - val targetWindow: Window = windows[0] - presetOptions - .asSequence() - .mapIndexedNotNull { rowIndex, presets -> - val presetMatchIndex = presets.indexOfFirst { - it.window == targetWindow - } - if (presetMatchIndex != -1) { - PresetSelection.Preset(rowIndex, presetMatchIndex) - } else { - null - } - } - .firstOrNull() ?: PresetSelection.Custom - } - else -> { - PresetSelection.Custom - } - } - } -} - public enum class Preset(public val startTime: LocalTime, public val endTime: LocalTime) { Morning(LocalTime(6, 0), LocalTime(10, 0)), Midday(LocalTime(10, 0), LocalTime(16, 0)),