├── CODEOWNERS ├── api ├── .gitignore └── src │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── sixbynine │ │ └── transit │ │ └── path │ │ ├── api │ │ ├── NetworkException.kt │ │ ├── PathApiException.kt │ │ ├── Coordinates.kt │ │ ├── StationChoice.kt │ │ ├── HttpClientFactory.kt │ │ ├── schedule │ │ │ ├── GithubSchedules.kt │ │ │ └── GithubScheduleRepository.kt │ │ ├── alerts │ │ │ ├── github │ │ │ │ ├── GithubAlerts.kt │ │ │ │ └── GithubAlertsRepository.kt │ │ │ ├── everbridge │ │ │ │ └── EverbridgeAlertsRepository.kt │ │ │ └── AlertsRepository.kt │ │ ├── UpcomingDepartures.kt │ │ ├── Line.kt │ │ ├── PathApi.kt │ │ ├── Route.kt │ │ ├── DepartingTrain.kt │ │ ├── State.kt │ │ └── templine │ │ │ ├── HobClosureConfigRepository.kt │ │ │ └── HobClosureConfig.kt │ │ ├── location │ │ └── Location.kt │ │ ├── util │ │ ├── TestRemoteFileProvider.kt │ │ ├── Serialization.kt │ │ ├── RemoteFileReading.kt │ │ ├── RemoteFileRepository.kt │ │ └── DataSource.kt │ │ └── model │ │ ├── Season.kt │ │ └── Colors.kt │ ├── jvmMain │ └── kotlin │ │ └── com │ │ └── sixbynine │ │ └── transit │ │ └── path │ │ └── api │ │ └── HttpClientFactory.jvm.kt │ ├── iosMain │ └── kotlin │ │ └── com │ │ └── sixbynine │ │ └── transit │ │ └── path │ │ └── api │ │ └── IosHttpClientFactory.kt │ ├── commonTest │ └── kotlin │ │ └── com │ │ └── sixbynine │ │ └── transit │ │ └── path │ │ ├── HobClosureRepoTest.kt │ │ └── api │ │ └── StationsTest.kt │ ├── androidMain │ └── kotlin │ │ └── com │ │ └── sixbynine │ │ └── transit │ │ └── path │ │ └── api │ │ └── AndroidHttpClientFactory.kt │ └── androidUnitTest │ └── resources │ └── com │ └── sixbynine │ └── transit │ └── path │ └── everbridge_alert_jan18.json ├── flipper ├── .gitignore ├── src │ ├── androidRelease │ │ └── kotlin │ │ │ └── com │ │ │ └── sixbynine │ │ │ └── transit │ │ │ └── path │ │ │ └── flipper │ │ │ └── FlipperUtil.kt │ └── androidDebug │ │ └── kotlin │ │ └── com │ │ └── sixbynine │ │ └── transit │ │ └── path │ │ └── flipper │ │ └── FlipperUtil.kt └── build.gradle.kts ├── logging ├── .gitignore ├── src │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── sixbynine │ │ └── transit │ │ └── path │ │ └── NonFatalReporter.kt └── build.gradle.kts ├── platform ├── .gitignore ├── src │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── sixbynine │ │ │ └── transit │ │ │ └── path │ │ │ ├── util │ │ │ ├── IsTest.kt │ │ │ ├── Staleness.kt │ │ │ ├── TimestampedValue.kt │ │ │ ├── CoroutineScopes.kt │ │ │ ├── AgedValue.kt │ │ │ ├── NonEmptyList.kt │ │ │ └── GlobalExtensions.kt │ │ │ ├── platform │ │ │ └── Platform.kt │ │ │ ├── network │ │ │ └── NetworkManager.kt │ │ │ ├── time │ │ │ ├── UserPreferenceDayOfWeekComparator.kt │ │ │ └── TimeUtils.kt │ │ │ └── preferences │ │ │ └── IntPersistable.kt │ ├── jvmMain │ │ └── kotlin │ │ │ └── com │ │ │ └── sixbynine │ │ │ └── transit │ │ │ └── path │ │ │ ├── platform │ │ │ └── Platform.jvm.kt │ │ │ ├── preferences │ │ │ └── Preferences.jvm.kt │ │ │ ├── network │ │ │ └── NetworkManager.jvm.kt │ │ │ ├── time │ │ │ └── TimeUtils.jvm.kt │ │ │ └── util │ │ │ └── GlobalDataStore.jvm.kt │ ├── iosMain │ │ └── kotlin │ │ │ └── com │ │ │ └── sixbynine │ │ │ └── transit │ │ │ └── path │ │ │ ├── platform │ │ │ └── Platform.ios.kt │ │ │ ├── network │ │ │ └── IosNetworkManager.kt │ │ │ ├── time │ │ │ └── NativeTimeUtils.kt │ │ │ └── util │ │ │ └── GlobalDataStore.ios.kt │ ├── androidMain │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── sixbynine │ │ │ │ └── transit │ │ │ │ └── path │ │ │ │ ├── platform │ │ │ │ └── Platform.android.kt │ │ │ │ ├── PreviewContext.kt │ │ │ │ ├── PathApplication.kt │ │ │ │ ├── time │ │ │ │ └── AndroidTimeUtils.kt │ │ │ │ └── network │ │ │ │ └── AndroidNetworkManager.kt │ │ └── AndroidManifest.xml │ └── commonTest │ │ └── kotlin │ │ └── com │ │ └── sixbynine │ │ └── transit │ │ └── path │ │ └── time │ │ └── UserPreferenceDayOfWeekComparatorTest.kt └── build.gradle.kts ├── schedule ├── .gitignore ├── src │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── sixbynine │ │ │ └── transit │ │ │ └── path │ │ │ └── schedule │ │ │ └── Schedule.kt │ └── commonTest │ │ └── kotlin │ │ ├── Departures.kt │ │ └── DeparturesParsing.kt ├── generator │ ├── src │ │ ├── test │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── sixbynine │ │ │ │ └── transit │ │ │ │ └── path │ │ │ │ └── schedule │ │ │ │ └── generator │ │ │ │ ├── ScheduleParserTest.kt │ │ │ │ └── ScheduleHtmlParserTest.kt │ │ └── main │ │ │ └── kotlin │ │ │ └── com │ │ │ └── sixbynine │ │ │ └── transit │ │ │ └── path │ │ │ └── schedule │ │ │ └── generator │ │ │ └── ScheduleGenerator.kt │ └── build.gradle.kts └── build.gradle.kts ├── schedule-generator ├── .gitignore └── requirements.txt ├── composeApp ├── .gitignore ├── path_app_icon.icns ├── path_app_icon.ico └── src │ ├── androidMain │ ├── res │ │ ├── values-es │ │ │ └── strings.xml │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── xml │ │ │ ├── file_paths.xml │ │ │ ├── locales_config.xml │ │ │ └── departure_widget.xml │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── values │ │ │ ├── strings.xml │ │ │ ├── styles.xml │ │ │ ├── colors.xml │ │ │ └── themes.xml │ │ ├── drawable-nodpi │ │ │ └── widget_preview.png │ │ ├── values-en │ │ │ └── widget_strings.xml │ │ ├── values-night │ │ │ ├── themes.xml │ │ │ └── colors.xml │ │ ├── drawable │ │ │ ├── ic_edit_inset.xml │ │ │ ├── ic_refresh_inset.xml │ │ │ ├── ic_warning_inset.xml │ │ │ ├── circle.xml │ │ │ ├── preview_circle_hob.xml │ │ │ ├── preview_circle_nwk.xml │ │ │ ├── circle_border.xml │ │ │ ├── circle_right.xml │ │ │ ├── ic_filter.xml │ │ │ ├── ic_sort.xml │ │ │ ├── ic_arrow_up.xml │ │ │ ├── ic_arrow_down.xml │ │ │ ├── ic_down.xml │ │ │ ├── ic_open_in_new.xml │ │ │ ├── ic_station.xml │ │ │ ├── ic_warning.xml │ │ │ ├── ic_edit.xml │ │ │ ├── ic_refresh.xml │ │ │ ├── ic_one_column.xml │ │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── layout │ │ │ └── widget_loading.xml │ │ └── xml-v27 │ │ │ └── departure_widget.xml │ ├── kotlin │ │ ├── Platform.android.kt │ │ └── com │ │ │ └── sixbynine │ │ │ └── transit │ │ │ └── path │ │ │ ├── widget │ │ │ ├── ui │ │ │ │ └── WidgetState.kt │ │ │ ├── UpdateWidgetAction.kt │ │ │ ├── glance │ │ │ │ ├── GlanceResources.kt │ │ │ │ ├── Text.kt │ │ │ │ ├── Theme.kt │ │ │ │ ├── ImageButton.kt │ │ │ │ └── Typography.kt │ │ │ ├── StationDataComparator.kt │ │ │ ├── configuration │ │ │ │ └── StoredWidgetConfiguration.kt │ │ │ ├── StartConfigurationActivityAction.kt │ │ │ ├── GlanceExtensions.kt │ │ │ └── WidgetMeasurements.kt │ │ │ ├── app │ │ │ ├── ui │ │ │ │ ├── setup │ │ │ │ │ └── SetupScreenPreview.kt │ │ │ │ ├── advancessettings │ │ │ │ │ └── AdvancedSettingsPreview.kt │ │ │ │ └── settings │ │ │ │ │ └── SettingsScreenPreview.kt │ │ │ └── settings │ │ │ │ └── DevOptionsExport.android.kt │ │ │ ├── analytics │ │ │ └── AndroidAnalytics.kt │ │ │ ├── BaseActivity.kt │ │ │ ├── MainActivity.kt │ │ │ └── MobilePathApplication.kt │ └── .gitignore │ ├── commonMain │ ├── kotlin │ │ ├── com │ │ │ └── sixbynine │ │ │ │ └── transit │ │ │ │ └── path │ │ │ │ ├── api │ │ │ │ ├── impl │ │ │ │ │ └── GithubAlertHelper.kt │ │ │ │ ├── LocationSetting.kt │ │ │ │ ├── StationSort.kt │ │ │ │ └── LineExtensions.kt │ │ │ │ ├── app │ │ │ │ ├── ui │ │ │ │ │ ├── SizeWrapper.kt │ │ │ │ │ ├── sheet │ │ │ │ │ │ └── BottomSheetTitle.kt │ │ │ │ │ ├── FontInfo.kt │ │ │ │ │ ├── AppUiScope.kt │ │ │ │ │ ├── theme │ │ │ │ │ │ └── Dimensions.kt │ │ │ │ │ ├── setup │ │ │ │ │ │ ├── SetupScreenContract.kt │ │ │ │ │ │ └── SetupScreenViewModel.kt │ │ │ │ │ ├── layout │ │ │ │ │ │ └── LayoutOption.kt │ │ │ │ │ ├── common │ │ │ │ │ │ └── AppUiTrainData.kt │ │ │ │ │ ├── ColorRect.kt │ │ │ │ │ ├── station │ │ │ │ │ │ └── StationContract.kt │ │ │ │ │ ├── BaseViewModel.kt │ │ │ │ │ ├── settings │ │ │ │ │ │ ├── StationLimitBottomSheet.kt │ │ │ │ │ │ ├── TrainFilterBottomSheet.kt │ │ │ │ │ │ ├── TimeDisplayBottomSheet.kt │ │ │ │ │ │ ├── LineFilterBottomSheet.kt │ │ │ │ │ │ └── StationSortBottomSheet.kt │ │ │ │ │ ├── home │ │ │ │ │ │ └── TrainLine.kt │ │ │ │ │ ├── PathViewModel.kt │ │ │ │ │ ├── ViewModelScreen.kt │ │ │ │ │ └── icon │ │ │ │ │ │ └── NativeIcon.kt │ │ │ │ ├── settings │ │ │ │ │ ├── DevOptionsExport.kt │ │ │ │ │ ├── TimeDisplay.kt │ │ │ │ │ ├── AvoidMissingTrains.kt │ │ │ │ │ ├── StationLimit.kt │ │ │ │ │ ├── AppSettings.kt │ │ │ │ │ └── CommutingConfiguration.kt │ │ │ │ ├── station │ │ │ │ │ └── StationSelection.kt │ │ │ │ ├── lifecycle │ │ │ │ │ └── AppLifecycleObserver.kt │ │ │ │ └── external │ │ │ │ │ └── ExternalRoutingManager.kt │ │ │ │ ├── widget │ │ │ │ ├── WidgetReloader.kt │ │ │ │ ├── StationByDisplayNameComparator.kt │ │ │ │ └── PathWidgetConfiguration.kt │ │ │ │ ├── util │ │ │ │ ├── ComposeExtensions.kt │ │ │ │ ├── CollectionExtensions.kt │ │ │ │ ├── TimeUtilities.kt │ │ │ │ └── Localization.kt │ │ │ │ └── native │ │ │ │ └── NativeHolder.kt │ │ ├── Greeting.kt │ │ └── Platform.kt │ └── composeResources │ │ └── drawable │ │ └── train_track.png │ ├── iosMain │ └── kotlin │ │ ├── MainViewController.kt │ │ ├── com │ │ └── sixbynine │ │ │ └── transit │ │ │ └── path │ │ │ └── analytics │ │ │ └── IosAnalytics.kt │ │ └── Platform.ios.kt │ ├── desktopMain │ ├── kotlin │ │ ├── com │ │ │ └── sixbynine │ │ │ │ └── transit │ │ │ │ └── path │ │ │ │ ├── app │ │ │ │ ├── settings │ │ │ │ │ └── DevOptionsExport.jvm.kt │ │ │ │ └── external │ │ │ │ │ └── ExternalRoutingManager.jvm.kt │ │ │ │ ├── analytics │ │ │ │ └── Analytics.jvm.kt │ │ │ │ └── location │ │ │ │ └── LocationProvider.jvm.kt │ │ ├── Platform.jvm.kt │ │ └── main.kt │ └── composeResources │ │ └── drawable │ │ ├── ic_filter.xml │ │ ├── ic_sort.xml │ │ ├── ic_arrow_up.xml │ │ ├── ic_arrow_down.xml │ │ ├── ic_down.xml │ │ ├── ic_open_in_new.xml │ │ ├── ic_station.xml │ │ └── ic_one_column.xml │ └── commonTest │ └── kotlin │ └── com │ └── sixbynine │ └── transit │ └── path │ ├── app │ └── ui │ │ └── ColorsTest.kt │ └── api │ └── impl │ └── CheckpointMapTest.kt ├── .fleet ├── settings.json └── receipt.json ├── iosApp ├── Configuration │ └── Config.xcconfig ├── iosApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── path_app_icon.png │ │ │ └── Contents.json │ ├── Media.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── path_app_icon.png │ │ │ └── Contents.json │ ├── LegacyAssets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── path_app_icon.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── LegacyAssets copy.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── path_app_icon.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ ├── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ ├── path_app_icon.png │ │ │ └── Contents.json │ ├── Localizable.strings │ ├── iOSApp.swift │ ├── iosApp.entitlements │ ├── WidgetReloader.swift │ ├── HomeView.swift │ ├── ContentView.swift │ └── InfoPlist.xcstrings ├── widget │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── path_app_icon.png │ │ │ └── Contents.json │ │ ├── FallBackground.imageset │ │ │ ├── image 39.png │ │ │ └── Contents.json │ │ ├── SpringBackground.imageset │ │ │ ├── image 37.png │ │ │ └── Contents.json │ │ ├── SummerBackground.imageset │ │ │ ├── image 38.png │ │ │ └── Contents.json │ │ ├── WinterBackground.imageset │ │ │ ├── image 40.png │ │ │ └── Contents.json │ │ ├── WidgetBackground.colorset │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Media.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── path_app_icon.png │ │ │ └── Contents.json │ ├── FetchResult.swift │ ├── Info.plist │ ├── RefreshIntent.swift │ ├── WidgetConfigurationUtils.swift │ ├── EmptyDepartureBoardView.swift │ ├── PathWidgetBundle.swift │ ├── EntryView.swift │ ├── StationTitle.swift │ ├── ColorCircle.swift │ ├── SeasonalUtils.swift │ └── Interop.swift └── widgetExtension.entitlements ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── test ├── src │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── sixbynine │ │ └── transit │ │ └── path │ │ └── test │ │ ├── Ridepath.kt │ │ ├── TestSetupHelper.kt │ │ ├── TestPreferences.kt │ │ └── TestRemoteFileProviderImpl.kt └── build.gradle.kts ├── hob_closure.json ├── .gitignore ├── gradle.properties ├── settings.gradle.kts ├── .github └── workflows │ └── update-schedule.yml └── README.md /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @steviek -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /flipper/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /logging/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /platform/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /schedule/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /schedule-generator/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ -------------------------------------------------------------------------------- /composeApp/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release 3 | -------------------------------------------------------------------------------- /.fleet/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /schedule-generator/requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4 2 | requests 3 | -------------------------------------------------------------------------------- /composeApp/path_app_icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/path_app_icon.icns -------------------------------------------------------------------------------- /composeApp/path_app_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/path_app_icon.ico -------------------------------------------------------------------------------- /iosApp/Configuration/Config.xcconfig: -------------------------------------------------------------------------------- 1 | TEAM_ID= 2 | BUNDLE_ID=com.sixbynine.transit.path 3 | APP_NAME=Departures for PATH 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iosApp/iosApp/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iosApp/widget/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iosApp/widget/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iosApp/iosApp/LegacyAssets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iosApp/iosApp/LegacyAssets copy.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /platform/src/commonMain/kotlin/com/sixbynine/transit/path/util/IsTest.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | var IsTest = false 4 | -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /platform/src/commonMain/kotlin/com/sixbynine/transit/path/platform/Platform.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.platform 2 | 3 | expect val IsDebug: Boolean 4 | -------------------------------------------------------------------------------- /platform/src/jvmMain/kotlin/com/sixbynine/transit/path/platform/Platform.jvm.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.platform 2 | 3 | actual val IsDebug = true 4 | -------------------------------------------------------------------------------- /platform/src/iosMain/kotlin/com/sixbynine/transit/path/platform/Platform.ios.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.platform 2 | 3 | actual val IsDebug: Boolean = false -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values-es/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Mostrar las salidas de los trenes PATH 3 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/api/impl/GithubAlertHelper.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api.impl 2 | 3 | class GithubAlertHelper { 4 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/xml/file_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Display the upcoming departure times for PATH trains 3 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/MainViewController.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.ui.window.ComposeUIViewController 2 | 3 | fun MainViewController() = ComposeUIViewController { App() } 4 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable-nodpi/widget_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/androidMain/res/drawable-nodpi/widget_preview.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values-en/widget_strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Display the upcoming departure times for PATH trains 3 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/train_track.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/composeApp/src/commonMain/composeResources/drawable/train_track.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/path_app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/path_app_icon.png -------------------------------------------------------------------------------- /iosApp/iosApp/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | iosApp 4 | 5 | Created by Steven Kideckel on 2024-03-24. 6 | Copyright © 2024 orgName. All rights reserved. 7 | */ 8 | -------------------------------------------------------------------------------- /iosApp/iosApp/Media.xcassets/AppIcon.appiconset/path_app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/iosApp/iosApp/Media.xcassets/AppIcon.appiconset/path_app_icon.png -------------------------------------------------------------------------------- /iosApp/widget/Assets.xcassets/AppIcon.appiconset/path_app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/iosApp/widget/Assets.xcassets/AppIcon.appiconset/path_app_icon.png -------------------------------------------------------------------------------- /iosApp/widget/Assets.xcassets/FallBackground.imageset/image 39.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/iosApp/widget/Assets.xcassets/FallBackground.imageset/image 39.png -------------------------------------------------------------------------------- /iosApp/widget/Media.xcassets/AppIcon.appiconset/path_app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/iosApp/widget/Media.xcassets/AppIcon.appiconset/path_app_icon.png -------------------------------------------------------------------------------- /platform/src/androidMain/kotlin/com/sixbynine/transit/path/platform/Platform.android.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.platform 2 | 3 | actual val IsDebug: Boolean = BuildConfig.DEBUG 4 | -------------------------------------------------------------------------------- /test/src/commonMain/kotlin/com/sixbynine/transit/path/test/Ridepath.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.test 2 | 3 | internal val RidePath = """ 4 | {"results":[]} 5 | """.trimIndent() -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/NetworkException.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api 2 | 3 | class NetworkException(message: String) : RuntimeException(message) 4 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/location/Location.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.location 2 | 3 | data class Location(val latitude: Double, val longitude: Double) 4 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/SizeWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui 2 | 3 | data class SizeWrapper(val width: Double, val height: Double) 4 | -------------------------------------------------------------------------------- /iosApp/widget/Assets.xcassets/SpringBackground.imageset/image 37.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/iosApp/widget/Assets.xcassets/SpringBackground.imageset/image 37.png -------------------------------------------------------------------------------- /iosApp/widget/Assets.xcassets/SummerBackground.imageset/image 38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/iosApp/widget/Assets.xcassets/SummerBackground.imageset/image 38.png -------------------------------------------------------------------------------- /iosApp/widget/Assets.xcassets/WinterBackground.imageset/image 40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/iosApp/widget/Assets.xcassets/WinterBackground.imageset/image 40.png -------------------------------------------------------------------------------- /logging/src/commonMain/kotlin/com/sixbynine/transit/path/NonFatalReporter.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path 2 | 3 | fun interface NonFatalReporter { 4 | fun report(e: Throwable) 5 | } 6 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/Greeting.kt: -------------------------------------------------------------------------------- 1 | class Greeting { 2 | private val platform = getPlatform() 3 | 4 | fun greet(): String { 5 | return "Hello, ${platform.name}!" 6 | } 7 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/widget/WidgetReloader.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget 2 | 3 | interface WidgetReloader { 4 | fun reloadWidgets() 5 | } 6 | -------------------------------------------------------------------------------- /iosApp/iosApp/LegacyAssets.xcassets/AppIcon.appiconset/path_app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steviek/PathWidgetXplat/HEAD/iosApp/iosApp/LegacyAssets.xcassets/AppIcon.appiconset/path_app_icon.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 10 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable/ic_station.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable/ic_warning.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/layout/widget_loading.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 12 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/theme/Dimensions.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.theme 2 | 3 | import androidx.compose.foundation.layout.BoxWithConstraintsScope 4 | import androidx.compose.ui.unit.Dp 5 | import androidx.compose.ui.unit.dp 6 | 7 | object Dimensions { 8 | fun gutter(isTablet: Boolean): Dp { 9 | return if (isTablet) 64.dp else 16.dp 10 | } 11 | 12 | fun BoxWithConstraintsScope.isTablet(): Boolean { 13 | return minOf(maxWidth, maxHeight) >= 480.dp 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /composeApp/src/desktopMain/composeResources/drawable/ic_station.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /iosApp/widget/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.widgetkit-extension 9 | 10 | CFBundleLocalizations 11 | 12 | en 13 | es 14 | 15 | NSWidgetWantsLocation 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable/ic_edit.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/Line.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api 2 | 3 | import com.sixbynine.transit.path.preferences.IntPersistable 4 | 5 | enum class Line(override val number: Int) : IntPersistable { 6 | NewarkWtc(1), HobokenWtc(2), JournalSquare33rd(3), Hoboken33rd(4); 7 | 8 | companion object { 9 | val permanentLines: List = 10 | listOf(NewarkWtc, HobokenWtc, JournalSquare33rd, Hoboken33rd) 11 | 12 | val permanentLinesForWtc33rd = listOf(HobokenWtc, JournalSquare33rd) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable/ic_refresh.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/PathApi.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api 2 | 3 | import com.sixbynine.transit.path.api.impl.PathApiImpl 4 | import com.sixbynine.transit.path.util.FetchWithPrevious 5 | import com.sixbynine.transit.path.util.Staleness 6 | import kotlinx.datetime.Instant 7 | 8 | interface PathApi { 9 | 10 | fun getUpcomingDepartures( 11 | now: Instant, 12 | staleness: Staleness, 13 | ): FetchWithPrevious 14 | 15 | companion object { 16 | val instance: PathApi = PathApiImpl() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/com/sixbynine/transit/path/analytics/IosAnalytics.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.analytics 2 | 3 | import platform.Foundation.NSNotificationCenter 4 | 5 | object IosAnalytics : AnalyticsStrategy { 6 | override fun logEvent(name: String, params: Map) { 7 | NSNotificationCenter.defaultCenter.postNotificationName( 8 | "logEvent", 9 | `object` = null, 10 | userInfo = params + ("event_name" to name) 11 | ) 12 | } 13 | } 14 | 15 | actual fun AnalyticsStrategy(): AnalyticsStrategy = IosAnalytics 16 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/setup/SetupScreenContract.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.setup 2 | 3 | import com.sixbynine.transit.path.api.Station 4 | 5 | object SetupScreenContract { 6 | data class State(val selectedStations: Set = emptySet()) 7 | 8 | sealed interface Intent { 9 | data class StationCheckedChanged(val station: Station, val isChecked: Boolean) : Intent 10 | data object ConfirmClicked : Intent 11 | } 12 | 13 | sealed interface Effect { 14 | data object NavigateToHome : Effect 15 | } 16 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/xml/departure_widget.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /platform/src/commonMain/kotlin/com/sixbynine/transit/path/time/UserPreferenceDayOfWeekComparator.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.time 2 | 3 | import kotlinx.datetime.DayOfWeek 4 | 5 | class UserPreferenceDayOfWeekComparator( 6 | private val firstDayOfWeek: DayOfWeek = getPlatformTimeUtils().getFirstDayOfWeek() 7 | ) : Comparator { 8 | 9 | override fun compare(a: DayOfWeek, b: DayOfWeek): Int { 10 | val aIndex = (a.ordinal - firstDayOfWeek.ordinal + 7) % 7 11 | val bIndex = (b.ordinal - firstDayOfWeek.ordinal + 7) % 7 12 | return aIndex - bIndex 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /platform/src/commonMain/kotlin/com/sixbynine/transit/path/util/AgedValue.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | import kotlin.time.Duration 4 | 5 | data class AgedValue(val age: Duration, val value: T) 6 | 7 | fun AgedValue.map(transform: (T) -> R): AgedValue { 8 | return AgedValue(age, transform(value)) 9 | } 10 | 11 | inline fun AgedValue.combine( 12 | other: AgedValue, 13 | transform: (A, B) -> C 14 | ): AgedValue { 15 | return AgedValue( 16 | age = maxOf(age, other.age), 17 | value = transform(value, other.value), 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/Platform.ios.kt: -------------------------------------------------------------------------------- 1 | import com.sixbynine.transit.path.time.IosPlatformTimeUtils 2 | import platform.UIKit.UIDevice 3 | 4 | class IOSPlatform: Platform { 5 | override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion 6 | 7 | override val type = PlatformType.IOS 8 | 9 | fun setFirstDayOfWeek(firstDayOfWeek: String?) { 10 | IosPlatformTimeUtils.setFirstDayOfWeek(firstDayOfWeek) 11 | } 12 | } 13 | 14 | actual fun getPlatform(): Platform = IOSPlatform() 15 | 16 | actual val IsDebug: Boolean = kotlin.native.Platform.isDebugBinary 17 | -------------------------------------------------------------------------------- /iosApp/widget/RefreshIntent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshIntent.swift 3 | // iosApp 4 | // 5 | // Created by Steven Kideckel on 2023-10-22. 6 | // Copyright © 2023 orgName. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppIntents 11 | import WidgetKit 12 | 13 | struct RefreshIntent : AppIntent { 14 | static var title: LocalizedStringResource = "Refresh" 15 | static var description = IntentDescription("Updates all the widgets") 16 | 17 | func perform() async throws -> some IntentResult { 18 | WidgetCenter.shared.reloadAllTimelines() 19 | return .result() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/Route.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api 2 | 3 | import com.sixbynine.transit.path.util.NonEmptyList 4 | 5 | /** 6 | * A route is a journey taken by a train. It may be considered part of multiple lines. 7 | * 8 | * e.g. 'NWK-WTC' is both a line and a route. However, 'JSQ-33S via HOB' is only a route, and 9 | * includes the 'JSQ-33S' and 'HOB-33S' lines. 10 | */ 11 | data class Route(val lines: NonEmptyList, val stops: NonEmptyList) 12 | 13 | val Route.origin: Station get() = stops.first() 14 | val Route.destination: Station get() = stops.last() 15 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/dictionaries 6 | /.idea/libraries 7 | /.idea/modules.xml 8 | /.idea/workspace.xml 9 | /.idea/navEditor.xml 10 | /.idea/assetWizardSettings.xml 11 | .DS_Store 12 | /build 13 | /captures 14 | .externalNativeBuild 15 | .cxx 16 | 17 | # Built application files 18 | *.apk 19 | *.ap_ 20 | 21 | # Files for the ART/Dalvik VM 22 | *.dex 23 | 24 | # Java class files 25 | *.class 26 | 27 | # Generated files 28 | bin/ 29 | gen/ 30 | out/ 31 | 32 | proguard/ 33 | 34 | *.log 35 | 36 | .navigation/ 37 | 38 | google-services.json 39 | 40 | *.jks -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #64c6ff 4 | #1896D1 5 | @color/path_blue_500 6 | @android:color/white 7 | @color/path_blue_500 8 | @android:color/black 9 | @android:color/darker_gray 10 | @android:color/transparent 11 | -------------------------------------------------------------------------------- /composeApp/src/desktopMain/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | 2 | import androidx.compose.runtime.DisposableEffect 3 | import androidx.compose.ui.window.Window 4 | import androidx.compose.ui.window.application 5 | import com.sixbynine.transit.path.app.lifecycle.AppLifecycleObserver 6 | 7 | fun main() = application { 8 | DisposableEffect(Unit) { 9 | AppLifecycleObserver.setAppIsActive(true) 10 | 11 | onDispose { 12 | AppLifecycleObserver.setAppIsActive(false) 13 | } 14 | } 15 | Window( 16 | onCloseRequest = ::exitApplication, 17 | title = "Departures for PATH", 18 | ) { 19 | App() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /schedule/generator/src/test/kotlin/com/sixbynine/transit/path/schedule/generator/ScheduleParserTest.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.schedule.generator 2 | 3 | import kotlinx.serialization.encodeToString 4 | import kotlinx.serialization.json.Json 5 | import kotlin.test.Test 6 | 7 | class ScheduleParserTest { 8 | 9 | private val json = Json { 10 | 11 | } 12 | 13 | @Test 14 | fun parsing() { 15 | val text = ScheduleParserTest::class.java.getResource("pathModel.json")!!.readText() 16 | 17 | val schedules = ScheduleParser.parse(text, "regular") 18 | 19 | val json = json.encodeToString(schedules) 20 | 21 | println(json) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/widget/UpdateWidgetAction.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget 2 | 3 | import android.content.Context 4 | import androidx.glance.GlanceId 5 | import androidx.glance.action.ActionParameters 6 | import androidx.glance.appwidget.action.ActionCallback 7 | 8 | class UpdateWidgetAction : ActionCallback { 9 | override suspend fun onAction( 10 | context: Context, 11 | glanceId: GlanceId, 12 | parameters: ActionParameters 13 | ) { 14 | AndroidWidgetDataRepository.refreshWidgetData( 15 | force = true, 16 | canRefreshLocation = true, 17 | isBackgroundUpdate = false 18 | ) 19 | } 20 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "PathWidgetXplat" 2 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 3 | 4 | pluginManagement { 5 | repositories { 6 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 7 | google() 8 | gradlePluginPortal() 9 | mavenCentral() 10 | } 11 | } 12 | 13 | dependencyResolutionManagement { 14 | repositories { 15 | google() 16 | mavenCentral() 17 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 18 | } 19 | } 20 | 21 | include(":composeApp") 22 | include(":logging") 23 | include(":api") 24 | include(":platform") 25 | include(":flipper") 26 | include(":schedule") 27 | include(":schedule:generator") 28 | include(":test") -------------------------------------------------------------------------------- /api/src/androidMain/kotlin/com/sixbynine/transit/path/api/AndroidHttpClientFactory.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api 2 | 3 | import com.sixbynine.transit.path.flipper.FlipperUtil 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.HttpClientConfig 6 | import io.ktor.client.engine.okhttp.OkHttp 7 | import okhttp3.Interceptor 8 | 9 | actual fun createHttpClient( 10 | block: HttpClientConfig<*>.() -> Unit 11 | ): HttpClient { 12 | return HttpClient(OkHttp) { 13 | block() 14 | 15 | engine { 16 | val debugInterceptor = FlipperUtil.interceptor() 17 | if (debugInterceptor is Interceptor) { 18 | addInterceptor(debugInterceptor) 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | alias(libs.plugins.androidLibrary) 4 | } 5 | 6 | kotlin { 7 | applyDefaultHierarchyTemplate() 8 | 9 | androidTarget() 10 | 11 | jvm() 12 | 13 | iosX64() 14 | iosArm64() 15 | iosSimulatorArm64() 16 | 17 | sourceSets { 18 | commonMain.dependencies { 19 | implementation(projects.api) 20 | implementation(projects.platform) 21 | } 22 | } 23 | } 24 | 25 | android { 26 | namespace = "com.sixbynine.transit.path.test" 27 | 28 | defaultConfig { 29 | minSdk = libs.versions.android.minSdk.get().toInt() 30 | } 31 | 32 | buildFeatures { 33 | buildConfig = true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/xml-v27/departure_widget.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/DepartingTrain.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api 2 | 3 | import com.sixbynine.transit.path.model.ColorWrapper 4 | import kotlinx.datetime.Instant 5 | import kotlinx.serialization.Serializable 6 | 7 | data class DepartingTrain( 8 | val headsign: String, 9 | val projectedArrival: Instant, 10 | val lineColors: List, 11 | val isDelayed: Boolean, 12 | val backfillSource: BackfillSource?, 13 | val directionState: State?, 14 | val lines: Set, 15 | ) 16 | 17 | @Serializable 18 | data class BackfillSource( 19 | val station: Station, 20 | val projectedArrival: Instant, 21 | ) 22 | 23 | val DepartingTrain.terminalStation: Station? get() = Stations.fromHeadSign(headsign) 24 | -------------------------------------------------------------------------------- /composeApp/src/desktopMain/kotlin/com/sixbynine/transit/path/app/external/ExternalRoutingManager.jvm.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.external 2 | 3 | object JvmExternalRoutingManager : ExternalRoutingManager { 4 | override suspend fun openEmail(): Boolean { 5 | TODO("Not yet implemented") 6 | } 7 | 8 | override suspend fun openUrl(url: String): Boolean { 9 | TODO("Not yet implemented") 10 | } 11 | 12 | override suspend fun shareTextToSystem(text: String): Boolean { 13 | TODO("Not yet implemented") 14 | } 15 | 16 | override suspend fun launchAppRating(): Boolean { 17 | TODO("Not yet implemented") 18 | } 19 | } 20 | 21 | actual fun ExternalRoutingManager(): ExternalRoutingManager = JvmExternalRoutingManager 22 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/widget/glance/GlanceResources.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget.glance 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.glance.GlanceComposable 5 | import kotlinx.coroutines.runBlocking 6 | import org.jetbrains.compose.resources.StringResource 7 | import org.jetbrains.compose.resources.getString 8 | 9 | // Glance runs on a background thread, so blocking is ok here. 10 | @GlanceComposable 11 | @Composable 12 | fun stringResource(resource: StringResource): String { 13 | return runBlocking { getString(resource) } 14 | } 15 | 16 | @GlanceComposable 17 | @Composable 18 | fun stringResource(resource: StringResource, vararg args: Any): String { 19 | return runBlocking { getString(resource, *args) } 20 | } -------------------------------------------------------------------------------- /flipper/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | alias(libs.plugins.androidLibrary) 4 | } 5 | 6 | kotlin { 7 | applyDefaultHierarchyTemplate() 8 | 9 | androidTarget() 10 | 11 | jvm() 12 | 13 | iosX64() 14 | iosArm64() 15 | iosSimulatorArm64() 16 | } 17 | 18 | android { 19 | namespace = "com.sixbynine.transit.path.flipper" 20 | 21 | defaultConfig { 22 | minSdk = libs.versions.android.minSdk.get().toInt() 23 | } 24 | 25 | buildFeatures { 26 | buildConfig = true 27 | } 28 | 29 | dependencies { 30 | debugImplementation(libs.facebook.flipper) 31 | debugImplementation(libs.facebook.soloader) 32 | debugImplementation(libs.facebook.flipper.network.plugin) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/src/commonMain/kotlin/com/sixbynine/transit/path/test/TestPreferences.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.test 2 | 3 | import com.sixbynine.transit.path.preferences.Preferences 4 | import com.sixbynine.transit.path.preferences.PreferencesKey 5 | import com.sixbynine.transit.path.preferences.testInstance 6 | 7 | object TestPreferences : Preferences { 8 | private val data = mutableMapOf, Any?>() 9 | 10 | override fun set(key: PreferencesKey, value: T?) { 11 | data[key] = value 12 | } 13 | 14 | override fun get(key: PreferencesKey): T? { 15 | return data[key] as T? 16 | } 17 | 18 | override fun clear() { 19 | data.clear() 20 | } 21 | 22 | fun install() { 23 | testInstance = this 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/settings/AppSettings.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.settings 2 | 3 | import com.sixbynine.transit.path.api.Line 4 | import com.sixbynine.transit.path.api.LocationSetting 5 | import com.sixbynine.transit.path.api.StationSort 6 | import com.sixbynine.transit.path.api.TrainFilter 7 | 8 | data class AppSettings( 9 | val locationSetting: LocationSetting, 10 | val trainFilter: TrainFilter, 11 | val lineFilters: Set, 12 | val timeDisplay: TimeDisplay, 13 | val stationLimit: StationLimit, 14 | val stationSort: StationSort, 15 | val displayPresumedTrains: Boolean, 16 | val avoidMissingTrains: AvoidMissingTrains, 17 | val commutingConfiguration: CommutingConfiguration, 18 | val groupTrains: Boolean, 19 | ) 20 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/alerts/github/GithubAlertsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api.alerts.github 2 | 3 | import com.sixbynine.transit.path.util.FetchWithPrevious 4 | import com.sixbynine.transit.path.util.RemoteFileRepository 5 | import kotlinx.datetime.Instant 6 | import kotlin.time.Duration.Companion.minutes 7 | 8 | object GithubAlertsRepository { 9 | 10 | private val helper = RemoteFileRepository( 11 | keyPrefix = "github_alerts", 12 | url = "https://raw.githubusercontent.com/steviek/PathWidgetXplat/main/alerts.json", 13 | serializer = GithubAlerts.serializer(), 14 | maxAge = 30.minutes 15 | ) 16 | 17 | fun getAlerts(now: Instant): FetchWithPrevious { 18 | return helper.get(now) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /schedule/src/commonTest/kotlin/Departures.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.datetime.LocalTime 2 | 3 | internal interface Departures { 4 | val nwkWtc: String 5 | 6 | val wtcNewark: String 7 | 8 | val jsq33s: String get() = "" 9 | 10 | val s33Jsq: String get() = "" 11 | 12 | val jsqHob33s: String 13 | 14 | val s33HobJsq: String 15 | 16 | val hob33s: String get() = "" 17 | 18 | val s33Hob: String get() = "" 19 | 20 | val hobWtc: String get() = "" 21 | 22 | val wtcHob: String get() = "" 23 | 24 | val wtcJsq: String get() = "" 25 | 26 | val jsqWtc: String get() = "" 27 | 28 | val nwkHar: String get() = "" 29 | 30 | val harNwk: String get() = "" 31 | 32 | val firstSlowDepartureTime: LocalTime? get() = null 33 | 34 | val lastSlowDepartureTime: LocalTime? get() = null 35 | } 36 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/lifecycle/AppLifecycleObserver.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.lifecycle 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.asStateFlow 5 | import kotlinx.coroutines.flow.first 6 | import kotlinx.coroutines.withTimeoutOrNull 7 | import kotlin.time.Duration 8 | 9 | object AppLifecycleObserver { 10 | 11 | private val _isActive = MutableStateFlow(false) 12 | val isActive = _isActive.asStateFlow() 13 | 14 | fun setAppIsActive(isActive: Boolean) { 15 | _isActive.value = isActive 16 | } 17 | 18 | suspend fun awaitActive() = isActive.first { it } 19 | 20 | suspend fun awaitInactive(timeout: Duration = Duration.INFINITE) { 21 | withTimeoutOrNull(timeout) { isActive.first { !it } } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/alerts/everbridge/EverbridgeAlertsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api.alerts.everbridge 2 | 3 | import com.sixbynine.transit.path.util.FetchWithPrevious 4 | import com.sixbynine.transit.path.util.RemoteFileRepository 5 | import kotlinx.datetime.Instant 6 | import kotlin.time.Duration.Companion.minutes 7 | 8 | object EverbridgeAlertsRepository { 9 | 10 | private val helper = RemoteFileRepository( 11 | keyPrefix = "everbridge_alerts", 12 | url = "https://panynj.gov/bin/portauthority/everbridge/incidents?status=All&department=Path", 13 | serializer = EverbridgeAlerts.serializer(), 14 | maxAge = 2.minutes 15 | ) 16 | 17 | fun getAlerts(now: Instant): FetchWithPrevious { 18 | return helper.get(now) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /schedule/generator/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | alias(libs.plugins.kotlinSerialization) 4 | application 5 | } 6 | 7 | kotlin { 8 | jvmToolchain(21) 9 | } 10 | 11 | dependencies { 12 | implementation(projects.api) 13 | implementation(projects.logging) 14 | implementation(projects.schedule) 15 | implementation(projects.platform) 16 | 17 | implementation(libs.kotlin.coroutines) 18 | implementation(libs.kotlin.serialization.json) 19 | implementation(libs.slf4j) 20 | implementation(libs.ksoup) 21 | 22 | testImplementation(projects.test) 23 | testImplementation(libs.kotlin.test) 24 | } 25 | 26 | application { 27 | mainClass.set("com.sixbynine.transit.path.schedule.generator.ScheduleGeneratorKt") 28 | } 29 | 30 | tasks.named("run") { 31 | outputs.dir("build/outputs") 32 | } 33 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/layout/LayoutOption.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.layout 2 | 3 | import com.sixbynine.transit.path.preferences.IntPreferencesKey 4 | import com.sixbynine.transit.path.preferences.persisting 5 | 6 | enum class LayoutOption(val number: Int) { 7 | OneColumn(1), TwoColumns(2), ThreeColumns(3) 8 | } 9 | 10 | object LayoutOptionManager { 11 | private val LayoutOptionKey = IntPreferencesKey("layout_option") 12 | private var storedLayoutOption by persisting(LayoutOptionKey) 13 | 14 | var layoutOption: LayoutOption? 15 | get() = 16 | storedLayoutOption 17 | ?.let { storedOption -> LayoutOption.entries.find { it.number == storedOption } } 18 | set(value) { 19 | storedLayoutOption = value?.number 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /iosApp/widget/WidgetConfigurationUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WidgetConfigurationUtils.swift 3 | // widget2Extension 4 | // 5 | // Created by Steven Kideckel on 2023-10-18. 6 | // Copyright © 2023 orgName. All rights reserved. 7 | // 8 | 9 | import WidgetKit 10 | 11 | class WidgetConfigurationUtils { 12 | static func getWidgetLimit(family: WidgetFamily) -> Int { 13 | return switch (family) { 14 | case .systemSmall: 15 | 1 16 | case .systemMedium: 17 | 2 18 | case .systemLarge: 19 | 4 20 | case .systemExtraLarge: 21 | 4 22 | case .accessoryCircular: 23 | 1 24 | case .accessoryRectangular: 25 | 1 26 | case .accessoryInline: 27 | 1 28 | @unknown default: 29 | 1 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable/ic_one_column.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /logging/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | alias(libs.plugins.androidLibrary) 4 | alias(libs.plugins.kotlinSerialization) 5 | } 6 | 7 | kotlin { 8 | applyDefaultHierarchyTemplate() 9 | 10 | androidTarget() 11 | 12 | jvm() 13 | 14 | iosX64() 15 | iosArm64() 16 | iosSimulatorArm64() 17 | 18 | sourceSets { 19 | commonMain.dependencies { 20 | implementation(projects.platform) 21 | implementation(libs.kotlin.coroutines) 22 | implementation(libs.kotlin.date.time) 23 | implementation(libs.kotlin.serialization.json) 24 | implementation(libs.napier) 25 | } 26 | } 27 | } 28 | 29 | android { 30 | namespace = "com.sixbynine.transit.path.logging" 31 | 32 | buildFeatures { 33 | buildConfig = true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/analytics/AndroidAnalytics.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.analytics 2 | 3 | import com.google.firebase.Firebase 4 | import com.google.firebase.analytics.analytics 5 | import com.google.firebase.analytics.logEvent 6 | 7 | actual fun AnalyticsStrategy(): AnalyticsStrategy = object : AnalyticsStrategy { 8 | override fun logEvent(name: String, params: Map) { 9 | Firebase.analytics.logEvent(name) { 10 | params.forEach { (key, value) -> 11 | when (value) { 12 | is Long -> param(key, value) 13 | is String -> param(key, value) 14 | is Int -> param(key, value.toLong()) 15 | is Boolean -> param(key, if (value) "true" else "false") 16 | } 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /composeApp/src/desktopMain/composeResources/drawable/ic_one_column.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/alerts/AlertsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api.alerts 2 | 3 | import com.sixbynine.transit.path.api.alerts.everbridge.EverbridgeAlertsRepository 4 | import com.sixbynine.transit.path.api.alerts.everbridge.toCommonAlert 5 | import com.sixbynine.transit.path.api.alerts.github.GithubAlertsRepository 6 | import com.sixbynine.transit.path.util.FetchWithPrevious 7 | import com.sixbynine.transit.path.util.combine 8 | import kotlinx.datetime.Instant 9 | 10 | object AlertsRepository { 11 | fun getAlerts(now: Instant): FetchWithPrevious> { 12 | return combine( 13 | GithubAlertsRepository.getAlerts(now), 14 | EverbridgeAlertsRepository.getAlerts(now) 15 | ) { githubAlerts, everbridgeAlerts -> 16 | githubAlerts.alerts + everbridgeAlerts.data.map { it.toCommonAlert() } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/src/commonMain/kotlin/com/sixbynine/transit/path/test/TestRemoteFileProviderImpl.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.test 2 | 3 | import com.sixbynine.transit.path.util.TestRemoteFileProvider 4 | 5 | object TestRemoteFileProviderImpl : TestRemoteFileProvider { 6 | override fun getText(url: String): Result { 7 | val lastSlash = url.lastIndexOf('/') 8 | val path = url.substring(lastSlash + 1) 9 | val json = when (path) { 10 | "alerts.json" -> Alerts 11 | "schedule.json" -> Schedule 12 | "schedule_override.json" -> ScheduleOverride 13 | "ridepath.json" -> RidePath 14 | else -> return Result.failure(IllegalArgumentException("No test file for $path")) 15 | } 16 | return Result.success(json) 17 | } 18 | } 19 | 20 | fun TestRemoteFileProvider.Companion.install() { 21 | instance = TestRemoteFileProviderImpl 22 | } 23 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | -------------------------------------------------------------------------------- /schedule/src/commonTest/kotlin/DeparturesParsing.kt: -------------------------------------------------------------------------------- 1 | internal fun parseDeparturesFromNwk(raw: String): String { 2 | val lines = raw.split("\n") 3 | return lines.filter { !it.trim().startsWith("-") }.joinToString(separator = "\n") 4 | } 5 | 6 | internal fun parseDeparturesToNwk(raw: String): String { 7 | val lines = raw.split("\n") 8 | return lines.filter { !it.trim().endsWith("-") }.joinToString(separator = "\n") 9 | } 10 | 11 | internal fun parseDeparturesFromJsq(raw: String): String { 12 | val lines = raw.split("\n") 13 | return lines 14 | .filter { it.trim().startsWith("-") }.joinToString(separator = "\n") { line -> 15 | val firstDigit = line.indexOfFirst { it.isDigit() } 16 | line.substring(startIndex = firstDigit) 17 | } 18 | } 19 | 20 | internal fun parseDeparturesToJsq(raw: String): String { 21 | val lines = raw.split("\n") 22 | return lines.filter { it.trim().endsWith("-") }.joinToString(separator = "\n") 23 | } 24 | -------------------------------------------------------------------------------- /platform/src/commonMain/kotlin/com/sixbynine/transit/path/util/NonEmptyList.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | class NonEmptyList internal constructor(private val list: List) : List by list { 4 | init { 5 | require(list.isNotEmpty()) 6 | } 7 | 8 | override fun equals(other: Any?): Boolean { 9 | if (other is NonEmptyList<*>) { 10 | return list == other.list 11 | } else { 12 | return list == other 13 | } 14 | } 15 | 16 | override fun hashCode(): Int { 17 | return list.hashCode() 18 | } 19 | 20 | override fun toString(): String { 21 | return list.toString() 22 | } 23 | } 24 | 25 | fun List.toNonEmptyList(): NonEmptyList? { 26 | return if (this.isNotEmpty()) NonEmptyList(this) else null 27 | } 28 | 29 | fun nonEmptyListOf(first: T, vararg others: T): NonEmptyList { 30 | val list = listOf(first) + others 31 | return NonEmptyList(list) 32 | } 33 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/common/AppUiTrainData.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.common 2 | 3 | import com.sixbynine.transit.path.api.BackfillSource 4 | import com.sixbynine.transit.path.api.Station 5 | import com.sixbynine.transit.path.model.ColorWrapper 6 | import kotlinx.datetime.Instant 7 | 8 | data class AppUiTrainData( 9 | val id: String, 10 | val title: String, 11 | val colors: List, 12 | val projectedArrival: Instant, 13 | val displayText: String, 14 | val isDelayed: Boolean = false, 15 | val backfill: AppUiBackfillSource? = null, 16 | ) { 17 | val isBackfilled: Boolean 18 | get() = backfill != null 19 | } 20 | 21 | data class AppUiBackfillSource( 22 | val source: BackfillSource, 23 | val displayText: String, 24 | ) { 25 | val projectedArrival: Instant 26 | get() = source.projectedArrival 27 | 28 | val station: Station 29 | get() = source.station 30 | } 31 | -------------------------------------------------------------------------------- /schedule/generator/src/test/kotlin/com/sixbynine/transit/path/schedule/generator/ScheduleHtmlParserTest.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.schedule.generator 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertNotNull 5 | 6 | class ScheduleHtmlParserTest { 7 | @Test 8 | fun `parse nwk wtc`() { 9 | parseAndPrint("nwkWtcAccordion.html") 10 | } 11 | 12 | @Test 13 | fun `parse jsq hob 33s`() { 14 | parseAndPrint("jsq33sAccordion.html") 15 | } 16 | 17 | private fun parseAndPrint(filename: String) { 18 | val html = ScheduleHtmlParserTest::class.java.getResource(filename)!!.readText() 19 | val result = ScheduleHtmlParser.parseDepartures(html, verboseLogging = false) 20 | assertNotNull(result) 21 | 22 | println("Route:") 23 | println("\tLines:${result.first.lines}") 24 | println("\tStations:${result.first.stops.map { it.pathApiName }}") 25 | println() 26 | println("Departures:") 27 | println(result.second) 28 | } 29 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/util/TimeUtilities.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | import kotlinx.datetime.DateTimeUnit 4 | import kotlinx.datetime.Instant 5 | import kotlinx.datetime.LocalDateTime 6 | import kotlinx.datetime.TimeZone 7 | import kotlinx.datetime.plus 8 | import kotlinx.datetime.toInstant 9 | import kotlinx.datetime.toLocalDateTime 10 | 11 | class TimeUtilities { 12 | fun getStartOfNextMinute(time: Instant): Instant { 13 | val timeZone = TimeZone.currentSystemDefault() 14 | return time 15 | .plus(1, DateTimeUnit.MINUTE) 16 | .toLocalDateTime(timeZone) 17 | .floorToMinute() 18 | .toInstant(timeZone) 19 | } 20 | 21 | private fun LocalDateTime.floorToMinute(): LocalDateTime { 22 | return LocalDateTime( 23 | year = year, 24 | monthNumber = monthNumber, 25 | dayOfMonth = dayOfMonth, 26 | hour = hour, 27 | minute = minute 28 | ) 29 | } 30 | } -------------------------------------------------------------------------------- /.github/workflows/update-schedule.yml: -------------------------------------------------------------------------------- 1 | name: schedule-updater 2 | 3 | on: 4 | schedule: 5 | - cron: '0 */12 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: get repo content 13 | uses: actions/checkout@v2 14 | 15 | - name: setup java 16 | uses: actions/setup-java@v4 17 | with: 18 | distribution: 'temurin' 19 | java-version: '21' 20 | 21 | - name: run schedule generator 22 | run: ./gradlew :schedule:generator:run --no-daemon -Pkotlin.native.ignoreDisabledTargets=true 23 | 24 | - name: put schedule in place 25 | run: | 26 | mv schedule/generator/build/outputs/schedule.json . 27 | 28 | - name: commit 29 | uses: stefanzweifel/git-auto-commit-action@v4 30 | with: 31 | commit_message: 'chore: update schedules' 32 | commit_author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 33 | file_pattern: 'schedule*.json' -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/util/Serialization.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | import kotlinx.datetime.Instant 4 | import kotlinx.serialization.KSerializer 5 | import kotlinx.serialization.descriptors.PrimitiveKind.LONG 6 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 7 | import kotlinx.serialization.encoding.Decoder 8 | import kotlinx.serialization.encoding.Encoder 9 | import kotlinx.serialization.json.Json 10 | 11 | val JsonFormat = Json { 12 | ignoreUnknownKeys = true 13 | explicitNulls = false 14 | isLenient = true 15 | } 16 | 17 | class InstantAsEpochMillisSerializer : KSerializer { 18 | override val descriptor = PrimitiveSerialDescriptor("InstantAsEpochMillis", LONG) 19 | 20 | override fun serialize(encoder: Encoder, value: Instant) { 21 | encoder.encodeLong(value.toEpochMilliseconds()) 22 | } 23 | 24 | override fun deserialize(decoder: Decoder): Instant { 25 | return Instant.fromEpochMilliseconds(decoder.decodeLong()) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/ColorRect.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.width 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.unit.Dp 10 | import androidx.compose.ui.unit.dp 11 | import com.sixbynine.transit.path.model.ColorWrapper 12 | import com.sixbynine.transit.path.model.unwrap 13 | 14 | @Composable 15 | fun ColorRect( 16 | colors: List, 17 | modifier: Modifier = Modifier 18 | ) { 19 | Column( 20 | modifier 21 | .width(12.dp) 22 | ) { 23 | colors.take(3).forEach { color -> 24 | Box( 25 | Modifier 26 | .width(12.dp) 27 | .weight(1f) 28 | .background(color.unwrap()) 29 | ) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/State.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api 2 | 3 | import com.sixbynine.transit.path.api.State.NewJersey 4 | import com.sixbynine.transit.path.api.State.NewYork 5 | import com.sixbynine.transit.path.location.Location 6 | 7 | enum class State { 8 | NewJersey, NewYork 9 | } 10 | 11 | fun State.other(): State = when(this) { 12 | NewJersey -> NewYork 13 | NewYork -> NewJersey 14 | } 15 | 16 | val Station.state: State 17 | get() = if (coordinates.longitude > -74.020) State.NewYork else State.NewJersey 18 | 19 | val Location.state: State 20 | get() = if (longitude > -74.020) State.NewYork else State.NewJersey 21 | 22 | infix fun Station.isEastOf(other: Station): Boolean { 23 | return coordinates.longitude > other.coordinates.longitude 24 | } 25 | 26 | infix fun Station.isWestOf(other: Station): Boolean { 27 | return coordinates.longitude < other.coordinates.longitude 28 | } 29 | 30 | val Station.isInNewJersey get() = state == State.NewJersey 31 | val Station.isInNewYork get() = state == State.NewYork -------------------------------------------------------------------------------- /iosApp/widget/EmptyDepartureBoardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyDepartureBoardView.swift 3 | // widget2Extension 4 | // 5 | // Created by Steven Kideckel on 2023-10-21. 6 | // Copyright © 2023 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ComposeApp 11 | 12 | struct EmptyDepartureBoardView: View { 13 | let isError: Bool 14 | let isPathError: Bool 15 | 16 | var body: some View { 17 | ZStack(alignment: .center) { 18 | Text(getText()) 19 | .font(Font.system(size: (isError ? 12 : 16))) 20 | .multilineTextAlignment(.center) 21 | } 22 | .frame(maxWidth: .infinity, maxHeight: .infinity) 23 | } 24 | 25 | private func getText() -> String { 26 | if isError { 27 | return IosResourceProvider().getEmptyErrorMessage(isPathApiError: isPathError) 28 | } else { 29 | return IosResourceProvider().getEmptyStateString() 30 | } 31 | } 32 | } 33 | 34 | #Preview { 35 | EmptyDepartureBoardView(isError: false, isPathError: false) 36 | } 37 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/widget/StationDataComparator.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget 2 | 3 | import com.sixbynine.transit.path.api.StationSort 4 | import com.sixbynine.transit.path.api.Stations 5 | import com.sixbynine.transit.path.time.now 6 | import com.sixbynine.transit.path.model.DepartureBoardData.StationData 7 | import kotlinx.datetime.LocalDateTime 8 | import kotlinx.datetime.TimeZone 9 | import kotlinx.datetime.toLocalDateTime 10 | 11 | class StationDataComparator( 12 | order: StationSort?, 13 | now: LocalDateTime = now().toLocalDateTime(TimeZone.currentSystemDefault()), 14 | ) : Comparator { 15 | 16 | // TODO: Fix this 17 | private val delegate = StationComparator(order, null, now) 18 | 19 | override fun compare(data1: StationData, data2: StationData): Int { 20 | val first = Stations.All.firstOrNull { it.pathApiName == data1.id } ?: return 0 21 | val second = Stations.All.firstOrNull { it.pathApiName == data2.id } ?: return 0 22 | return delegate.compare(first, second) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /iosApp/widget/PathWidgetBundle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // widgetBundle.swift 3 | // widget 4 | // 5 | // Created by Steven Kideckel on 2023-11-27. 6 | // Copyright © 2023 orgName. All rights reserved. 7 | // 8 | 9 | import WidgetKit 10 | import SwiftUI 11 | import ComposeApp 12 | 13 | @main 14 | struct PathWidgetBundle: WidgetBundle { 15 | 16 | init() { 17 | let locationHelper = LocationHelper() 18 | locationHelper.isWidget = true 19 | IosLocationProvider().requestDelegate = locationHelper 20 | 21 | var firstDayOfWeek: String? = nil 22 | if #available(iOS 16, *) { 23 | firstDayOfWeek = Locale.current.firstDayOfWeek.rawValue 24 | } 25 | IOSPlatform().setFirstDayOfWeek(firstDayOfWeek: firstDayOfWeek) 26 | 27 | NativeHolder().initialize( 28 | widgetReloader: IosWidgetReloader(), 29 | nonFatalReporter: { e in 30 | 31 | } 32 | ) 33 | } 34 | 35 | var body: some Widget { 36 | CommuteWidget() 37 | DepartureWidget() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/widget/glance/Text.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget.glance 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.unit.TextUnit 5 | import androidx.glance.GlanceModifier 6 | import androidx.glance.text.TextAlign 7 | import androidx.glance.text.TextDefaults 8 | import androidx.glance.text.TextStyle 9 | import androidx.glance.unit.ColorProvider 10 | 11 | @Composable 12 | fun Text( 13 | text: String, 14 | modifier: GlanceModifier = GlanceModifier, 15 | style: TextStyle = TextDefaults.defaultTextStyle, 16 | maxLines: Int = Int.MAX_VALUE, 17 | fontSize: TextUnit? = null, 18 | textAlign: TextAlign? = null, 19 | color: ColorProvider? = null, 20 | ) { 21 | androidx.glance.text.Text( 22 | text = text, 23 | modifier = modifier, 24 | style = style.copy( 25 | color = color ?: style.color, 26 | fontSize = fontSize ?: style.fontSize, 27 | textAlign = textAlign ?: style.textAlign, 28 | ), 29 | maxLines = maxLines, 30 | ) 31 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/station/StationContract.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.station 2 | 3 | import com.sixbynine.transit.path.app.settings.TimeDisplay 4 | import com.sixbynine.transit.path.app.ui.ScreenScope 5 | import com.sixbynine.transit.path.app.ui.common.AppUiTrainData 6 | import com.sixbynine.transit.path.app.ui.home.HomeScreenContract.StationData 7 | import com.sixbynine.transit.path.app.ui.station.StationContract.Intent 8 | import com.sixbynine.transit.path.app.ui.station.StationContract.State 9 | 10 | object StationContract { 11 | data class State( 12 | val station: StationData? = null, 13 | val trainsMatchingFilters: List, 14 | val otherTrains: List, 15 | val timeDisplay: TimeDisplay, 16 | val groupByDestination: Boolean, 17 | ) 18 | 19 | sealed interface Intent { 20 | data object BackClicked : Intent 21 | 22 | } 23 | 24 | sealed interface Effect { 25 | data object GoBack : Effect 26 | } 27 | } 28 | 29 | typealias StationScope = ScreenScope -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui 2 | 3 | import kotlinx.coroutines.channels.Channel 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.asStateFlow 6 | import kotlinx.coroutines.flow.receiveAsFlow 7 | import kotlinx.coroutines.flow.update 8 | import kotlinx.coroutines.launch 9 | import kotlinx.coroutines.sync.Mutex 10 | 11 | abstract class BaseViewModel( 12 | initialState: State 13 | ) : PathViewModel() { 14 | 15 | private val _state = MutableStateFlow(initialState) 16 | final override val state = _state.asStateFlow() 17 | private val stateMutex = Mutex() 18 | 19 | 20 | 21 | private val _effects = Channel() 22 | final override val effects = _effects.receiveAsFlow() 23 | 24 | protected fun sendEffect(effect: Effect) { 25 | lightweightScope.launch { _effects.send(effect) } 26 | } 27 | 28 | protected fun updateState(block: State.() -> State) { 29 | _state.update(block) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /iosApp/iosApp/HomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeView.swift 3 | // iosApp 4 | // 5 | // Created by Steven Kideckel on 2023-10-23. 6 | // Copyright © 2023 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct HomeView: View { 12 | var body: some View { 13 | ZStack { 14 | VStack { 15 | Spacer() 16 | Text(getString(strings().welcome_text)) 17 | .multilineTextAlignment(.center) 18 | Spacer() 19 | } 20 | 21 | 22 | VStack { 23 | Spacer() 24 | Button(action: emailSupport) { 25 | Text(getString(strings().report_a_problem)) 26 | } 27 | .padding() 28 | Spacer().frame(height: 8) 29 | } 30 | } 31 | .padding() 32 | } 33 | 34 | private func emailSupport() { 35 | if let url = URL(string: "mailto:sixbynineapps@gmail.com") { 36 | UIApplication.shared.open(url) 37 | } 38 | } 39 | } 40 | 41 | #Preview { 42 | HomeView() 43 | } 44 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/util/RemoteFileReading.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | import com.sixbynine.transit.path.api.NetworkException 4 | import com.sixbynine.transit.path.api.createHttpClient 5 | import io.ktor.client.request.get 6 | import io.ktor.client.statement.bodyAsText 7 | import io.ktor.http.isSuccess 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.IO 10 | import kotlinx.coroutines.withContext 11 | import kotlinx.coroutines.withTimeout 12 | import kotlin.time.Duration.Companion.seconds 13 | 14 | private val httpClient by lazy { createHttpClient() } 15 | 16 | suspend fun readRemoteFile(url: String): Result = suspendRunCatching { 17 | TestRemoteFileProvider.instance 18 | ?.getText(url) 19 | ?.let { return@suspendRunCatching it.getOrThrow() } 20 | 21 | withContext(Dispatchers.IO) { 22 | val response = withTimeout(5.seconds) { httpClient.get(url) } 23 | 24 | if (!response.status.isSuccess()) { 25 | throw NetworkException(response.status.toString()) 26 | } 27 | 28 | response.bodyAsText() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/templine/HobClosureConfigRepository.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api.templine 2 | 3 | import com.sixbynine.transit.path.time.now 4 | import com.sixbynine.transit.path.util.RemoteFileRepository 5 | import com.sixbynine.transit.path.util.await 6 | import kotlinx.coroutines.GlobalScope 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.launch 9 | import kotlin.time.Duration.Companion.hours 10 | import kotlin.time.Duration.Companion.seconds 11 | 12 | object HobClosureConfigRepository { 13 | 14 | init { 15 | GlobalScope.launch { 16 | delay(2.seconds) 17 | helper.get(now()).await() 18 | } 19 | } 20 | 21 | private val helper = RemoteFileRepository( 22 | keyPrefix = "hob_closue_config", 23 | url = "https://raw.githubusercontent.com/steviek/PathWidgetXplat/main/hob_closure.json", 24 | serializer = HobClosureConfig.serializer(), 25 | maxAge = 1.hours, 26 | ) 27 | 28 | fun getConfig(): HobClosureConfig { 29 | return helper.get(now()).previous?.value ?: HobClosureConfig.fallback 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/widget/glance/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget.glance 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.ReadOnlyComposable 6 | import androidx.glance.GlanceComposable 7 | import androidx.glance.color.ColorProviders 8 | 9 | object GlanceTheme { 10 | val colors: ColorProviders 11 | @GlanceComposable 12 | @Composable 13 | @ReadOnlyComposable 14 | get() = androidx.glance.GlanceTheme.colors 15 | 16 | val typography: Typography 17 | @GlanceComposable 18 | @Composable 19 | @ReadOnlyComposable 20 | get() = LocalTypography.current 21 | } 22 | 23 | @Composable 24 | fun GlanceTheme( 25 | colors: ColorProviders = androidx.glance.GlanceTheme.colors, 26 | content: @GlanceComposable @Composable () -> Unit 27 | ) { 28 | androidx.glance.GlanceTheme(colors) { 29 | CompositionLocalProvider( 30 | LocalTypography provides createTypography(), 31 | content = content 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/widget/configuration/StoredWidgetConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget.configuration 2 | 3 | import com.sixbynine.transit.path.api.Line 4 | import com.sixbynine.transit.path.api.StationSort 5 | import com.sixbynine.transit.path.api.TrainFilter 6 | import com.sixbynine.transit.path.preferences.IntPersistable 7 | import kotlinx.serialization.Serializable 8 | import kotlin.contracts.contract 9 | 10 | /** The persisted data for a widget. */ 11 | @Serializable 12 | data class StoredWidgetConfiguration( 13 | val fixedStations: Set? = null, 14 | private val linesBitmask: Int? = null, 15 | val useClosestStation: Boolean = false, 16 | val sortOrder: StationSort? = null, 17 | val filter: TrainFilter? = null, 18 | val version: Int = 1, 19 | ) { 20 | val lines: Set 21 | get() = IntPersistable.fromBitmask(linesBitmask ?: 0) 22 | } 23 | 24 | fun StoredWidgetConfiguration?.needsSetup(): Boolean { 25 | contract { returns(false) implies (this@needsSetup != null) } 26 | return this == null || (fixedStations.isNullOrEmpty() && !useClosestStation) 27 | } 28 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/native/NativeHolder.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.native 2 | 3 | import com.sixbynine.transit.path.Logging 4 | import com.sixbynine.transit.path.NonFatalReporter 5 | import com.sixbynine.transit.path.api.templine.HobClosureConfigRepository 6 | import com.sixbynine.transit.path.widget.WidgetReloader 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.StateFlow 9 | 10 | object NativeHolder { 11 | 12 | val widgetReloader = MutableStateFlow(null) 13 | 14 | fun initialize( 15 | widgetReloader: WidgetReloader, 16 | nonFatalReporter: (Throwable) -> Unit, 17 | ) { 18 | this.widgetReloader.value = widgetReloader 19 | 20 | // kick start some initialization here 21 | HobClosureConfigRepository.getConfig() 22 | 23 | Logging.nonFatalReporter = NonFatalReporter { e -> nonFatalReporter(e) } 24 | } 25 | } 26 | 27 | val widgetReloader: WidgetReloader 28 | get() = getInitialized(NativeHolder.widgetReloader) 29 | 30 | private fun getInitialized(ref: StateFlow): T { 31 | return checkNotNull(ref.value) { "not initialized" } 32 | } 33 | -------------------------------------------------------------------------------- /platform/src/commonMain/kotlin/com/sixbynine/transit/path/util/GlobalExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | import kotlinx.datetime.LocalDateTime 4 | import kotlinx.datetime.LocalTime 5 | import kotlinx.datetime.atTime 6 | 7 | inline fun T?.orElse(other: () -> T): T { 8 | return this ?: other() 9 | } 10 | 11 | inline fun T.runIf(condition: Boolean, block: T.() -> T): T { 12 | return if (condition) { 13 | block() 14 | } else { 15 | this 16 | } 17 | } 18 | 19 | inline fun T.runUnless(condition: Boolean, block: T.() -> T) = runIf(!condition) { block() } 20 | 21 | inline fun ifNotNull(first: A?, second: B?, transform: (A, B) -> C): C? { 22 | first ?: return null 23 | second ?: return null 24 | return transform(first, second) 25 | } 26 | 27 | fun Result>.flatten(): Result { 28 | return fold( 29 | onSuccess = { it }, 30 | onFailure = { Result.failure(it) } 31 | ) 32 | } 33 | 34 | fun LocalDateTime.dropSubSeconds(): LocalDateTime { 35 | return date.atTime(time.dropSubSeconds()) 36 | } 37 | 38 | fun LocalTime.dropSubSeconds(): LocalTime { 39 | return LocalTime(hour, minute, second) 40 | } 41 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/com/sixbynine/transit/path/app/ui/ColorsTest.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui 2 | 3 | import com.sixbynine.transit.path.model.Colors 4 | import com.sixbynine.transit.path.model.Colors.approxEquals 5 | import kotlin.test.Test 6 | import kotlin.test.assertFalse 7 | import kotlin.test.assertTrue 8 | 9 | class ColorsTest { 10 | @Test 11 | fun approxEquals() { 12 | val c1 = Colors.parse("#4287f5") 13 | val c2 = Colors.parse("#3a80f0") 14 | assertTrue(c1 approxEquals c2) 15 | 16 | val c3 = Colors.parse("#053685") 17 | assertFalse(c1 approxEquals c3) 18 | 19 | val c4 = Colors.parse("#d00dd6") 20 | assertFalse(c1 approxEquals c4) 21 | assertFalse(c3 approxEquals c4) 22 | 23 | val c5 = Colors.parse("#782C94") 24 | val c6 = Colors.parse("#6b128c") 25 | assertTrue(c5 approxEquals c6) 26 | assertFalse(c4 approxEquals c6) 27 | 28 | val c7 = Colors.parse("#19bf32") 29 | assertFalse(c7 approxEquals c6) 30 | assertFalse(c7 approxEquals c5) 31 | assertFalse(c7 approxEquals c4) 32 | assertFalse(c7 approxEquals c3) 33 | assertFalse(c7 approxEquals c2) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /platform/src/commonTest/kotlin/com/sixbynine/transit/path/time/UserPreferenceDayOfWeekComparatorTest.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.time 2 | 3 | import kotlinx.datetime.DayOfWeek.MONDAY 4 | import kotlinx.datetime.DayOfWeek.SATURDAY 5 | import kotlinx.datetime.DayOfWeek.SUNDAY 6 | import kotlinx.datetime.DayOfWeek.TUESDAY 7 | import kotlin.test.Test 8 | import kotlin.test.assertTrue 9 | 10 | class UserPreferenceDayOfWeekComparatorTest { 11 | @Test 12 | fun `monday as start`() { 13 | val comparator = UserPreferenceDayOfWeekComparator(MONDAY) 14 | assertTrue { comparator.compare(MONDAY, TUESDAY) < 0 } 15 | assertTrue { comparator.compare(SUNDAY, TUESDAY) > 0 } 16 | assertTrue { comparator.compare(MONDAY, SUNDAY) < 0 } 17 | } 18 | 19 | @Test 20 | fun `sunday as start`() { 21 | val comparator = UserPreferenceDayOfWeekComparator(SUNDAY) 22 | assertTrue { comparator.compare(MONDAY, TUESDAY) < 0 } 23 | assertTrue { comparator.compare(SUNDAY, TUESDAY) < 0 } 24 | assertTrue { comparator.compare(SUNDAY, SATURDAY) < 0 } 25 | assertTrue { comparator.compare(MONDAY, SUNDAY) > 0 } 26 | assertTrue { comparator.compare(SATURDAY, SATURDAY) == 0 } 27 | } 28 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/widget/StartConfigurationActivityAction.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget 2 | 3 | import android.appwidget.AppWidgetManager 4 | import android.content.Intent 5 | import androidx.compose.runtime.Composable 6 | import androidx.glance.LocalContext 7 | import androidx.glance.LocalGlanceId 8 | import androidx.glance.action.Action 9 | import androidx.glance.appwidget.GlanceAppWidgetManager 10 | import androidx.glance.appwidget.action.actionStartActivity 11 | import com.sixbynine.transit.path.widget.setup.WidgetSetupActivity 12 | 13 | @Composable 14 | fun startConfigurationActivityAction(): Action { 15 | val context = LocalContext.current 16 | val appWidgetManager = GlanceAppWidgetManager(context) 17 | val appWidgetId = appWidgetManager.getAppWidgetId(LocalGlanceId.current) 18 | val configurationIntent = 19 | Intent(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE) 20 | .setClass(LocalContext.current, WidgetSetupActivity::class.java) 21 | .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) 22 | .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) 23 | return actionStartActivity(configurationIntent) 24 | } -------------------------------------------------------------------------------- /iosApp/iosApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import ComposeApp 4 | 5 | struct ComposeView: UIViewControllerRepresentable { 6 | func makeUIViewController(context: Context) -> UIViewController { 7 | MainViewControllerKt.MainViewController() 8 | } 9 | 10 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 11 | } 12 | 13 | struct ContentView: View { 14 | var body: some View { 15 | ComposeView() 16 | .onAppear { 17 | AppLifecycleObserver().setAppIsActive(isActive: true) 18 | } 19 | .onDisappear { 20 | AppLifecycleObserver().setAppIsActive(isActive: false) 21 | } 22 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in 23 | AppLifecycleObserver().setAppIsActive(isActive: true) 24 | } 25 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in 26 | AppLifecycleObserver().setAppIsActive(isActive: false) 27 | } 28 | .ignoresSafeArea(.all) // Compose has own keyboard handler 29 | } 30 | } 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /iosApp/widget/EntryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntryView.swift 3 | // widgetExtension 4 | // 5 | // Created by Steven Kideckel on 2024-06-19. 6 | // Copyright © 2024 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ComposeApp 11 | 12 | protocol EntryView : View { 13 | var entry: DepartureBoardWidgetEntry { get } 14 | } 15 | 16 | extension EntryView { 17 | func measureTextHeight(text: String, font: UIFont) -> CGFloat { 18 | measureTextSize(text: text, font: font).height 19 | } 20 | 21 | func measureTextWidth(text: String, font: UIFont) -> CGFloat { 22 | measureTextSize(text: text, font: font).width 23 | } 24 | 25 | func measureTextSize(text: String, font: UIFont) -> CGRect { 26 | measureTextSize(maxSize: entry.size, text: text, font: font) 27 | } 28 | 29 | func formatTrainTime(_ time: Kotlinx_datetimeInstant) -> String { 30 | if (entry.configuration.timeDisplay == .clock) { 31 | return WidgetDataFormatter().formatTime(instant: time) 32 | } else { 33 | return WidgetDataFormatter().formatRelativeTime( 34 | now: entry.date.toKotlinInstant(), 35 | time: time 36 | ) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /platform/src/commonMain/kotlin/com/sixbynine/transit/path/preferences/IntPersistable.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.preferences 2 | 3 | import kotlin.enums.EnumEntries 4 | import kotlin.enums.enumEntries 5 | 6 | interface IntPersistable { 7 | val number: Int 8 | 9 | companion object { 10 | fun fromPersistence( 11 | number: Int, 12 | entries: EnumEntries 13 | ): E? where E : Enum, E : IntPersistable { 14 | return entries.find { it.number == number } 15 | } 16 | 17 | inline fun fromPersistence( 18 | number: Int 19 | ): E? where E : Enum, E : IntPersistable { 20 | return fromPersistence(number, enumEntries()) 21 | } 22 | 23 | inline fun createBitmask( 24 | values: Collection 25 | ): Int where E : Enum, E : IntPersistable { 26 | return values.fold(0) { acc, e -> acc or (1 shl e.number) } 27 | } 28 | 29 | inline fun fromBitmask( 30 | mask: Int 31 | ): Set where E : Enum, E : IntPersistable { 32 | return enumEntries().filter { (1 shl it.number) and mask != 0 }.toSet() 33 | } 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /platform/src/androidMain/kotlin/com/sixbynine/transit/path/time/AndroidTimeUtils.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.time 2 | 3 | import android.text.format.DateFormat 4 | import androidx.core.text.util.LocalePreferences 5 | import androidx.core.text.util.LocalePreferences.FirstDayOfWeek 6 | import com.sixbynine.transit.path.PathApplication 7 | import com.sixbynine.transit.path.PreviewContext 8 | import kotlinx.datetime.DayOfWeek 9 | 10 | object AndroidPlatformTimeUtils : PlatformTimeUtils { 11 | override fun is24HourClock(): Boolean { 12 | return DateFormat.is24HourFormat(PreviewContext ?: PathApplication.instance) 13 | } 14 | 15 | override fun getFirstDayOfWeek(): DayOfWeek = when (LocalePreferences.getFirstDayOfWeek()) { 16 | FirstDayOfWeek.MONDAY -> DayOfWeek.MONDAY 17 | FirstDayOfWeek.TUESDAY -> DayOfWeek.TUESDAY 18 | FirstDayOfWeek.WEDNESDAY -> DayOfWeek.WEDNESDAY 19 | FirstDayOfWeek.THURSDAY -> DayOfWeek.THURSDAY 20 | FirstDayOfWeek.FRIDAY -> DayOfWeek.FRIDAY 21 | FirstDayOfWeek.SATURDAY -> DayOfWeek.SATURDAY 22 | FirstDayOfWeek.SUNDAY -> DayOfWeek.SUNDAY 23 | else -> DayOfWeek.SUNDAY 24 | } 25 | } 26 | 27 | actual fun getPlatformTimeUtils(): PlatformTimeUtils { 28 | return AndroidPlatformTimeUtils 29 | } 30 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/app/ui/advancessettings/AdvancedSettingsPreview.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.advancessettings 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.sixbynine.transit.path.PathWidgetPreview 5 | import com.sixbynine.transit.path.PreviewTheme 6 | import com.sixbynine.transit.path.app.settings.AvoidMissingTrains.OffPeak 7 | import com.sixbynine.transit.path.app.settings.CommutingConfiguration 8 | import com.sixbynine.transit.path.app.settings.StationLimit.Six 9 | import com.sixbynine.transit.path.app.settings.TimeDisplay.Clock 10 | import com.sixbynine.transit.path.app.ui.advancedsettings.AdvancedSettingsContract.State 11 | import com.sixbynine.transit.path.app.ui.advancedsettings.AdvancedSettingsScreen 12 | 13 | @PathWidgetPreview 14 | @Composable 15 | fun AdvancedSettingsPreview() { 16 | PreviewTheme { 17 | AdvancedSettingsScreen( 18 | state = State( 19 | avoidMissingTrains = OffPeak, 20 | stationLimit = Six, 21 | timeDisplay = Clock, 22 | groupTrains = true, 23 | commutingConfiguration = CommutingConfiguration.default() 24 | ), 25 | onIntent = { 26 | 27 | } 28 | ) 29 | } 30 | } -------------------------------------------------------------------------------- /iosApp/widget/StationTitle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StationTitle.swift 3 | // widgetExtension 4 | // 5 | // Created by Steven Kideckel on 2024-06-19. 6 | // Copyright © 2024 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ComposeApp 11 | 12 | struct StationTitle: View { 13 | let title: String 14 | let width: CGFloat 15 | let maxHeight: CGFloat 16 | 17 | var body: some View { 18 | HStack(spacing: 0) { 19 | Spacer() 20 | Text( 21 | WidgetDataFormatter().formatHeadSign( 22 | title: title, 23 | fits: { 24 | let titleSpace = width - 16 25 | let textWidth = measureTextWidth( 26 | maxSize: CGSize(width: width, height: maxHeight), 27 | text: $0, 28 | font: UIFont.systemFont(ofSize: 14, weight: .bold) 29 | ) 30 | return (textWidth <= titleSpace).toKotlinBoolean() 31 | } 32 | ) 33 | ) 34 | .multilineTextAlignment(.center) 35 | .font(Font.system(size: 14)) 36 | .fontWeight(.bold) 37 | Spacer() 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/widget/StationByDisplayNameComparator.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget 2 | 3 | import com.sixbynine.transit.path.api.Station 4 | 5 | /** Comparator that sorts [Station]s by their display names. */ 6 | object StationByDisplayNameComparator : Comparator { 7 | override fun compare(first: Station, second: Station): Int { 8 | // Station names that start with digits should come at the end. 9 | val firstIsDigit = first.displayName.first().isDigit() 10 | val secondIsDigit = second.displayName.first().isDigit() 11 | if (firstIsDigit && !secondIsDigit) { 12 | return 1 13 | } 14 | 15 | if (secondIsDigit && !firstIsDigit) { 16 | return -1 17 | } 18 | 19 | if (!firstIsDigit) { 20 | return first.displayName.compareTo(second.displayName) 21 | } 22 | 23 | // If both the names start with digits, compare by the numerical value. This ensures that i.e. 24 | // '9th St' comes before '14th St'. 25 | val firstNumber = first.displayName.takeWhile { it.isDigit() }.toInt() 26 | val secondNumber = second.displayName.takeWhile { it.isDigit() }.toInt() 27 | return firstNumber.compareTo(secondNumber) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path 2 | 3 | import android.Manifest.permission.ACCESS_COARSE_LOCATION 4 | import android.Manifest.permission.ACCESS_FINE_LOCATION 5 | import android.os.Bundle 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.result.ActivityResultLauncher 8 | import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions 9 | import com.sixbynine.transit.path.location.AndroidLocationProvider 10 | 11 | abstract class BaseActivity : ComponentActivity() { 12 | 13 | private lateinit var locationPermissionRequest: ActivityResultLauncher> 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | locationPermissionRequest = 18 | registerForActivityResult(RequestMultiplePermissions()) { permissions -> 19 | AndroidLocationProvider.onLocationPermissionResult( 20 | permissions[ACCESS_FINE_LOCATION] == true || 21 | permissions[ACCESS_COARSE_LOCATION] == true 22 | ) 23 | } 24 | } 25 | 26 | fun requestLocationPermissions() { 27 | locationPermissionRequest.launch(arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/widget/GlanceExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget 2 | 3 | import androidx.annotation.ColorInt 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.datastore.preferences.core.MutablePreferences 6 | import androidx.glance.GlanceId 7 | import androidx.glance.appwidget.GlanceAppWidget 8 | import androidx.glance.appwidget.GlanceAppWidgetManager 9 | import androidx.glance.appwidget.state.updateAppWidgetState 10 | import com.sixbynine.transit.path.MobilePathApplication 11 | 12 | suspend inline fun updateAppWidgetStates( 13 | crossinline updateState: suspend (MutablePreferences, GlanceId) -> Unit, 14 | ) { 15 | val context = MobilePathApplication.instance 16 | GlanceAppWidgetManager(context) 17 | .getGlanceIds(T::class.java) 18 | .forEach { glanceId -> 19 | updateAppWidgetState(context, glanceId) { preferences -> 20 | updateState(preferences, glanceId) 21 | } 22 | } 23 | } 24 | 25 | fun @receiver:ColorInt Int.toColor(): Color { 26 | return Color( 27 | red = android.graphics.Color.red(this), 28 | green = android.graphics.Color.green(this), 29 | blue = android.graphics.Color.blue(this), 30 | alpha = android.graphics.Color.alpha(this) 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/widget/WidgetMeasurements.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget 2 | 3 | import android.content.Context 4 | import android.hardware.display.DisplayManager 5 | import android.os.Build.VERSION 6 | import android.view.Display 7 | import android.widget.TextView 8 | import androidx.compose.ui.unit.Dp 9 | import androidx.compose.ui.unit.TextUnit 10 | import androidx.compose.ui.unit.dp 11 | import kotlin.math.ceil 12 | 13 | /** Best guess at how wide [text] is when drawn with [size]. */ 14 | fun estimateTextWidth(context: Context, text: String, size: TextUnit): Dp { 15 | val textView = TextView(context.asUiContext()) 16 | if (VERSION.SDK_INT >= 29) { 17 | textView.setTextAppearance(android.R.style.Theme_DeviceDefault_DayNight) 18 | } 19 | require(size.isSp) 20 | textView.textSize = size.value 21 | return context.pxToDp(textView.paint.measureText(text)) 22 | } 23 | 24 | private fun Context.asUiContext(): Context { 25 | val displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager 26 | val defaultDisplay = displayManager.getDisplay(Display.DEFAULT_DISPLAY) 27 | return createDisplayContext(defaultDisplay) 28 | } 29 | 30 | private fun Context.pxToDp(px: Float): Dp { 31 | val density = resources.displayMetrics.density 32 | return ceil(px / density).dp 33 | } -------------------------------------------------------------------------------- /platform/src/jvmMain/kotlin/com/sixbynine/transit/path/util/GlobalDataStore.jvm.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | import com.sixbynine.transit.path.preferences.BooleanPreferencesKey 4 | import com.sixbynine.transit.path.preferences.LongPreferencesKey 5 | import com.sixbynine.transit.path.preferences.StringPreferencesKey 6 | import com.sixbynine.transit.path.preferences.createPreferences 7 | 8 | actual fun globalDataStore(): GlobalDataStore { 9 | val preferences = createPreferences() 10 | return object : GlobalDataStore { 11 | override fun set(key: String, value: String?) { 12 | preferences[StringPreferencesKey(key)] = value 13 | } 14 | 15 | override fun set(key: String, value: Boolean?) { 16 | preferences[BooleanPreferencesKey(key)] = value 17 | } 18 | 19 | override fun set(key: String, value: Long?) { 20 | preferences[LongPreferencesKey(key)] = value 21 | } 22 | 23 | override fun getString(key: String): String? { 24 | return preferences[StringPreferencesKey(key)] 25 | } 26 | 27 | override fun getBoolean(key: String): Boolean? { 28 | return preferences[BooleanPreferencesKey(key)] 29 | } 30 | 31 | override fun getLong(key: String): Long? { 32 | return preferences[LongPreferencesKey(key)] 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/widget/glance/ImageButton.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget.glance 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.unit.dp 6 | import androidx.glance.ColorFilter 7 | import androidx.glance.GlanceComposable 8 | import androidx.glance.GlanceModifier 9 | import androidx.glance.GlanceTheme 10 | import androidx.glance.Image 11 | import androidx.glance.ImageProvider 12 | import androidx.glance.action.Action 13 | import androidx.glance.action.clickable 14 | import androidx.glance.appwidget.cornerRadius 15 | import androidx.glance.unit.ColorProvider 16 | import org.jetbrains.compose.resources.StringResource 17 | 18 | @GlanceComposable 19 | @Composable 20 | fun ImageButton( 21 | modifier: GlanceModifier = GlanceModifier, 22 | @DrawableRes srcResId: Int, 23 | contentDesc: StringResource, 24 | onClick: Action, 25 | isClickable: Boolean = true, 26 | tintColor: ColorProvider = GlanceTheme.colors.primary 27 | ) { 28 | Image( 29 | modifier = modifier 30 | .let { if (isClickable) it.clickable(onClick) else it } 31 | .cornerRadius(200.dp), 32 | provider = ImageProvider(srcResId), 33 | contentDescription = stringResource(contentDesc), 34 | colorFilter = ColorFilter.tint(tintColor), 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/util/Localization.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | import androidx.compose.ui.text.intl.LocaleList 4 | import kotlinx.coroutines.runBlocking 5 | import org.jetbrains.compose.resources.getString 6 | import pathwidgetxplat.composeapp.generated.resources.Res.string 7 | import pathwidgetxplat.composeapp.generated.resources.language_code 8 | 9 | private val locale by lazy { 10 | runCatching { runBlocking { getString(string.language_code) } } 11 | .getOrElse { 12 | // Apparently, getString can crash sometimes? Maybe only when called from non-compose 13 | // code. Let's try this, even though it's also compose logic. We can hand-roll even more 14 | // if needed. 15 | val localeList = LocaleList.current 16 | localeList 17 | .firstOrNull { it.language == "es" || it.language == "en" } 18 | ?.language 19 | ?: localeList.firstOrNull()?.language 20 | ?: "en" 21 | } 22 | } 23 | 24 | fun localizedString(en: () -> String, es: () -> String): String { 25 | return when (locale) { 26 | "es" -> es() 27 | else -> en() 28 | } 29 | } 30 | 31 | fun localizedString(en: String, es: String): String { 32 | return when (locale) { 33 | "es" -> es 34 | else -> en 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/widget/glance/Typography.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget.glance 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.ProvidableCompositionLocal 5 | import androidx.compose.runtime.staticCompositionLocalOf 6 | import androidx.compose.ui.unit.sp 7 | import androidx.glance.text.FontWeight 8 | import androidx.glance.text.TextStyle 9 | 10 | data class Typography(val header: TextStyle, val primary: TextStyle, val secondary: TextStyle) 11 | 12 | @Composable 13 | fun createTypography( 14 | header: TextStyle = TextStyle( 15 | color = GlanceTheme.colors.onSurface, 16 | fontSize = 18.sp, 17 | fontWeight = FontWeight.Bold, 18 | ), 19 | primary: TextStyle = TextStyle( 20 | color = GlanceTheme.colors.onSurface, 21 | fontSize = 14.sp, 22 | fontWeight = FontWeight.Bold, 23 | ), 24 | secondary: TextStyle = TextStyle( 25 | color = GlanceTheme.colors.onSurfaceVariant, 26 | fontSize = 14.sp, 27 | fontWeight = FontWeight.Normal, 28 | ), 29 | ): Typography { 30 | return Typography( 31 | header = header, 32 | primary = primary, 33 | secondary = secondary, 34 | ) 35 | } 36 | 37 | internal val LocalTypography: ProvidableCompositionLocal = 38 | staticCompositionLocalOf { error("no typography") } 39 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/settings/StationLimitBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.settings 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.unit.dp 7 | import com.sixbynine.transit.path.app.settings.StationLimit 8 | import com.sixbynine.transit.path.app.ui.PathBottomSheet 9 | import org.jetbrains.compose.resources.stringResource 10 | import pathwidgetxplat.composeapp.generated.resources.Res.string 11 | import pathwidgetxplat.composeapp.generated.resources.settings_header_station_filter 12 | 13 | @Composable 14 | fun StationLimitBottomSheet( 15 | isShown: Boolean, 16 | limit: StationLimit, 17 | onLimitClicked: (StationLimit) -> Unit, 18 | onDismiss: () -> Unit, 19 | ) { 20 | PathBottomSheet( 21 | isShown = isShown, 22 | onDismissRequest = onDismiss, 23 | title = stringResource(string.settings_header_station_filter) 24 | ) { 25 | RadioSection(modifier = Modifier.padding(bottom = 16.dp)) { 26 | StationLimit.entries.forEach { 27 | item( 28 | text = stringResource(it.displayName), 29 | selected = it == limit, 30 | onClick = { onLimitClicked(it) } 31 | ) 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/settings/TrainFilterBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.settings 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.unit.dp 7 | import com.sixbynine.transit.path.api.TrainFilter 8 | import com.sixbynine.transit.path.app.ui.PathBottomSheet 9 | import org.jetbrains.compose.resources.stringResource 10 | import pathwidgetxplat.composeapp.generated.resources.Res.string 11 | import pathwidgetxplat.composeapp.generated.resources.filter 12 | 13 | @Composable 14 | fun TrainFilterBottomSheet( 15 | isShown: Boolean, 16 | filter: TrainFilter, 17 | onFilterClicked: (TrainFilter) -> Unit, 18 | onDismiss: () -> Unit, 19 | ) { 20 | PathBottomSheet( 21 | isShown = isShown, 22 | onDismissRequest = onDismiss, 23 | title = stringResource(string.filter) 24 | ) { 25 | RadioSection(modifier = Modifier.padding(bottom = 16.dp)) { 26 | TrainFilter.entries.forEach { 27 | item( 28 | text = stringResource(it.title), 29 | subtext = it.subtext?.let { stringResource(it) }, 30 | selected = it == filter, 31 | onClick = { onFilterClicked(it) } 32 | ) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/templine/HobClosureConfig.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api.templine 2 | 3 | import com.sixbynine.transit.path.schedule.LocalDateTimeSerializer 4 | import kotlinx.datetime.LocalDateTime 5 | import kotlinx.datetime.Month.FEBRUARY 6 | import kotlinx.datetime.Month.JANUARY 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data class HobClosureConfig( 11 | val tempLineInfo: TempLineInfo, 12 | @Serializable(with = LocalDateTimeSerializer::class) val validFrom: LocalDateTime?, 13 | @Serializable(with = LocalDateTimeSerializer::class) val validTo: LocalDateTime?, 14 | ) { 15 | companion object { 16 | val fallback get() = HobClosureConfig( 17 | tempLineInfo = TempLineInfo.fallback, 18 | validFrom = LocalDateTime(2025, JANUARY, 18, 23, 59), 19 | validTo = LocalDateTime(2025, FEBRUARY, 28, 5, 0), 20 | ) 21 | } 22 | } 23 | 24 | @Serializable 25 | data class TempLineInfo( 26 | val displayName: String, 27 | val codes: List, 28 | val lightColor: String, 29 | val darkColor: String?, 30 | ) { 31 | companion object { 32 | val fallback get() = TempLineInfo( 33 | displayName = "33rd Street ⇆ World Trade Center", 34 | codes = listOf("WTC-33", "33-WTC"), 35 | lightColor = "65C100", 36 | darkColor = null, 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/api/schedule/GithubScheduleRepository.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api.schedule 2 | 3 | import com.sixbynine.transit.path.schedule.Timetables 4 | import com.sixbynine.transit.path.util.FetchWithPrevious 5 | import com.sixbynine.transit.path.util.RemoteFileRepository 6 | import com.sixbynine.transit.path.util.combine 7 | import kotlinx.datetime.Instant 8 | import kotlin.time.Duration.Companion.days 9 | import kotlin.time.Duration.Companion.minutes 10 | 11 | object GithubScheduleRepository { 12 | private val scheduleRepo = RemoteFileRepository( 13 | keyPrefix = "github_schedule", 14 | url = "https://raw.githubusercontent.com/steviek/PathWidgetXplat/main/schedule.json", 15 | serializer = Timetables.serializer(), 16 | maxAge = 3.days 17 | ) 18 | 19 | private val scheduleOverrideRepo = RemoteFileRepository( 20 | keyPrefix = "github_schedule_override", 21 | url = "https://raw.githubusercontent.com/steviek/PathWidgetXplat/main/schedule_override.json", 22 | serializer = Timetables.serializer(), 23 | maxAge = 30.minutes 24 | ) 25 | 26 | fun getSchedules(now: Instant): FetchWithPrevious { 27 | return combine(scheduleRepo.get(now), scheduleOverrideRepo.get(now)) { schedule, override -> 28 | ScheduleAndOverride(regularSchedule = schedule, override = override) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /composeApp/src/desktopMain/kotlin/com/sixbynine/transit/path/location/LocationProvider.jvm.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.location 2 | 3 | import com.sixbynine.transit.path.util.DataResult 4 | import com.sixbynine.transit.path.util.stateFlowOf 5 | import kotlinx.coroutines.flow.MutableSharedFlow 6 | import kotlinx.coroutines.flow.SharedFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.asSharedFlow 9 | import kotlin.time.Duration 10 | 11 | object JvmLocationProvider : LocationProvider { 12 | override val isLocationSupportedByDeviceFlow: StateFlow> 13 | get() = stateFlowOf(DataResult.success(false)) 14 | 15 | private val _locationPermissionResults = MutableSharedFlow(replay = 1) 16 | override val locationPermissionResults: SharedFlow 17 | get() = _locationPermissionResults.asSharedFlow() 18 | 19 | override fun hasLocationPermission() = false 20 | 21 | override fun requestLocationPermission() { 22 | _locationPermissionResults.tryEmit(LocationPermissionRequestResult.Denied) 23 | } 24 | 25 | override suspend fun tryToGetLocation(): LocationCheckResult { 26 | return LocationCheckResult.NoProvider 27 | } 28 | 29 | override val defaultLocationCheckTimeout: Duration 30 | get() = Duration.INFINITE 31 | } 32 | 33 | actual fun LocationProvider() : LocationProvider = JvmLocationProvider -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/settings/TimeDisplayBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.settings 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.unit.dp 7 | import com.sixbynine.transit.path.app.settings.TimeDisplay 8 | import com.sixbynine.transit.path.app.ui.PathBottomSheet 9 | import org.jetbrains.compose.resources.stringResource 10 | import pathwidgetxplat.composeapp.generated.resources.Res.string 11 | import pathwidgetxplat.composeapp.generated.resources.setting_header_time_display 12 | 13 | @Composable 14 | fun TimeDisplayBottomSheet( 15 | isShown: Boolean, 16 | display: TimeDisplay, 17 | onTimeDisplayClicked: (TimeDisplay) -> Unit, 18 | onDismiss: () -> Unit, 19 | ) { 20 | PathBottomSheet( 21 | isShown = isShown, 22 | onDismissRequest = onDismiss, 23 | title = stringResource(string.setting_header_time_display) 24 | ) { 25 | RadioSection(modifier = Modifier.padding(bottom = 16.dp)) { 26 | TimeDisplay.entries.forEach { 27 | item( 28 | text = stringResource(it.title), 29 | subtext = it.subtitle(), 30 | selected = it == display, 31 | onClick = { onTimeDisplayClicked(it) } 32 | ) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/util/RemoteFileRepository.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | import com.sixbynine.transit.path.preferences.StringPreferencesKey 4 | import com.sixbynine.transit.path.time.now 5 | import kotlinx.datetime.Instant 6 | import kotlinx.serialization.KSerializer 7 | import kotlin.time.Duration 8 | 9 | class RemoteFileRepository( 10 | keyPrefix: String, 11 | private val url: String, 12 | maxAge: Duration, 13 | private val serializer: KSerializer 14 | ) { 15 | private var storedJson by persistingGlobally(StringPreferencesKey(keyPrefix)) 16 | private var storedTime by persistingInstantGlobally("{$keyPrefix}_time") 17 | 18 | private val dataSource = DataSource( 19 | getCached = getCached@{ 20 | val storedDataTime = storedTime ?: return@getCached null 21 | val storedDataJson = storedJson ?: return@getCached null 22 | val deserialized = JsonFormat.decodeFromString(serializer, storedDataJson) 23 | TimestampedValue(storedDataTime, deserialized) 24 | }, 25 | fetch = { 26 | val responseText = readRemoteFile(url).getOrThrow() 27 | 28 | storedJson = responseText 29 | storedTime = now() 30 | JsonFormat.decodeFromString(serializer, responseText) 31 | }, 32 | maxAge = maxAge 33 | ) 34 | 35 | fun get(now: Instant): FetchWithPrevious { 36 | return dataSource.get(now) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path 2 | 3 | import App 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.activity.compose.setContent 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.tooling.preview.Preview 9 | import androidx.glance.action.Action 10 | import androidx.glance.appwidget.action.actionStartActivity 11 | import androidx.glance.appwidget.updateAll 12 | import androidx.lifecycle.lifecycleScope 13 | import com.sixbynine.transit.path.widget.DepartureBoardWidget 14 | import kotlinx.coroutines.launch 15 | 16 | class MainActivity : BaseActivity() { 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | 20 | if (savedInstanceState == null) { 21 | lifecycleScope.launch { 22 | DepartureBoardWidget().updateAll(MobilePathApplication.instance) 23 | } 24 | } 25 | 26 | setContent { 27 | App() 28 | } 29 | } 30 | 31 | companion object { 32 | fun createAppWidgetLaunchAction(): Action { 33 | return Intent(MobilePathApplication.instance, MainActivity::class.java) 34 | .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) 35 | .let { actionStartActivity(it) } 36 | } 37 | } 38 | } 39 | 40 | @Preview 41 | @Composable 42 | fun AppAndroidPreview() { 43 | App() 44 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/settings/LineFilterBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.settings 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.unit.dp 9 | import com.sixbynine.transit.path.api.Line 10 | import com.sixbynine.transit.path.app.ui.PathBottomSheet 11 | import org.jetbrains.compose.resources.stringResource 12 | import pathwidgetxplat.composeapp.generated.resources.Res.string 13 | import pathwidgetxplat.composeapp.generated.resources.lines 14 | 15 | @Composable 16 | fun LineFilterBottomSheet( 17 | isShown: Boolean, 18 | lines: Set, 19 | onLineCheckedChange: (Line, Boolean) -> Unit, 20 | onDismiss: () -> Unit, 21 | ) { 22 | PathBottomSheet( 23 | isShown = isShown, 24 | onDismissRequest = onDismiss, 25 | title = stringResource(string.lines) 26 | ) { 27 | Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) { 28 | Line.permanentLines.forEach { line -> 29 | TrainLineCheckboxRow( 30 | line = line, 31 | checked = line in lines, 32 | onCheckedChange = { isChecked -> onLineCheckedChange(line, isChecked) }, 33 | ) 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /schedule/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | alias(libs.plugins.androidLibrary) 4 | alias(libs.plugins.kotlinSerialization) 5 | } 6 | 7 | kotlin { 8 | applyDefaultHierarchyTemplate() 9 | 10 | androidTarget() 11 | 12 | jvm() 13 | 14 | iosX64() 15 | iosArm64() 16 | iosSimulatorArm64() 17 | 18 | sourceSets { 19 | commonMain.dependencies { 20 | implementation(libs.kotlin.coroutines) 21 | implementation(libs.kotlin.date.time) 22 | implementation(libs.kotlin.serialization.json) 23 | implementation(projects.platform) 24 | } 25 | 26 | commonTest.dependencies { 27 | implementation(libs.kotlin.test) 28 | } 29 | 30 | androidMain.dependencies { 31 | implementation(libs.androidx.core.ktx) 32 | } 33 | 34 | all { 35 | languageSettings.optIn("kotlin.ExperimentalStdlibApi") 36 | languageSettings.optIn("kotlin.contracts.ExperimentalContracts") 37 | } 38 | } 39 | } 40 | 41 | android { 42 | namespace = "com.sixbynine.transit.path.schedule" 43 | 44 | defaultConfig { 45 | minSdk = libs.versions.android.minSdk.get().toInt() 46 | } 47 | 48 | buildFeatures { 49 | buildConfig = true 50 | } 51 | 52 | compileOptions { 53 | isCoreLibraryDesugaringEnabled = true 54 | } 55 | 56 | dependencies { 57 | coreLibraryDesugaring(libs.android.tools.desugar) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /platform/src/androidMain/kotlin/com/sixbynine/transit/path/network/AndroidNetworkManager.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.network 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.net.NetworkCapabilities 6 | import android.os.Build.VERSION 7 | import com.sixbynine.transit.path.PathApplication 8 | import com.sixbynine.transit.path.util.IsTest 9 | 10 | object AndroidNetworkManager : NetworkManager { 11 | private val connectivityManager: ConnectivityManager 12 | get() { 13 | return PathApplication.instance.getSystemService(Context.CONNECTIVITY_SERVICE) 14 | as ConnectivityManager 15 | } 16 | 17 | override fun isConnectedToInternet(): Boolean { 18 | if (IsTest) return true 19 | if (VERSION.SDK_INT < 23) { 20 | return connectivityManager.activeNetworkInfo?.isConnectedOrConnecting == true 21 | } 22 | 23 | val network = connectivityManager.activeNetwork ?: return false 24 | val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false 25 | return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || 26 | capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || 27 | capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) || 28 | capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) 29 | } 30 | } 31 | 32 | actual fun NetworkManager(): NetworkManager = AndroidNetworkManager 33 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/external/ExternalRoutingManager.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.external 2 | 3 | import PlatformType 4 | import getPlatform 5 | import org.jetbrains.compose.resources.getString 6 | import pathwidgetxplat.composeapp.generated.resources.Res.string 7 | import pathwidgetxplat.composeapp.generated.resources.sharing_message 8 | 9 | interface ExternalRoutingManager { 10 | suspend fun openEmail(): Boolean 11 | 12 | suspend fun openUrl(url: String): Boolean 13 | 14 | suspend fun shareTextToSystem(text: String): Boolean 15 | 16 | suspend fun launchAppRating(): Boolean 17 | } 18 | 19 | expect fun ExternalRoutingManager(): ExternalRoutingManager 20 | 21 | suspend fun ExternalRoutingManager.shareAppToSystem(): Boolean { 22 | val text = StringBuilder() 23 | 24 | text.appendLine(getString(string.sharing_message)) 25 | text.appendLine() 26 | 27 | listOf(AndroidSharingLine, "", IosSharingLine) 28 | .let { if (getPlatform().type == PlatformType.ANDROID) it else it.reversed() } 29 | .forEach { text.appendLine(it) } 30 | 31 | return shareTextToSystem(text.toString()) 32 | } 33 | 34 | const val FeedbackEmail = "sixbynineapps@gmail.com" 35 | 36 | const val AppStoreUrl = "https://apps.apple.com/id/app/departures-widget-for-path/id6470330823" 37 | 38 | private const val IosSharingLine = "iOS: $AppStoreUrl" 39 | 40 | private const val AndroidSharingLine = 41 | "Android: https://play.google.com/store/apps/details?id=com.sixbynine.transit.path" 42 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/MobilePathApplication.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path 2 | 3 | import com.google.firebase.Firebase 4 | import com.google.firebase.crashlytics.crashlytics 5 | import com.sixbynine.transit.path.analytics.Analytics 6 | import com.sixbynine.transit.path.app.ui.ActivityRegistry 7 | import com.sixbynine.transit.path.native.NativeHolder 8 | import com.sixbynine.transit.path.widget.WidgetRefreshWorker 9 | import com.sixbynine.transit.path.widget.WidgetReloader 10 | import kotlinx.coroutines.GlobalScope 11 | import kotlinx.coroutines.delay 12 | import kotlinx.coroutines.launch 13 | import kotlin.time.Duration.Companion.seconds 14 | 15 | class MobilePathApplication : PathApplication() { 16 | override fun onCreate() { 17 | super.onCreate() 18 | instance = this 19 | 20 | Analytics.appLaunched() 21 | 22 | NativeHolder.initialize( 23 | object : WidgetReloader { 24 | override fun reloadWidgets() { 25 | GlobalScope.launch { 26 | WidgetRefreshWorker.scheduleOneTime() 27 | } 28 | } 29 | }, 30 | Firebase.crashlytics::recordException 31 | ) 32 | 33 | ActivityRegistry.register(this) 34 | 35 | GlobalScope.launch { 36 | delay(1.seconds) 37 | WidgetRefreshWorker.schedule() 38 | } 39 | } 40 | 41 | companion object { 42 | lateinit var instance: MobilePathApplication 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /platform/src/commonMain/kotlin/com/sixbynine/transit/path/time/TimeUtils.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.time 2 | 3 | import kotlinx.datetime.Clock 4 | import kotlinx.datetime.DayOfWeek 5 | import kotlinx.datetime.Instant 6 | import kotlinx.datetime.LocalDate 7 | import kotlinx.datetime.TimeZone 8 | import kotlinx.datetime.toLocalDateTime 9 | 10 | interface PlatformTimeUtils { 11 | fun is24HourClock(): Boolean 12 | 13 | fun getFirstDayOfWeek(): DayOfWeek 14 | } 15 | 16 | expect fun getPlatformTimeUtils(): PlatformTimeUtils 17 | 18 | fun is24HourClock() = getPlatformTimeUtils().is24HourClock() 19 | 20 | fun now(): Instant = Clock.System.now() 21 | 22 | fun today(): LocalDate = now().toLocalDateTime(TimeZone.currentSystemDefault()).date 23 | 24 | val NewYorkTimeZone = TimeZone.of("America/New_York") 25 | 26 | fun DayOfWeek.plusDays(days: Int): DayOfWeek { 27 | var newOrdinal = ordinal + days 28 | while (newOrdinal < 0) { 29 | newOrdinal += 7 30 | } 31 | while (newOrdinal >= 7) { 32 | newOrdinal -= 7 33 | } 34 | return DayOfWeek.entries[newOrdinal] 35 | } 36 | 37 | fun DayOfWeek.minusDays(days: Int): DayOfWeek = plusDays(-days) 38 | 39 | fun DayOfWeek.previous() = minusDays(1) 40 | fun DayOfWeek.next() = plusDays(1) 41 | 42 | fun closedDayOfWeekSet(start: DayOfWeek, end: DayOfWeek): Set { 43 | return buildSet { 44 | var current = start 45 | while (current != end) { 46 | add(current) 47 | current = current.next() 48 | } 49 | add(end) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/util/DataSource.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | import kotlinx.coroutines.CoroutineStart.LAZY 4 | import kotlinx.coroutines.Deferred 5 | import kotlinx.coroutines.async 6 | import kotlinx.coroutines.sync.Mutex 7 | import kotlinx.coroutines.sync.withLock 8 | import kotlinx.datetime.Instant 9 | import kotlin.time.Duration 10 | 11 | internal class DataSource( 12 | private val getCached: () -> TimestampedValue?, 13 | private val fetch: suspend () -> T, 14 | private val maxAge: Duration, 15 | ) { 16 | 17 | private val fetchMutex = Mutex() 18 | private var ongoingFetch: Deferred>? = null 19 | 20 | fun get(now: Instant): FetchWithPrevious { 21 | val lastResult = runCatching { getCached() }.getOrNull()?.toAgedValue(now) 22 | 23 | if (lastResult != null && lastResult.age <= maxAge) { 24 | return FetchWithPrevious(lastResult) 25 | } 26 | 27 | val fetch = startOrJoinFetch() 28 | return FetchWithPrevious(previous = lastResult, fetch = fetch) 29 | } 30 | 31 | private fun startOrJoinFetch(): Deferred> { 32 | return IoScope.async(start = LAZY) { 33 | val asyncFetch = fetchMutex.withLock { 34 | ongoingFetch?.takeIf { it.isActive }?.let { return@withLock it } 35 | IoScope.asyncCatchingDataResult { fetch() }.also { ongoingFetch = it } 36 | } 37 | 38 | asyncFetch.await().also { ongoingFetch = null } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /flipper/src/androidDebug/kotlin/com/sixbynine/transit/path/flipper/FlipperUtil.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.flipper 2 | 3 | import android.content.Context 4 | import com.facebook.flipper.android.AndroidFlipperClient 5 | import com.facebook.flipper.plugins.inspector.DescriptorMapping 6 | import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin 7 | import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor 8 | import com.facebook.flipper.plugins.network.NetworkFlipperPlugin 9 | import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin 10 | import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin.SharedPreferencesDescriptor 11 | import com.facebook.soloader.SoLoader 12 | 13 | object FlipperUtil { 14 | 15 | private val networkPlugin = NetworkFlipperPlugin() 16 | 17 | fun initialize(context: Context) { 18 | SoLoader.init(context, false) 19 | 20 | val client = AndroidFlipperClient.getInstance(context) 21 | 22 | val sharedPreferencesDescriptors = 23 | listOf("path", "widget_data") 24 | .map { SharedPreferencesDescriptor(it, Context.MODE_PRIVATE) } 25 | 26 | client.addPlugin(InspectorFlipperPlugin(context, DescriptorMapping.withDefaults())) 27 | client.addPlugin(SharedPreferencesFlipperPlugin(context, sharedPreferencesDescriptors)) 28 | client.addPlugin(networkPlugin) 29 | 30 | client.start() 31 | } 32 | 33 | fun interceptor(): Any? { 34 | return FlipperOkhttpInterceptor(networkPlugin) 35 | } 36 | } -------------------------------------------------------------------------------- /iosApp/widget/ColorCircle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorCircle.swift 3 | // widgetExtension 4 | // 5 | // Created by Steven Kideckel on 2024-06-19. 6 | // Copyright © 2024 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ComposeApp 11 | 12 | struct ColorCircle: View { 13 | 14 | @Environment(\.colorScheme) var colorScheme 15 | 16 | let size: CGFloat 17 | let colors: [ColorWrapper] 18 | 19 | var body: some View { 20 | let isDark = colorScheme == .dark 21 | let color1 = colors.first?.toColor(isDark: isDark) ?? Color.black 22 | let color2 = colors.count > 1 ? colors[1].toColor(isDark: isDark) : color1 23 | ZStack { 24 | Circle() 25 | .fill(color1) 26 | .frame(width: size, height: size) 27 | 28 | SemiCircle() 29 | .fill(color2) 30 | .rotationEffect(.degrees(90)) 31 | .frame(width: size, height: size) 32 | } 33 | .overlay(isDark ? Circle().stroke(Color.white, lineWidth: 1) : nil) 34 | } 35 | 36 | private struct SemiCircle: Shape { 37 | func path(in rect: CGRect) -> Path { 38 | var path = Path() 39 | 40 | path.move(to: CGPoint(x: rect.minX, y: rect.midY)) 41 | path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: .degrees(0), endAngle: .degrees(180), clockwise: true) 42 | path.closeSubpath() 43 | 44 | return path 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Departures App for PATH 2 | 3 | This is a [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html) project targeting Android and iOS. 4 | 5 | The App UI is written in [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) with minimal platform-specific code. 6 | 7 | The widgets are native by necessity, and written in [SwiftUI](https://developer.apple.com/documentation/widgetkit/swiftui-views) on iOS and [Jetpack Glance](https://developer.android.com/develop/ui/compose/glance) for Android. 8 | 9 | ## Setup 10 | 11 | You can use any of the following options to work with the code here: 12 | 13 | - [Android Studio](https://developer.android.com/studio) Standard IDE for editing kotlin/Android code. With the [kotlin multiplatform plugin](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform), you can run the code on both Android and iOS 14 | - [Xcode](https://developer.apple.com/xcode/) allows for running the code on iOS, managing iOS simulators, and editing Swift code 15 | 16 | 17 | ## App store links 18 | 19 | [Android](https://play.google.com/store/apps/details?id=com.sixbynine.transit.path) 20 | 21 | [iOS](https://apps.apple.com/id/app/departures-widget-for-path/id6470330823?platform=iphone) 22 | 23 | ## Localizations 24 | 25 | English and Spanish currently. If you want to contribute something else feel free. Translations are found in the following files: 26 | 27 | - composeApp/src/commonMain/composeResources/values-{locale}/strings.xml 28 | - iosApp/widget/Localizable.xcstrings (edit in Xcode) 29 | - iosApp/iosApp/InfoPlist.xcstrings (edit in Xcode) 30 | -------------------------------------------------------------------------------- /platform/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | alias(libs.plugins.androidLibrary) 4 | alias(libs.plugins.kotlinSerialization) 5 | } 6 | 7 | kotlin { 8 | applyDefaultHierarchyTemplate() 9 | 10 | androidTarget() 11 | 12 | jvm() 13 | 14 | iosX64() 15 | iosArm64() 16 | iosSimulatorArm64() 17 | 18 | sourceSets { 19 | commonMain.dependencies { 20 | implementation(projects.flipper) 21 | 22 | implementation(libs.kotlin.coroutines) 23 | implementation(libs.kotlin.date.time) 24 | implementation(libs.kotlin.serialization.json) 25 | } 26 | 27 | commonTest.dependencies { 28 | implementation(libs.kotlin.test) 29 | } 30 | 31 | androidMain.dependencies { 32 | implementation(libs.androidx.core.ktx) 33 | } 34 | 35 | all { 36 | languageSettings.optIn("kotlin.ExperimentalStdlibApi") 37 | languageSettings.optIn("kotlin.contracts.ExperimentalContracts") 38 | languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") 39 | } 40 | } 41 | } 42 | 43 | android { 44 | namespace = "com.sixbynine.transit.path.platform" 45 | 46 | defaultConfig { 47 | minSdk = libs.versions.android.minSdk.get().toInt() 48 | } 49 | 50 | buildFeatures { 51 | buildConfig = true 52 | } 53 | 54 | compileOptions { 55 | isCoreLibraryDesugaringEnabled = true 56 | } 57 | 58 | dependencies { 59 | coreLibraryDesugaring(libs.android.tools.desugar) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/com/sixbynine/transit/path/api/impl/CheckpointMapTest.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api.impl 2 | 3 | import com.sixbynine.transit.path.api.Stations.Harrison 4 | import com.sixbynine.transit.path.api.Stations.Hoboken 5 | import com.sixbynine.transit.path.api.Stations.JournalSquare 6 | import com.sixbynine.transit.path.api.Stations.Newark 7 | import kotlin.test.Test 8 | import kotlin.test.assertEquals 9 | import kotlin.test.assertNull 10 | import kotlin.time.Duration.Companion.minutes 11 | 12 | class CheckpointMapTest { 13 | 14 | @Test 15 | fun `basic functions`() { 16 | assertEquals(0.minutes, Map[Newark]) 17 | assertEquals(1.minutes, Map[Harrison]) 18 | assertEquals(2.minutes, Map[JournalSquare]) 19 | assertNull(Map[Hoboken]) 20 | 21 | assertEquals(0, Map.getPosition(Newark)) 22 | assertEquals(1, Map.getPosition(Harrison)) 23 | assertEquals(2, Map.getPosition(JournalSquare)) 24 | assertNull(Map.getPosition(Hoboken)) 25 | } 26 | 27 | @Test 28 | fun `filtering middle`() { 29 | val filtered = Map.without(Harrison) 30 | 31 | assertEquals(checkpointMapOf(Newark to 0.minutes, JournalSquare to 2.minutes), filtered) 32 | } 33 | 34 | @Test 35 | fun `filtering start`() { 36 | val filtered = Map.without(Newark) 37 | 38 | assertEquals(checkpointMapOf(Harrison to 0.minutes, JournalSquare to 1.minutes), filtered) 39 | } 40 | 41 | companion object { 42 | val Map = 43 | checkpointMapOf(Newark to 0.minutes, Harrison to 1.minutes, JournalSquare to 2.minutes) 44 | } 45 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/settings/StationSortBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.settings 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.unit.dp 7 | import com.sixbynine.transit.path.api.StationSort 8 | import com.sixbynine.transit.path.api.StationSort.Proximity 9 | import com.sixbynine.transit.path.app.ui.PathBottomSheet 10 | import org.jetbrains.compose.resources.stringResource 11 | import pathwidgetxplat.composeapp.generated.resources.Res.string 12 | import pathwidgetxplat.composeapp.generated.resources.station_order 13 | 14 | @Composable 15 | fun StationSortBottomSheet( 16 | isShown: Boolean, 17 | sort: StationSort, 18 | onSortClicked: (StationSort) -> Unit, 19 | onDismiss: () -> Unit, 20 | ) { 21 | PathBottomSheet( 22 | isShown = isShown, 23 | onDismissRequest = onDismiss, 24 | title = stringResource(string.station_order) 25 | ) { 26 | RadioSection(modifier = Modifier.padding(bottom = 16.dp)) { 27 | StationSort.entries.forEach { 28 | if (it == Proximity && !StationSort.isProximityEnabled()) { 29 | return@forEach 30 | } 31 | 32 | item( 33 | text = stringResource(it.title), 34 | subtext = it.subtitle?.let { stringResource(it) }, 35 | selected = it == sort, 36 | onClick = { onSortClicked(it) } 37 | ) 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /iosApp/widget/SeasonalUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SeasonalUtils.swift 3 | // widgetExtension 4 | // 5 | // Created by Assistant on 2024-12-19. 6 | // Copyright © 2024 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Foundation 11 | import ComposeApp 12 | 13 | struct SeasonalUtils { 14 | 15 | /// Returns the appropriate background image name for the current season 16 | static func getSeasonalBackgroundName(for date: Date) -> String { 17 | let season = SeasonUtils().getSeasonForInstant(instant: date.toKotlinInstant()) 18 | switch season { 19 | case .spring: 20 | return "SpringBackground" 21 | case .summer: 22 | return "SummerBackground" 23 | case .fall: 24 | return "FallBackground" 25 | case .winter: 26 | return "WinterBackground" 27 | default: 28 | return "WinterBackground" 29 | } 30 | } 31 | 32 | /// Returns the appropriate text color for the current season 33 | /// Summer and Fall: white, Spring and Winter: custom dark colors 34 | static func getSeasonalTextColor(for date: Date) -> Color { 35 | let season = SeasonUtils().getSeasonForInstant(instant: date.toKotlinInstant()) 36 | switch season { 37 | case .spring: 38 | return Color(red: 0.173, green: 0.184, blue: 0.118) 39 | case .summer: 40 | return .white 41 | case .fall: 42 | return .white 43 | case .winter: 44 | return Color(red: 0.294, green: 0.239, blue: 0.043) 45 | default: 46 | return .white 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/app/ui/settings/SettingsScreenPreview.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.settings 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.runtime.setValue 8 | import com.sixbynine.transit.path.PathWidgetPreview 9 | import com.sixbynine.transit.path.PreviewTheme 10 | import com.sixbynine.transit.path.api.Line 11 | import com.sixbynine.transit.path.api.StationSort 12 | import com.sixbynine.transit.path.api.TrainFilter.Interstate 13 | import com.sixbynine.transit.path.app.settings.TimeDisplay.Relative 14 | import com.sixbynine.transit.path.app.ui.settings.SettingsContract.LocationSettingState 15 | import com.sixbynine.transit.path.app.ui.settings.SettingsContract.State 16 | 17 | @PathWidgetPreview 18 | @Composable 19 | fun SettingsScreenPreview() { 20 | PreviewTheme { 21 | var timeDisplay by remember { mutableStateOf(Relative) } 22 | val settingsScope = SettingsScope( 23 | state = State( 24 | locationSetting = LocationSettingState.Disabled, 25 | trainFilter = Interstate, 26 | lines = Line.permanentLines.toSet(), 27 | stationSort = StationSort.Alphabetical, 28 | showPresumedTrains = false, 29 | hasLocationPermission = false, 30 | devOptionsEnabled = false, 31 | ), 32 | onIntent = { intent -> 33 | 34 | } 35 | ) 36 | settingsScope.Content() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /platform/src/iosMain/kotlin/com/sixbynine/transit/path/time/NativeTimeUtils.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.time 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.datetime.DayOfWeek 5 | import platform.Foundation.NSDateFormatter 6 | import platform.Foundation.NSLocale 7 | import platform.Foundation.currentLocale 8 | 9 | object IosPlatformTimeUtils : PlatformTimeUtils { 10 | 11 | private val firstDayOfWeek = MutableStateFlow(null) 12 | 13 | fun setFirstDayOfWeek(firstDayOfWeek: String?) { 14 | this.firstDayOfWeek.value = firstDayOfWeek 15 | } 16 | 17 | override fun is24HourClock(): Boolean { 18 | val dateFormat = NSDateFormatter.dateFormatFromTemplate( 19 | tmplate = "j", 20 | options = 0U, 21 | locale = NSLocale.currentLocale 22 | ) 23 | return dateFormat?.indexOf("a")?.let { it < 0 } ?: true 24 | } 25 | 26 | override fun getFirstDayOfWeek(): DayOfWeek { 27 | val value = firstDayOfWeek.value?.lowercase() 28 | return when { 29 | value == null || value.startsWith("su") -> DayOfWeek.SUNDAY 30 | value.startsWith("sa") -> DayOfWeek.SATURDAY 31 | value.startsWith("m") -> DayOfWeek.MONDAY 32 | value.startsWith("tu") -> DayOfWeek.TUESDAY 33 | value.startsWith("w") -> DayOfWeek.WEDNESDAY 34 | value.startsWith("th") -> DayOfWeek.THURSDAY 35 | value.startsWith("f") -> DayOfWeek.FRIDAY 36 | else -> DayOfWeek.SUNDAY 37 | } 38 | } 39 | } 40 | 41 | actual fun getPlatformTimeUtils(): PlatformTimeUtils { 42 | return IosPlatformTimeUtils 43 | } 44 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/widget/PathWidgetConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.widget 2 | 3 | import com.sixbynine.transit.path.api.Line 4 | import com.sixbynine.transit.path.api.StationChoice 5 | import com.sixbynine.transit.path.api.StationSort 6 | import com.sixbynine.transit.path.api.Stations 7 | import com.sixbynine.transit.path.api.TrainFilter 8 | 9 | sealed interface PathWidgetConfiguration { 10 | val stationLimit: Int 11 | 12 | data class DepartureBoard( 13 | override val stationLimit: Int, 14 | val stationChoices: List, 15 | val lines: Collection, 16 | val sort: StationSort, 17 | val filter: TrainFilter, 18 | ) : PathWidgetConfiguration 19 | 20 | data class Commute( 21 | val origin: StationChoice, 22 | val destination: StationChoice, 23 | ) : PathWidgetConfiguration { 24 | override val stationLimit: Int = 1 25 | } 26 | 27 | companion object { 28 | fun allData( 29 | includeClosestStation: Boolean, 30 | sort: StationSort = StationSort.Alphabetical 31 | ): DepartureBoard { 32 | return DepartureBoard( 33 | stationLimit = Int.MAX_VALUE, 34 | stationChoices = buildList { 35 | if (includeClosestStation) add(StationChoice.Closest) 36 | Stations.All.forEach { add(StationChoice.Fixed(it)) } 37 | }, 38 | lines = Line.entries, 39 | sort = StationSort.Alphabetical, 40 | filter = TrainFilter.All, 41 | ) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/settings/CommutingConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.settings 2 | 3 | import com.sixbynine.transit.path.schedule.DailySchedule 4 | import com.sixbynine.transit.path.schedule.Schedule 5 | import kotlinx.datetime.DayOfWeek 6 | import kotlinx.datetime.DayOfWeek.FRIDAY 7 | import kotlinx.datetime.DayOfWeek.MONDAY 8 | import kotlinx.datetime.DayOfWeek.THURSDAY 9 | import kotlinx.datetime.DayOfWeek.TUESDAY 10 | import kotlinx.datetime.DayOfWeek.WEDNESDAY 11 | import kotlinx.datetime.LocalDateTime 12 | import kotlinx.datetime.LocalTime 13 | import kotlinx.serialization.Serializable 14 | 15 | @Serializable 16 | data class CommutingConfiguration( 17 | val schedules: List, 18 | ) : Schedule { 19 | override fun isActiveAt(dateTime: LocalDateTime): Boolean { 20 | return activeSchedule.isActiveAt(dateTime) 21 | } 22 | 23 | companion object { 24 | val DefaultSchedule = CommutingSchedule( 25 | days = setOf(MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY), 26 | start = LocalTime(12, 0), 27 | end = LocalTime(3, 0), 28 | ) 29 | 30 | fun default(): CommutingConfiguration { 31 | return CommutingConfiguration(schedules = listOf(DefaultSchedule)) 32 | } 33 | } 34 | } 35 | 36 | val CommutingConfiguration.activeSchedule: CommutingSchedule 37 | get() = schedules.firstOrNull() ?: CommutingConfiguration.DefaultSchedule 38 | 39 | @Serializable 40 | data class CommutingSchedule( 41 | override val days: Set, 42 | override val start: LocalTime, 43 | override val end: LocalTime, 44 | ) : DailySchedule 45 | -------------------------------------------------------------------------------- /iosApp/widget/Interop.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Interop.swift 3 | // widget2Extension 4 | // 5 | // Created by Steven Kideckel on 2023-10-18. 6 | // Copyright © 2023 orgName. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ComposeApp 11 | import SwiftUI 12 | import WidgetKit 13 | 14 | extension Date { 15 | func toKotlinInstant() -> Kotlinx_datetimeInstant { 16 | Kotlinx_datetimeInstant.Companion().fromEpochMilliseconds( 17 | epochMilliseconds: Int64(self.timeIntervalSince1970 * 1000) 18 | ) 19 | } 20 | } 21 | 22 | extension Kotlinx_datetimeInstant { 23 | func toDate() -> Date { 24 | Date(timeIntervalSince1970: Double(self.toEpochMilliseconds()) / 1000.0) 25 | } 26 | } 27 | 28 | extension Bool { 29 | func toKotlinBoolean() -> KotlinBoolean { 30 | KotlinBoolean(bool: self) 31 | } 32 | } 33 | 34 | extension KotlinBoolean { 35 | func toBool() -> Bool { 36 | self.boolValue 37 | } 38 | } 39 | 40 | extension ColorWrapper { 41 | func toColor(isDark: Bool) -> SwiftUI.Color { 42 | let adjusted = adjustForDarkMode(isDark: isDark) 43 | return SwiftUI.Color( 44 | red: Double(adjusted.red), 45 | green: Double(adjusted.green), 46 | blue: Double(adjusted.blue) 47 | ) 48 | } 49 | } 50 | 51 | extension TimeDisplay { 52 | func toKotlinTimeDisplay() -> ComposeApp.TimeDisplay { 53 | switch self { 54 | case .clock: 55 | return ComposeApp.TimeDisplay.clock 56 | case .relative: 57 | return ComposeApp.TimeDisplay.relative 58 | default: 59 | return ComposeApp.TimeDisplay.clock 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/model/Season.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.model 2 | 3 | import kotlinx.datetime.Instant 4 | import kotlinx.datetime.LocalDate 5 | import kotlinx.datetime.Month 6 | import kotlinx.datetime.Month.DECEMBER 7 | import kotlinx.datetime.Month.JUNE 8 | import kotlinx.datetime.Month.MARCH 9 | import kotlinx.datetime.Month.SEPTEMBER 10 | import kotlinx.datetime.TimeZone 11 | import kotlinx.datetime.toLocalDateTime 12 | 13 | enum class Season { 14 | Spring, 15 | Summer, 16 | Fall, 17 | Winter 18 | } 19 | 20 | object SeasonUtils { 21 | /** 22 | * Determines the current season based on equinoxes and solstices. 23 | * Uses the local system timezone for date calculations. 24 | * 25 | * Approximate dates: 26 | * - Spring Equinox: March 20 27 | * - Summer Solstice: June 21 28 | * - Fall Equinox: September 22 29 | * - Winter Solstice: December 21 30 | */ 31 | fun getSeasonForInstant(instant: Instant): Season { 32 | val localDate = instant.toLocalDateTime(TimeZone.currentSystemDefault()).date 33 | val year = localDate.year 34 | 35 | val springEquinox = LocalDate(year, MARCH, 20) 36 | val summerSolstice = LocalDate(year, JUNE, 21) 37 | val fallEquinox = LocalDate(year, SEPTEMBER, 22) 38 | val winterSolstice = LocalDate(year, DECEMBER, 21) 39 | 40 | return when { 41 | localDate >= winterSolstice -> Season.Winter 42 | localDate >= fallEquinox -> Season.Fall 43 | localDate >= summerSolstice -> Season.Summer 44 | localDate >= springEquinox -> Season.Spring 45 | else -> Season.Winter 46 | } 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/setup/SetupScreenViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.setup 2 | 3 | import com.sixbynine.transit.path.app.ui.setup.SetupScreenContract.Effect 4 | import com.sixbynine.transit.path.app.ui.setup.SetupScreenContract.Intent.ConfirmClicked 5 | import com.sixbynine.transit.path.app.ui.setup.SetupScreenContract.Intent.StationCheckedChanged 6 | import dev.icerock.moko.mvvm.viewmodel.ViewModel 7 | import kotlinx.coroutines.channels.Channel 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.asStateFlow 10 | import kotlinx.coroutines.flow.receiveAsFlow 11 | import kotlinx.coroutines.flow.update 12 | import kotlinx.coroutines.launch 13 | 14 | class SetupScreenViewModel : ViewModel() { 15 | private val _state = MutableStateFlow(SetupScreenContract.State()) 16 | val state = _state.asStateFlow() 17 | 18 | private val _effects = Channel() 19 | val effects = _effects.receiveAsFlow() 20 | 21 | fun onIntent(intent: SetupScreenContract.Intent) { 22 | when (intent) { 23 | is StationCheckedChanged -> { 24 | _state.update { 25 | it.copy( 26 | selectedStations = if (intent.isChecked) { 27 | it.selectedStations + intent.station 28 | } else { 29 | it.selectedStations - intent.station 30 | } 31 | ) 32 | } 33 | } 34 | 35 | ConfirmClicked -> { 36 | viewModelScope.launch { 37 | _effects.send(Effect.NavigateToHome) 38 | } 39 | } 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /iosApp/iosApp/InfoPlist.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "CFBundleDisplayName" : { 5 | "comment" : "Bundle display name", 6 | "extractionState" : "extracted_with_value", 7 | "localizations" : { 8 | "en" : { 9 | "stringUnit" : { 10 | "state" : "new", 11 | "value" : "Departures for PATH" 12 | } 13 | }, 14 | "es" : { 15 | "stringUnit" : { 16 | "state" : "translated", 17 | "value" : "Salidas de PATH" 18 | } 19 | } 20 | } 21 | }, 22 | "CFBundleName" : { 23 | "comment" : "Bundle name", 24 | "extractionState" : "extracted_with_value", 25 | "localizations" : { 26 | "en" : { 27 | "stringUnit" : { 28 | "state" : "new", 29 | "value" : "Departures for PATH" 30 | } 31 | }, 32 | "es" : { 33 | "stringUnit" : { 34 | "state" : "translated", 35 | "value" : "Salidas" 36 | } 37 | } 38 | } 39 | }, 40 | "NSLocationWhenInUseUsageDescription" : { 41 | "comment" : "Privacy - Location When In Use Usage Description", 42 | "extractionState" : "extracted_with_value", 43 | "localizations" : { 44 | "en" : { 45 | "stringUnit" : { 46 | "state" : "new", 47 | "value" : "Your location will be used to identify the closest PATH station" 48 | } 49 | }, 50 | "es" : { 51 | "stringUnit" : { 52 | "state" : "needs_review", 53 | "value" : "Su ubicación se utilizará para identificar la estación de PATH más cercana." 54 | } 55 | } 56 | } 57 | } 58 | }, 59 | "version" : "1.0" 60 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/home/TrainLine.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.home 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.text.TextStyle 8 | import com.sixbynine.transit.path.app.ui.common.AppUiTrainData 9 | 10 | @Composable 11 | fun HomeScreenScope.TrainLineContent( 12 | data: AppUiTrainData, 13 | modifier: Modifier = Modifier, 14 | textStyle: TextStyle = MaterialTheme.typography.titleMedium, 15 | subtitleTextStyle: TextStyle = MaterialTheme.typography.bodyLarge, 16 | textColor: Color = MaterialTheme.colorScheme.onSurface, 17 | fullWidth: Boolean = true, 18 | ) { 19 | TrainLineContent( 20 | data = listOf(data), 21 | modifier = modifier, 22 | textStyle = textStyle, 23 | subtitleTextStyle = subtitleTextStyle, 24 | textColor = textColor, 25 | fullWidth = fullWidth, 26 | ) 27 | } 28 | 29 | @Composable 30 | fun HomeScreenScope.TrainLineContent( 31 | data: List, 32 | modifier: Modifier = Modifier, 33 | textStyle: TextStyle = MaterialTheme.typography.titleMedium, 34 | subtitleTextStyle: TextStyle = MaterialTheme.typography.bodyLarge, 35 | textColor: Color = MaterialTheme.colorScheme.onSurface, 36 | fullWidth: Boolean = true, 37 | ) { 38 | com.sixbynine.transit.path.app.ui.common.TrainLineContent( 39 | data = data, 40 | timeDisplay = state.timeDisplay, 41 | modifier = modifier, 42 | textStyle = textStyle, 43 | subtitleTextStyle = subtitleTextStyle, 44 | textColor = textColor, 45 | fullWidth = fullWidth, 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /schedule/generator/src/main/kotlin/com/sixbynine/transit/path/schedule/generator/ScheduleGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.schedule.generator 2 | 3 | import com.sixbynine.transit.path.Logging 4 | import com.sixbynine.transit.path.util.readRemoteFile 5 | import kotlinx.coroutines.CompletableDeferred 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.launch 9 | import kotlinx.coroutines.runBlocking 10 | import kotlinx.serialization.encodeToString 11 | import kotlinx.serialization.json.Json 12 | import java.io.File 13 | import kotlin.system.exitProcess 14 | 15 | private val jobComplete = CompletableDeferred() 16 | 17 | fun main() { 18 | val taskScope = CoroutineScope(Dispatchers.Default) 19 | taskScope.launch { ScheduleGenerator.generateSchedule() } 20 | 21 | runBlocking { 22 | jobComplete.await() 23 | exitProcess(0) 24 | } 25 | } 26 | 27 | object ScheduleGenerator { 28 | private val json = Json { } 29 | 30 | suspend fun generateSchedule() { 31 | val response = 32 | readRemoteFile("https://www.panynj.gov/content/path/en.model.json") 33 | .getOrElse { 34 | Logging.e("Failed to reach path model", it) 35 | exitProcess(1) 36 | } 37 | 38 | val schedules = ScheduleParser.parse(response, "regular") 39 | 40 | val outputDir = File("build/outputs") 41 | outputDir.mkdirs() 42 | 43 | val outputFile = File(outputDir, "schedule.json") 44 | val jsonString = json.encodeToString(schedules) 45 | outputFile.writeText(jsonString) 46 | 47 | println("Schedule written to: ${outputFile.absolutePath} successfully.") 48 | 49 | jobComplete.complete(Unit) 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /platform/src/iosMain/kotlin/com/sixbynine/transit/path/util/GlobalDataStore.ios.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.util 2 | 3 | import platform.Foundation.NSUserDefaults 4 | 5 | object NativeGlobalDataStore : GlobalDataStore { 6 | 7 | private val defaults: NSUserDefaults = 8 | NSUserDefaults(suiteName = "group.com.sixbynine.transit.path") 9 | 10 | override fun set(key: String, value: String?) { 11 | if (value == null) { 12 | remove(key) 13 | return 14 | } 15 | defaults.setObject(value, key) 16 | defaults.synchronize() 17 | } 18 | 19 | override fun getString(key: String): String? { 20 | return defaults.stringForKey(key) 21 | } 22 | 23 | override fun set(key: String, value: Boolean?) { 24 | if (value == null) { 25 | remove(key) 26 | return 27 | } 28 | defaults.setBool(value, key) 29 | defaults.synchronize() 30 | } 31 | 32 | override fun getBoolean(key: String): Boolean? { 33 | if (defaults.objectForKey(key) == null) { 34 | return null 35 | } 36 | return defaults.boolForKey(key) 37 | } 38 | 39 | override fun set(key: String, value: Long?) { 40 | if (value == null) { 41 | remove(key) 42 | return 43 | } 44 | defaults.setInteger(value, key) 45 | defaults.synchronize() 46 | } 47 | 48 | override fun getLong(key: String): Long? { 49 | if (defaults.objectForKey(key) == null) { 50 | return null 51 | } 52 | return defaults.integerForKey(key) 53 | } 54 | 55 | private fun remove(key: String) { 56 | defaults.removeObjectForKey(key) 57 | } 58 | } 59 | 60 | actual fun globalDataStore(): GlobalDataStore = NativeGlobalDataStore 61 | -------------------------------------------------------------------------------- /api/src/androidUnitTest/resources/com/sixbynine/transit/path/everbridge_alert_jan18.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "Success", 3 | "data": [ 4 | { 5 | "incidentMessage": { 6 | "subject": "Hack River Bridge Lift Fri 01-17-2025", 7 | "preMessage": "Plan Ahead: Fri 01-17-2025 12:30pm: Mandatory Hack River Bridge lift scheduled. Time subject to change. Possible NWK-WTC impacts. Please allow extra travel time.", 8 | "formVariableItems": [ 9 | { 10 | "val": [ 11 | "NWK-WTC" 12 | ], 13 | "variableName": "Lines", 14 | "isRequired": true, 15 | "seq": 1, 16 | "variableId": 367103739691127 17 | }, 18 | { 19 | "val": [ 20 | "01-17-2025" 21 | ], 22 | "variableName": "Date", 23 | "isRequired": true, 24 | "seq": 2, 25 | "variableId": 392942262944022, 26 | "prefixName": "1" 27 | }, 28 | { 29 | "val": [ 30 | "Fri" 31 | ], 32 | "variableName": "Day of the Week", 33 | "isRequired": true, 34 | "seq": 3, 35 | "variableId": 392942262944016, 36 | "prefixName": "1" 37 | }, 38 | { 39 | "val": [ 40 | "12:30pm" 41 | ], 42 | "variableName": "Time Range 1", 43 | "isRequired": true, 44 | "seq": 4, 45 | "variableId": 2626187817910379, 46 | "prefixName": "1" 47 | } 48 | ], 49 | "sysVarTodayDateFormat": "mm-dd-yyyy", 50 | "sysVarCurrentTimeFormat": "HH:mm:ss" 51 | }, 52 | "CreatedDate": "1737118615522", 53 | "ModifiedDate": "1737118615522" 54 | } 55 | ] 56 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/sixbynine/transit/path/app/settings/DevOptionsExport.android.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.settings 2 | 3 | import android.content.Intent 4 | import androidx.core.content.FileProvider 5 | import com.sixbynine.transit.path.LogRecord 6 | import com.sixbynine.transit.path.PathApplication 7 | import com.sixbynine.transit.path.app.ui.ActivityRegistry 8 | import com.sixbynine.transit.path.time.NewYorkTimeZone 9 | import kotlinx.datetime.toLocalDateTime 10 | import java.io.BufferedWriter 11 | import java.io.File 12 | import java.io.FileWriter 13 | 14 | actual fun exportDevLogs(logs: List) { 15 | val context = PathApplication.instance 16 | val activity = ActivityRegistry.peekCreatedActivity() ?: return 17 | val filename = "logs.tsv" 18 | val logsDir = File(context.cacheDir, "logs") 19 | logsDir.mkdirs() 20 | val file = File(logsDir, filename) 21 | file.createNewFile() 22 | 23 | BufferedWriter(FileWriter(file)).use { writer -> 24 | writer.write("timestamp\tlevel\tmessage") 25 | writer.newLine() 26 | 27 | logs.asReversed().forEach { log -> 28 | writer.write(log.timestamp.toLocalDateTime(NewYorkTimeZone).toString()) 29 | writer.write("\t") 30 | writer.write(log.level.toString()) 31 | writer.write("\t") 32 | writer.write(log.message) 33 | writer.newLine() 34 | } 35 | } 36 | 37 | val uri = FileProvider.getUriForFile(context, context.packageName + ".provider", file) 38 | val shareIntent = Intent(Intent.ACTION_SEND).apply { 39 | type = "text/tsv" 40 | putExtra(Intent.EXTRA_STREAM, uri) 41 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 42 | } 43 | activity.startActivity(Intent.createChooser(shareIntent, "Share TSV")) 44 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/PathViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui 2 | 3 | import com.sixbynine.transit.path.util.launchAndReturnUnit 4 | import dev.icerock.moko.mvvm.viewmodel.ViewModel 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.cancel 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlin.time.ComparableTimeMark 11 | import kotlin.time.Duration.Companion.milliseconds 12 | import kotlin.time.TimeSource.Monotonic 13 | 14 | abstract class PathViewModel : ViewModel() { 15 | 16 | private var lastIntent: Intent? = null 17 | private var lastIntentTime: ComparableTimeMark? = null 18 | 19 | protected val lightweightScope = CoroutineScope(Dispatchers.Default) 20 | 21 | protected open val rateLimitedIntents: Set = emptySet() 22 | 23 | override fun onCleared() { 24 | super.onCleared() 25 | lightweightScope.cancel() 26 | } 27 | 28 | abstract val state: StateFlow 29 | abstract val effects: Flow 30 | 31 | protected abstract suspend fun performIntent(intent: Intent) 32 | 33 | fun onIntent(intent: Intent) = lightweightScope.launchAndReturnUnit { 34 | val lastIntent = lastIntent 35 | val lastIntentTime = lastIntentTime 36 | val now = Monotonic.markNow() 37 | if (lastIntent == intent && 38 | lastIntentTime != null && 39 | lastIntentTime > (now - 500.milliseconds) && 40 | intent in rateLimitedIntents 41 | ) { 42 | return@launchAndReturnUnit 43 | } 44 | this.lastIntent = intent 45 | this.lastIntentTime = now 46 | performIntent(intent) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/ViewModelScreen.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui 2 | 3 | import LocalNavigator 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.ui.platform.LocalUriHandler 9 | import androidx.compose.ui.platform.UriHandler 10 | import dev.icerock.moko.mvvm.compose.getViewModel 11 | import dev.icerock.moko.mvvm.compose.viewModelFactory 12 | import kotlinx.coroutines.flow.Flow 13 | import moe.tlaster.precompose.navigation.Navigator 14 | 15 | @Composable 16 | fun ViewModelScreen( 17 | viewModelKey: String, 18 | createViewModel: () -> PathViewModel, 19 | onEffect: suspend HandleEffectsScope.(Effect) -> Unit, 20 | content: @Composable ScreenScope.() -> Unit, 21 | ) { 22 | val viewModel = getViewModel( 23 | key = viewModelKey, 24 | factory = viewModelFactory { createViewModel() } 25 | ) 26 | HandleEffects(viewModel.effects) { effect -> 27 | onEffect(effect) 28 | } 29 | val state by viewModel.state.collectAsState() 30 | val scope = ScreenScope(state, viewModel::onIntent) 31 | scope.content() 32 | } 33 | 34 | @Composable 35 | fun HandleEffects(flow: Flow, onEffect: suspend HandleEffectsScope.(T) -> Unit) { 36 | val scope = HandleEffectsScope(LocalNavigator.current, LocalUriHandler.current) 37 | LaunchedEffect(Unit) { 38 | flow.collect { scope.onEffect(it) } 39 | } 40 | } 41 | 42 | data class HandleEffectsScope(val navigator: Navigator, val uriHandler: UriHandler) 43 | 44 | data class ScreenScope(val state: State, val onIntent: (Intent) -> Unit) 45 | -------------------------------------------------------------------------------- /api/src/commonMain/kotlin/com/sixbynine/transit/path/model/Colors.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.model 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import com.sixbynine.transit.path.Logging 5 | import com.sixbynine.transit.path.api.templine.HobClosureConfigRepository 6 | 7 | object Colors { 8 | fun parse(hexString: String): Color { 9 | return Color(hexString.removePrefix("#").toLong(16) or 0x00000000FF000000) 10 | } 11 | 12 | val Path: ColorWrapper 13 | get() = ColorWrapper(Color(0xFF1896D1)) 14 | 15 | val NwkWtcSingle = Color(red = 0xd9, green = 0x3a, blue = 0x30).wrap() 16 | val Jsq33sSingle = Color(red = 0xff, green = 0x99, blue = 0x00).wrap() 17 | val Hob33sSingle = Color(red = 0x4d, green = 0x92, blue = 0xfb).wrap() 18 | val HobWtcSingle = Color(red = 0x65, green = 0xc1, blue = 0x00).wrap() 19 | val Wtc33sSingle by lazy { 20 | HobClosureConfigRepository.getConfig().tempLineInfo.lightColor 21 | .let { parse(it) } 22 | .wrap() 23 | } 24 | val NwkWtc = listOf(NwkWtcSingle) 25 | val Jsq33s = listOf(Jsq33sSingle) 26 | val Hob33s = listOf(Hob33sSingle) 27 | val HobWtc = listOf(HobWtcSingle) 28 | val Wtc33s by lazy { listOf(Wtc33sSingle) } 29 | 30 | fun background(isDark: Boolean): ColorWrapper { 31 | Logging.initialize() 32 | return if (isDark) { 33 | Color(0xFF191C1E).wrap() 34 | } else { 35 | Color(0xFFFCFCFF).wrap() 36 | } 37 | } 38 | 39 | fun Color.wrap(): ColorWrapper { 40 | return ColorWrapper(this) 41 | } 42 | 43 | infix fun Color.approxEquals(other: Color): Boolean { 44 | val dR = red - other.red 45 | val dG = green - other.green 46 | val dB = blue - other.blue 47 | val delta = (dR * dR) + (dG * dG) + (dB * dB) 48 | return delta < .1f 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/api/LineExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.api 2 | 3 | import com.sixbynine.transit.path.api.Line.Hoboken33rd 4 | import com.sixbynine.transit.path.api.Line.HobokenWtc 5 | import com.sixbynine.transit.path.api.Line.JournalSquare33rd 6 | import com.sixbynine.transit.path.api.Line.NewarkWtc 7 | import com.sixbynine.transit.path.api.impl.LineComputer 8 | import com.sixbynine.transit.path.app.ui.common.AppUiTrainData 9 | import com.sixbynine.transit.path.model.ColorWrapper 10 | import com.sixbynine.transit.path.model.Colors 11 | import com.sixbynine.transit.path.model.DepartureBoardData.SignData 12 | import com.sixbynine.transit.path.model.DepartureBoardData.TrainData 13 | 14 | val Line.colors: List 15 | get() = when (this) { 16 | NewarkWtc -> Colors.NwkWtc 17 | HobokenWtc -> Colors.HobWtc 18 | JournalSquare33rd -> Colors.Jsq33s 19 | Hoboken33rd -> Colors.Hob33s 20 | } 21 | 22 | private fun Line.matches( 23 | colors: Collection, 24 | target: String, 25 | stationId: String 26 | ): Boolean { 27 | return this in LineComputer.computeLines(station = stationId, target = target, colors = colors) 28 | } 29 | 30 | fun Line.matches(data: TrainData, stationId: String): Boolean { 31 | return matches(data.colors, data.title, stationId) 32 | } 33 | 34 | fun Collection.anyMatch(data: TrainData, stationId: String): Boolean { 35 | if (containsAll(Line.permanentLines)) return true 36 | return any { it.matches(data, stationId) } 37 | } 38 | 39 | fun Collection.anyMatch(data: SignData, stationId: String): Boolean { 40 | if (containsAll(Line.permanentLines)) return true 41 | return any { it.matches(data.colors, data.title, stationId) } 42 | } 43 | 44 | fun Line.matches(data: AppUiTrainData, stationId: String): Boolean { 45 | return matches(data.colors, data.title, stationId) 46 | } 47 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/com/sixbynine/transit/path/app/ui/icon/NativeIcon.kt: -------------------------------------------------------------------------------- 1 | package com.sixbynine.transit.path.app.ui.icon 2 | 3 | import PlatformType.ANDROID 4 | import PlatformType.DESKTOP 5 | import PlatformType.IOS 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.IconButton 9 | import androidx.compose.material3.LocalContentColor 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.graphics.isSpecified 14 | import androidx.compose.ui.graphics.painter.Painter 15 | import androidx.compose.ui.unit.Dp 16 | import androidx.compose.ui.unit.dp 17 | import getPlatform 18 | 19 | enum class IconType { 20 | Edit, Station, Filter, Sort, LayoutOneColumn, ArrowUp, ArrowDown, Settings, Delete, Back, 21 | ExpandDown, Internet 22 | } 23 | 24 | @Composable 25 | expect fun IconPainter(icon: IconType): Painter 26 | 27 | @Composable 28 | fun NativeIconButton( 29 | icon: IconType, 30 | contentDescription: String?, 31 | onClick: () -> Unit, 32 | modifier: Modifier = Modifier, 33 | tint: Color = Color.Unspecified, 34 | enabled: Boolean = true, 35 | buttonSize: Dp = 48.dp, 36 | iconSize: Dp = 24.dp, 37 | ) { 38 | val actualIconSize = when (getPlatform().type) { 39 | ANDROID, DESKTOP -> iconSize 40 | IOS -> iconSize - 4.dp 41 | } 42 | val painter = IconPainter(icon) 43 | return IconButton( 44 | onClick = onClick, 45 | modifier = modifier.size(buttonSize), 46 | enabled = enabled, 47 | ) { 48 | Icon( 49 | painter = painter, 50 | contentDescription = contentDescription, 51 | modifier = Modifier.size(actualIconSize), 52 | tint = if (tint.isSpecified) tint else LocalContentColor.current, 53 | ) 54 | } 55 | } 56 | --------------------------------------------------------------------------------