From ae93b490db5684ac655e7e948b555a1633a58f93 Mon Sep 17 00:00:00 2001 From: Pantelis Giazitsis Date: Fri, 19 Jan 2024 15:16:39 +0200 Subject: [PATCH] Initial commit --- .github/ISSUE_TEMPLATE/ask_a_question.md | 15 + .github/ISSUE_TEMPLATE/bug_report.md | 40 + .github/ISSUE_TEMPLATE/feature_request.md | 23 + .github/pull_request_template.md | 10 + .gitignore | 32 + .swift-version | 1 + .swiftlint.yml | 38 + BuildTools/Empty.swift | 1 + BuildTools/Package.swift | 11 + CODE_OF_CONDUCT.md | 69 + CONTRIBUTING.md | 202 + .../Debug/ConfigDebug.xcconfig-template | 19 + .../Mock/ConfigMock.xcconfig-template | 19 + .../Production/Config.xcconfig-template | 19 + LICENSE | 201 + .../Constants/AnimationsEnums.swift | 79 + PresentationLayer/Constants/AssetEnum.swift | 110 + PresentationLayer/Constants/ColorEnum.swift | 51 + .../Constants/CoordinatesEnum.swift | 19 + PresentationLayer/Constants/Dimensions.swift | 93 + .../Constants/FailAPICodeEnum.swift | 33 + .../Constants/FontSizeEnum.swift | 52 + PresentationLayer/Constants/FontsEnum.swift | 47 + PresentationLayer/Constants/ShadowEnum.swift | 50 + .../Constants/Strings/DisplayedLinks.swift | 93 + .../Constants/Strings/StringConstants.swift | 13 + .../Constants/Strings/SuccessFailEnum.swift | 107 + .../Constants/Strings/TextFieldError.swift | 27 + PresentationLayer/Constants/ThemeEnum.swift | 81 + .../Constants/WeatherFields.swift | 317 + PresentationLayer/ContentView.swift | 25 + .../Extensions/Common/Color+.swift | 15 + .../Extensions/Common/Functions+Units.swift | 78 + .../Extensions/Common/Image+.swift | 21 + .../Extensions/Common/String+Common.swift | 41 + PresentationLayer/Extensions/Date+.swift | 110 + .../Domain Extensions/BluetoothState+.swift | 23 + .../Domain Extensions/Common/Common.swift | 18 + .../Common/CurrentWeather+.swift | 45 + .../Common/DeviceDetails+Common.swift | 40 + .../Common/NetworkErrorResponse+.swift | 108 + .../Common/UserDeviceFollowState+.swift | 41 + .../Domain Extensions/DeviceDetails+.swift | 81 + .../DeviceRewardsOverview+.swift | 272 + .../ExplorerLocationError+.swift | 19 + .../Domain Extensions/Filters+.swift | 98 + .../Domain Extensions/Firmware+.swift | 58 + .../Domain Extensions/Network+.swift | 21 + ...kDeviceIDTokensTransactionsResponse+.swift | 100 + .../NetworkDevicesInfoResponse+.swift | 20 + .../NetworkSearchResponse+.swift | 48 + .../Domain Extensions/UnitsProtocol+.swift | 127 + .../Extensions/Numeric/CGFloat+.swift | 19 + .../Extensions/Numeric/Double+.swift | 63 + .../Extensions/Numeric/Int+.swift | 43 + .../Extensions/Numeric/Numeric+.swift | 16 + PresentationLayer/Extensions/Shape+.swift | 28 + PresentationLayer/Extensions/String+.swift | 183 + PresentationLayer/Extensions/Text+.swift | 15 + PresentationLayer/Extensions/UIColor+.swift | 15 + PresentationLayer/Extensions/UIImage+.swift | 42 + PresentationLayer/Extensions/View+.swift | 19 + .../Navigation/DeepLinkHandler.swift | 189 + PresentationLayer/Navigation/Router.swift | 234 + PresentationLayer/Navigation/RouterView.swift | 68 + .../Protocols/SwinjectInterface.swift | 13 + .../AccountConfirmationView.swift | 60 + .../AccountConfirmationViewModel.swift | 69 + .../Base Components/AddButton.swift | 50 + .../Base Components/AttributedLabel.swift | 53 + .../Base Components/AttributedTextView.swift | 66 + .../UI Components/Base Components/Badge.swift | 45 + .../Base Text Field/BaseTextField.swift | 182 + .../Base Text Field/BaseTextFieldEnum.swift | 91 + .../Base Components/CTAView.swift | 61 + .../Base Components/CardWarningView.swift | 120 + .../Base Components/CircleRadius.swift | 29 + .../Custom Sheet/BottomSheetModifier.swift | 121 + .../Custom Sheet/CustomSheet.swift | 249 + .../Custom Sheet/OverlayAnimator.swift | 63 + .../Base Components/CustomPicker.swift | 188 + .../Base Components/CustomSegmentView.swift | 213 + .../DeviceUpdatesLoadingView.swift | 103 + .../FailSuccessStateObject.swift | 45 + .../FailSuccessViewModifier.swift | 78 + .../Fail Success /FailView.swift | 116 + .../Fail Success /SuccessView.swift | 82 + .../Indication/Indication.swift | 48 + .../Base Components/InfoView.swift | 38 + .../LazyLoadingPagerView.swift | 187 + .../Base Components/Lottie/LottieView.swift | 38 + .../PercentageGridLayoutView.swift | 43 + .../Base Components/ProgressBarStyle.swift | 61 + .../Base Components/QrScannerView.swift | 52 + .../Scrolling Picker/ScrollingPagerView.swift | 229 + .../ShimmerEffectLoader.swift | 66 + .../ShimmerEffectLoaderView.swift | 46 + .../Spinning Loader/SpinningLoader.swift | 44 + .../Spinning Loader/SpinningLoaderView.swift | 25 + .../Base Components/StaticButtonStyle.swift | 14 + .../StationLastActiveView.swift | 58 + .../Base Components/StepsNavView.swift | 250 + .../Base Components/StepsView.swift | 76 + .../Base Components/TabViewWrapper.swift | 20 + .../Base Components/Toast/ToastView.swift | 114 + .../TabBarVisibilityHandler.swift | 56 + .../TrackableScrollView.swift | 159 + .../Base Components/UberTextField.swift | 139 + .../WXMAlert/WXMAlertModifier.swift | 145 + .../WXMAlert/WXMAlertView.swift | 172 + .../Base Components/WXMDivider.swift | 21 + .../Base Components/WXMEmptyView.swift | 137 + .../WeatherOverviewView+Content.swift | 324 + .../WeatherOverviewView.swift | 112 + .../WeatherStationCard+Content.swift | 125 + .../WeatherStationCard.swift | 54 + .../WeatherStationCardView.swift | 55 + .../WebView/WebContainerView.swift | 137 + .../MapBox/MapBoxClaimDevice.swift | 215 + .../UI Components/MapBox/MapBoxMapView.swift | 256 + .../Modifiers/ChartModifier.swift | 34 + .../Modifiers/ConditionalModifier.swift | 42 + .../Modifiers/CornerRadius.swift | 26 + .../Modifiers/CustomColorButtonStyle.swift | 21 + .../Modifiers/GeneralButtonStyle.swift | 15 + .../OnAnimationCompletedModifier.swift | 49 + .../Modifiers/SizeObserver.swift | 59 + .../Modifiers/StrokeBorderModifier.swift | 29 + .../Modifiers/TabBarModifier.swift | 45 + .../Modifiers/TextfieldClearButton.swift | 68 + .../Modifiers/WXMButtonStyle.swift | 104 + .../Modifiers/WXMCardStyle.swift | 44 + .../UI Components/Modifiers/WXMPopover.swift | 114 + .../UI Components/Modifiers/WXMShadow.swift | 24 + .../Modifiers/WXMToggleStyle.swift | 66 + .../Navigation/CustomNavigationLinkView.swift | 27 + .../Navigation/NavigationContainerView.swift | 129 + .../Screens/Analytics/AnalyticsView.swift | 189 + .../Analytics/AnalyticsViewModel.swift | 26 + .../Screens/App Update/AppUpdateView.swift | 91 + .../App Update/AppUpdateViewModel.swift | 50 + .../ChangeFrequencyView.swift | 115 + .../ChangeFrequencyViewModel.swift | 225 + .../SelectFrequencyView.swift | 166 + .../ClaimDevice/ClaimDeviceViewModel.swift | 588 ++ .../Helium/ClaimDeviceNavView.swift | 116 + .../Bluetooth/BluetoothMessageView.swift | 149 + .../Steps/Bluetooth/BluetoothScanView.swift | 258 + .../Helium/Steps/ClaimDeviceBluetooth.swift | 82 + .../Helium/Steps/ClaimDeviceConnection.swift | 154 + .../Helium/Steps/ClaimDeviceFrequency.swift | 97 + .../Helium/Steps/ClaimDeviceLocation.swift | 152 + .../Helium/Steps/ClaimDeviceReset.swift | 97 + .../Helium/Steps/ClaimDeviceVerify.swift | 143 + .../Location/ClaimDeviceLocationMapView.swift | 173 + .../HeliumClaimingStatusView+Content.swift | 66 + .../Util/HeliumClaimingStatusView.swift | 482 ++ .../UITextField+StationSerialNumber.swift | 226 + .../ClaimDevice/SelectDeviceTypeView.swift | 117 + .../ClaimDevice/SuccessFailureView.swift | 118 + .../Screens/ConnectWallet/MyWalletView.swift | 262 + .../ConnectWallet/MyWalletViewModel.swift | 186 + .../ConnectWallet/TextFieldsWallet.swift | 63 + .../Daily Rewards /RewardDetailsView.swift | 163 + .../RewardDetailsViewModel.swift | 211 + .../Device Info/DeviceInfoRowView.swift | 152 + .../Screens/Device Info/DeviceInfoView.swift | 102 + .../DeviceInfoViewModel+Content.swift | 359 + .../Device Info/DeviceInfoViewModel.swift | 381 + .../Screens/Device Info/StationInfoView.swift | 134 + .../ExplorerStationsListView.swift | 68 + .../ExplorerStationsListViewModel.swift | 245 + .../Screens/Explorer/ExplorerView.swift | 130 + .../Screens/Explorer/ExplorerViewModel.swift | 151 + .../Search/ExplorerSearchViewModel.swift | 232 + .../Explorer/Search/SearchView+Content.swift | 209 + .../Screens/Explorer/Search/SearchView.swift | 51 + .../Screens/ExplorerSignIn/RegisterView.swift | 88 + .../ExplorerSignIn/RegisterViewModel.swift | 104 + .../ExplorerSignIn/ResetPasswordView.swift | 80 + .../ResetPasswordViewModel.swift | 77 + .../Screens/ExplorerSignIn/SignInView.swift | 94 + .../ExplorerSignIn/SignInViewModel.swift | 74 + .../HistoryContainerView.swift | 141 + .../HistoryContainerViewModel.swift | 53 + .../History View/ChartCardTypes.swift | 108 + .../History View/ChartCardView.swift | 135 + .../History View/ChartsContainer.swift | 37 + .../History View/HistoryView.swift | 49 + .../View Model/ChartsFactory.swift | 137 + .../View Model/HistoryChartModels.swift | 24 + .../View Model/HistoryViewModel.swift | 120 + .../LoggedInTabViewContainer.swift | 185 + .../LoggedInViews/TabBar/TabBarView.swift | 49 + .../LoggedInViews/TabBar/TabItemView.swift | 31 + .../TabBar/TabSelectionEnum.swift | 40 + .../Screens/Main Screen/MainScreen.swift | 65 + .../Main Screen/MainScreenViewModel.swift | 271 + .../Multiple Alerts/AlertsViewModel.swift | 82 + .../Multiple Alerts/MultipleAlertsView.swift | 65 + .../NetworkStats+Content.swift | 454 ++ .../Network Statistics/NetworkStatsView.swift | 79 + .../NetworkStatsViewModel+Factory.swift | 219 + .../NetworkStatsViewModel.swift | 186 + .../StationDetailsGridView.swift | 89 + .../Network Statistics/StatisticsChart.swift | 72 + .../Screens/Profile/ProfileTypes.swift | 36 + .../Screens/Profile/ProfileView.swift | 270 + .../Screens/Profile/ProfileViewModel.swift | 190 + .../Reboot Station/RebootStationView.swift | 74 + .../RebootStationViewModel.swift | 165 + .../SelectStationLocationView.swift | 205 + .../SelectStationLocationViewModel.swift | 142 + .../Components/DeleteAccountModalView.swift | 83 + .../Components/FailedDeleteView.swift | 122 + .../Components/SettingsButtonView.swift | 75 + .../Components/SettingsSectionTitle.swift | 18 + .../Components/SuccessfulDeleteView.swift | 99 + .../Settings/Components/UnitsOptionView.swift | 33 + .../Components/UnitsOptionsModalView.swift | 65 + .../Screens/Settings/DeleteAccountView.swift | 132 + .../Settings/DeleteAccountViewModel.swift | 128 + .../Screens/Settings/SettingsEnum.swift | 111 + .../Screens/Settings/SettingsView.swift | 262 + .../Screens/Settings/SettingsViewModel.swift | 173 + .../UpdateFirmwareView+Content.swift | 21 + .../Update Firmware/UpdateFirmwareView.swift | 64 + .../View Model/FirmwareUpdateUtils.swift | 154 + .../View Model/UpdateFirmwareViewModel.swift | 295 + .../RewardDatePoint.swift | 37 + .../RewardsCard/BaseRewardsCard.swift | 101 + .../RewardsCard/ProgressBar.swift | 42 + .../RewardsCard/RewardsScoreItem.swift | 51 + .../TransactionDetailsView.swift | 106 + .../TransactionDetailsViewModel.swift | 187 + .../TransactionsPagination.swift | 42 + .../Weather Charts/CustomYAxisFormatter.swift | 38 + .../NetStatsChartViewModel.swift | 21 + .../WeatherChartDataModel.swift | 31 + .../Weather Charts/WeatherLineChart.swift | 291 + .../Weather Charts/XAxisValueFormatter.swift | 19 + .../Weather Charts/YAxisValueFormatter.swift | 53 + .../Home/Filters/FilterView.swift | 123 + .../Home/Filters/FilterViewModel.swift | 94 + .../Home/WeatherStationsHomeView.swift | 171 + .../Home/WeatherStationsHomeViewModel.swift | 298 + .../Forecast/CustomRangeSlider.swift | 62 + .../StationForeCastCardView+Content.swift | 154 + .../Forecast/StationForecastCardView.swift | 77 + .../Forecast/StationForecastView.swift | 95 + .../Forecast/StationForecastViewModel.swift | 164 + .../Observations/ObservationsView.swift | 70 + .../Observations/ObservationsViewModel.swift | 117 + .../Rewards/StationLostRewardsView.swift | 56 + .../Rewards/StationRewardTypes.swift | 156 + .../Rewards/StationRewardsCardView.swift | 76 + .../Rewards/StationRewardsErrorView.swift | 54 + .../Rewards/StationRewardsOverviewView.swift | 192 + .../Rewards/StationRewardsTimelineView.swift | 37 + .../Rewards/StationRewardsView.swift | 65 + .../Rewards/StationRewardsViewModel.swift | 207 + .../StationDetailsContainerView.swift | 204 + .../Station Details/StationDetailsTypes.swift | 119 + .../StationDetailsViewModel.swift | 307 + .../Screens/WebView/WebView.swift | 89 + .../UI Components/ViewModelsFactory.swift | 164 + PresentationLayer/Utils/DateRange.swift | 79 + PresentationLayer/Utils/HelperFunctions.swift | 123 + PresentationLayer/Utils/LoaderView.swift | 81 + .../Utils/MapBox/MapBoxConstants.swift | 17 + .../MapBox/MapBoxSnapshotUrlGenerator.swift | 59 + PresentationLayer/Utils/SwiftUIUtils.swift | 12 + PresentationLayer/Utils/Toast.swift | 63 + .../Alert Helper/AlertHelper+Follow.swift | 34 + .../Alert Helper/AlertHelper.swift | 73 + .../Utils/UIKit Utils/UIKitUtils.swift | 73 + .../Utils/Weather/UnitConstants.swift | 28 + .../Utils/Weather/WeatherFormatter.swift | 154 + .../Utils/Weather/WeatherUnitsConverter.swift | 110 + .../Utils/Weather/WeatherUnitsManager.swift | 126 + .../ViewModels/HistoryViewModel.swift | 63 + README.md | 39 + Scripts/sort-Xcode-project-file | 196 + Scripts/sort_all_projects.sh | 16 + ci_scripts/ci_post_clone.sh | 20 + station-intent/Info.plist | 32 + station-intent/IntentHandler.swift | 48 + station-intent/station-intent.entitlements | 14 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + station-widget/Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + .../clear-day.imageset/Contents.json | 15 + .../clear-day.imageset/clear-day.svg | 16 + .../clear-night.imageset/Contents.json | 15 + .../clear-night.imageset/clear-night.svg | 15 + .../cloudy.imageset/Contents.json | 15 + .../cloudy.imageset/cloudy.svg | 10 + .../drizzle.imageset/Contents.json | 15 + .../drizzle.imageset/drizzle.svg | 18 + .../fog.imageset/Contents.json | 15 + .../Assets.xcassets/fog.imageset/fog.svg | 27 + .../overcast-day.imageset/Contents.json | 15 + .../overcast-day.imageset/overcast-day.svg | 43 + .../overcast-night.imageset/Contents.json | 15 + .../overcast-night.svg | 42 + .../partly-cloudy-day.imageset/Contents.json | 15 + .../partly-cloudy-day.svg | 27 + .../Contents.json | 15 + .../partly-cloudy-night.svg | 26 + .../rain.imageset/Contents.json | 15 + .../Assets.xcassets/rain.imageset/rain.svg | 18 + .../sleet.imageset/Contents.json | 15 + .../Assets.xcassets/sleet.imageset/sleet.svg | 41 + .../snow.imageset/Contents.json | 15 + .../Assets.xcassets/snow.imageset/snow.svg | 33 + .../thunderstorms-rain.imageset/Contents.json | 15 + .../thunderstorms-rain.svg | 29 + .../wind.imageset/Contents.json | 15 + .../Assets.xcassets/wind.imageset/wind.svg | 22 + .../Extenstions/DeviceDetails+Widget.swift | 29 + station-widget/Extenstions/View+.swift | 34 + station-widget/Info.plist | 26 + station-widget/StationTimelineEntry.swift | 89 + ...tationWidgetConfiguration.intentdefinition | 165 + station-widget/Views/ErrorView.swift | 44 + station-widget/Views/LoggedOutView.swift | 92 + station-widget/Views/StationWidgetView.swift | 296 + station-widget/WidgetConstants.swift | 18 + station-widget/station_widget.swift | 146 + station-widget/station_widgetBundle.swift | 16 + station-widgetExtension.entitlements | 14 + wxm-ios.xcodeproj/project.pbxproj | 3628 +++++++++ .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../station-widgetExtension.xcscheme | 113 + .../xcschemes/wxm-ios-mock.xcscheme | 78 + .../xcschemes/wxm-ios-release.xcscheme | 77 + .../xcshareddata/xcschemes/wxm-ios.xcscheme | 92 + wxm-ios/AppCore/AppDelegate.swift | 18 + wxm-ios/AppCore/MainApp.swift | 43 + .../favoriteHeart.colorset/Contents.json | 20 + .../DataLayer.xcodeproj/project.pbxproj | 950 +++ .../xcshareddata/xcschemes/DataLayer.xcscheme | 67 + wxm-ios/DataLayer/DataLayer/DataLayer.h | 18 + .../DBExplorerAddress+CoreDataClass.swift | 15 + ...DBExplorerAddress+CoreDataProperties.swift | 41 + .../DBExplorerDevice+CoreDataClass.swift | 15 + .../DBExplorerDevice+CoreDataProperties.swift | 47 + ...DBExplorerSearchEntity+CoreDataClass.swift | 15 + ...lorerSearchEntity+CoreDataProperties.swift | 19 + .../DataLayer/Database/DBProtocols.swift | 20 + .../Database/DBWeather+CoreDataClass.swift | 15 + .../DBWeather+CoreDataProperties.swift | 92 + .../DataLayer/Database/DatabaseService.swift | 129 + .../Model.xcdatamodeld/.xccurrentversion | 8 + .../Model.xcdatamodel/contents | 53 + .../Entities/EmailPasswordCodable.swift | 18 + .../DataLayer/Networking/ApiClient.swift | 227 + .../AuthApiRequestBuilder.swift | 97 + .../CellRequestBuilder.swift | 75 + .../DevicesApiRequestBuilder.swift | 108 + .../MeApiRequestBuilder.swift | 204 + .../NetworkApiRequestBuilder.swift | 101 + .../DataLayer/Networking/Connectivity.swift | 15 + .../Constants/NetworkConstants.swift | 33 + .../Constants/ParameterConstants.swift | 37 + .../Interceptors/AuthInterceptor.swift | 84 + .../Interceptors/RequestHeadersAdapter.swift | 47 + .../Mock/Jsons/get_device_history.json | 212 + .../Jsons/get_device_history_24_samples.json | 417 ++ .../Mock/Jsons/get_device_info_helium.json | 20 + .../Mock/Jsons/get_device_info_m5.json | 20 + .../Mock/Jsons/get_network_search.json | 24 + .../Mock/Jsons/get_network_stats.json | 323 + .../Mock/Jsons/get_network_stats_alt.json | 319 + .../Mock/Jsons/get_transactions.json | 289 + .../Mock/Jsons/get_transactions_empty.json | 5 + .../Mock/Jsons/get_user_device.json | 106 + .../Mock/Jsons/get_user_device_forecast.json | 2157 ++++++ .../Jsons/get_user_device_just_claimed.json | 97 + .../Mock/Jsons/get_user_device_rewards.json | 164 + .../Mock/Jsons/get_user_devices.json | 108 + .../Mock/Jsons/get_user_rewards.json | 11 + .../Mock/Jsons/get_user_wallet.json | 4 + .../Networking/Mock/MockProtocol.swift | 51 + .../AuthRepositoryImpl.swift | 59 + .../Bluetooth/BTActionsWrapper.swift | 226 + .../Bluetooth/BTPerformCommandDelegate.swift | 166 + .../Bluetooth/BluetoothManager.swift | 249 + .../Bluetooth/BluetoothUtils.swift | 84 + .../HeliumDeviceBluetoothHelper.swift | 150 + .../BluetoothDevicesRepositoryImpl.swift | 231 + .../DeviceInfoRepositoryImpl.swift | 116 + .../DevicesRepositoryImpl.swift | 89 + .../ExplorerRepositoryImpl.swift | 48 + .../FiltersRepositoryImpl.swift | 34 + .../Firmware Update/FirmwareUpdater.swift | 116 + .../FirmwareUpdateImpl.swift | 148 + .../KeychainRepositoryImpl.swift | 48 + .../LocationRepositoryImpl.swift | 237 + .../MeRepositoryImpl.swift | 202 + .../NetworkRepositoryImpl.swift | 82 + .../SettingsRepositoryImpl.swift | 52 + .../UserDefaultsRepositoryImp.swift | 42 + .../UserDefaultsService.swift | 95 + .../DataLayer/countries_information.json | 1958 +++++ wxm-ios/DataLayer/FiltersService.swift | 68 + .../KeychainConstants.swift | 34 + .../KeychainHelperService+User.swift | 93 + .../KeychainHelperService.swift | 96 + wxm-ios/DataLayer/UserDevicesService.swift | 220 + wxm-ios/DataLayer/UserInfoService.swift | 48 + .../DomainLayer.xcodeproj/project.pbxproj | 961 +++ wxm-ios/DomainLayer/DomainLayer/DomainLayer.h | 18 + .../AuthRepository.swift | 23 + .../BluetoothDevicesRepository.swift | 109 + .../DeviceInfoRepository.swift | 47 + .../DeviceLocationRepository.swift | 21 + .../DevicesRepository.swift | 17 + .../ExplorerRepository.swift | 34 + .../FiltersRepository.swift | 16 + .../FirmwareUpdateRepository.swift | 34 + .../KeychainRepository.swift | 18 + .../MeRepository.swift | 46 + .../NetworkRepository.swift | 24 + .../SettingsRepository.swift | 26 + .../UserDefaultsRepository.swift | 15 + .../Codables/Auth/NetworkTokenResponse.swift | 24 + .../Codables/Cells/PublicDevice.swift | 28 + .../Entities/Codables/Cells/PublicHex.swift | 25 + .../Entities/Codables/Connectivity.swift | 14 + .../Devices/NetworkDevicesInfoResponse.swift | 100 + .../Devices/NetworkDevicesResponse.swift | 215 + .../Devices/NetworkDevicesTypes.swift | 34 + .../Entities/Codables/EmptyEntity.swift | 15 + .../Codables/Error/NetworkErrorResponse.swift | 55 + .../Codables/LocationCoordinates.swift | 18 + .../Codables/Me/Body/ClaimDeviceBody.swift | 37 + .../NetworkDeviceForecastResponse.swift | 24 + .../NetworkDeviceHistoryResponse.swift | 23 + .../NetworkDeviceIDTransactionsResponse.swift | 62 + .../Me/Network/NetworkDeviceRewards.swift | 118 + .../Network/NetworkDeviceTokensResponse.swift | 93 + .../Me/Network/NetworkFirmwareResponse.swift | 36 + .../Me/Network/NetworkUserInfoResponse.swift | 26 + .../Network/NetworkUserRewardsResponse.swift | 24 + .../Network/NetworkSearchResponse.swift | 59 + .../Network/NetworkStatsResponse.swift | 59 + .../DomainErrors/PublicHexError.swift | 14 + .../Entities/DomainModels/CountryInfo.swift | 40 + .../DomainModels/DeviceAnnotation.swift | 72 + .../Entities/DomainModels/DeviceDetails.swift | 87 + .../DomainModels/DeviceLocation.swift | 46 + .../DomainModels/Explorer/ExplorerData.swift | 27 + .../Entities/DomainModels/Filters.swift | 79 + .../Entities/DomainModels/Frequency.swift | 52 + .../Entities/DomainModels/HeliumDevice.swift | 16 + .../Entities/DomainModels/UITransaction.swift | 106 + .../Entities/DomainModels/WeatherField.swift | 25 + .../DomainLayer/Extensions/Collection+.swift | 15 + .../DomainLayer/Extensions/Double+.swift | 13 + .../Extensions/UserDefaults+Constants.swift | 56 + .../InfoKeychainConstants.swift | 16 + .../KeychainHelperService.swift | 103 + .../DomainLayer/UseCases/AuthUseCase.swift | 47 + .../UseCases/DeviceDetailsUseCase.swift | 111 + .../UseCases/DeviceInfoUseCase.swift | 42 + .../UseCases/DeviceLocationUseCase.swift | 45 + .../DomainLayer/UseCases/DevicesUseCase.swift | 80 + .../UseCases/ExplorerUseCase.swift | 187 + .../DomainLayer/UseCases/FiltersUseCase.swift | 28 + .../DomainLayer/UseCases/HistoryUseCase.swift | 26 + .../UseCases/KeychainUseCase.swift | 40 + .../DomainLayer/UseCases/MainUseCase.swift | 82 + .../DomainLayer/UseCases/MeUseCase.swift | 122 + .../DomainLayer/UseCases/NetworkUseCase.swift | 43 + .../DomainLayer/UseCases/RewardsUseCase.swift | 115 + .../UseCases/SettingsUseCase.swift | 77 + .../DomainLayer/UseCases/TokenUseCase.swift | 44 + .../UseCases/UpdateFirmwareUseCase.swift | 42 + .../DomainLayer/UseCases/WidgetUseCase.swift | 40 + .../DomainLayer/Utils/Geocoder.swift | 49 + .../Utils/NetworkResultsConverter.swift | 50 + .../DomainLayer/Utils/UnitsEnum.swift | 126 + .../Utils/WeatherUnitFormatter.swift | 172 + wxm-ios/Info.plist | 78 + wxm-ios/PresentationLayer/PresentationLayer.h | 18 + .../project.pbxproj | 733 ++ .../Preview Assets.xcassets/Contents.json | 6 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/1025.png | Bin 0 -> 26751 bytes .../AppIcon.appiconset/Contents.json | 14 + .../App Assets/Assets.xcassets/Contents.json | 6 + .../DeleteFailureIcon.imageset/Contents.json | 21 + .../DeleteFailureIcon.pdf | Bin 0 -> 3892 bytes .../DeleteSuccessIcon.imageset/Contents.json | 12 + .../DeleteSuccessIcon.pdf | Bin 0 -> 3618 bytes .../Contents.json | 21 + .../account_balance_wallet.pdf | Bin 0 -> 2061 bytes .../Contents.json | 12 + .../account_filled_icon.pdf | Bin 0 -> 2748 bytes .../analytics_icon.imageset/Contents.json | 12 + .../analytics_icon.pdf | Bin 0 -> 5061 bytes .../backArrow.imageset/Contents.json | 12 + .../backArrow.imageset/backArrow.pdf | Bin 0 -> 971 bytes .../badge.imageset/Contents.json | 21 + .../Assets.xcassets/badge.imageset/badge.pdf | Bin 0 -> 1088 bytes .../check.imageset/Contents.json | 21 + .../Assets.xcassets/check.imageset/check.pdf | Bin 0 -> 910 bytes .../checkBoxOutlined.imageset/Contents.json | 21 + .../checkBoxOutlined.pdf | Bin 0 -> 1216 bytes .../chevronDown.imageset/Contents.json | 21 + .../chevronDown.imageset/chevronDown.pdf | Bin 0 -> 2553 bytes .../circleCheckmark.imageset/Contents.json | 12 + .../circleCheckmark.pdf | Bin 0 -> 2735 bytes .../Assets.xcassets/claimDevice/Contents.json | 6 + .../bluetoothGray.imageset/Contents.json | 21 + .../bluetoothGray.imageset/bluetoothGray.pdf | Bin 0 -> 3242 bytes .../Contents.json | 15 + .../claimBluetoothButton.pdf | Bin 0 -> 3238 bytes .../claimHelium.imageset/Contents.json | 12 + .../claimHelium.imageset/claimHelium.pdf | Bin 0 -> 3755 bytes .../claimWiFi.imageset/Contents.json | 21 + .../claimWiFi.imageset/claimWiFi.pdf | Bin 0 -> 3438 bytes .../infoIcon.imageset/Contents.json | 12 + .../infoIcon.imageset/infoIcon.pdf | Bin 0 -> 3176 bytes .../Contents.json | 21 + .../stationResetSchematic.pdf | Bin 0 -> 63962 bytes .../claimDeviceFailure.imageset/Contents.json | 21 + .../claimDeviceFailure.pdf | Bin 0 -> 15429 bytes .../clear_icon.imageset/Contents.json | 12 + .../clear_icon.imageset/clear_icon.pdf | Bin 0 -> 1569 bytes .../closeIcon.imageset/Contents.json | 12 + .../closeIcon.imageset/closeIcon.pdf | Bin 0 -> 1036 bytes .../close_button.imageset/Contents.json | 23 + .../close_button.imageset/icon.png | Bin 0 -> 259 bytes .../close_button.imageset/icon@2x.png | Bin 0 -> 432 bytes .../close_button.imageset/icon@3x.png | Bin 0 -> 547 bytes .../detectLocation.imageset/Contents.json | 12 + .../detectLocation.pdf | Bin 0 -> 3490 bytes .../downArrow.imageset/Contents.json | 12 + .../downArrow.imageset/downArrow.pdf | Bin 0 -> 2554 bytes .../edit_icon.imageset/Contents.json | 12 + .../edit_icon.imageset/editIcon.pdf | Bin 0 -> 3441 bytes .../email.imageset/Contents.json | 12 + .../email.imageset/mail_outline.pdf | Bin 0 -> 1324 bytes .../error_icon.imageset/Contents.json | 12 + .../error_icon.imageset/error_icon.pdf | Bin 0 -> 3240 bytes .../eye.imageset/Contents.json | 12 + .../eye.imageset/visibility.pdf | Bin 0 -> 1802 bytes .../globe.imageset/Contents.json | 12 + .../Assets.xcassets/globe.imageset/globe.pdf | Bin 0 -> 2542 bytes .../info.imageset/Contents.json | 21 + .../info.imageset/infoIcon.pdf | Bin 0 -> 1583 bytes .../keyboardArrowLeft.imageset/Contents.json | 21 + .../keyboard_arrow_left.pdf | Bin 0 -> 908 bytes .../keyboardArrowRight.imageset/Contents.json | 21 + .../keyboardArrowRight.pdf | Bin 0 -> 908 bytes .../locationMap.imageset/Contents.json | 21 + .../locationMap.imageset/locationMap.pdf | Bin 0 -> 1315119 bytes .../lock.imageset/Contents.json | 12 + .../Assets.xcassets/lock.imageset/lock.pdf | Bin 0 -> 1858 bytes .../m5Video.imageset/Contents.json | 21 + .../m5Video.imageset/m5Video.pdf | Bin 0 -> 565584 bytes .../mapStaticImage.imageset/Contents.json | 12 + .../mapStaticImage.pdf | Bin 0 -> 645548 bytes .../markerDefault.imageset/Contents.json | 15 + .../place_black_24dp_1.pdf | Bin 0 -> 1282 bytes .../network_stats_icon.imageset/Contents.json | 12 + .../network_stats_icon.pdf | Bin 0 -> 3091 bytes .../offline_icon.imageset/Contents.json | 12 + .../offline_icon.imageset/offline_icon.pdf | Bin 0 -> 3242 bytes .../open_external_icon.imageset/Contents.json | 12 + .../open_external_icon.pdf | Bin 0 -> 3383 bytes .../panoramaFishEye.imageset/Contents.json | 21 + .../panoramaFishEye.pdf | Bin 0 -> 1263 bytes .../Contents.json | 21 + .../panoramaFishEyeFilled.pdf | Bin 0 -> 1608 bytes .../partlyCloudyDay.imageset/Contents.json | 21 + .../partlyCloudyDay2.pdf | Bin 0 -> 3862 bytes .../pin.imageset/Contents.json | 12 + .../Assets.xcassets/pin.imageset/pin.pdf | Bin 0 -> 2643 bytes .../plain_info_icon.imageset/Contents.json | 12 + .../plain_info_icon.pdf | Bin 0 -> 3129 bytes .../plus.imageset/Contents.json | 21 + .../plus.imageset/add_24px.pdf | Bin 0 -> 1039 bytes .../qrCode.imageset/Contents.json | 12 + .../qrCode.imageset/qrcode-solid.pdf | Bin 0 -> 2652 bytes .../qrCodeBlue.imageset/Contents.json | 21 + .../qrCodeBlue.imageset/qrCodeBlue.pdf | Bin 0 -> 3907 bytes .../Contents.json | 12 + .../radio_button_active_icon.pdf | Bin 0 -> 1647 bytes .../radio_button_icon.imageset/Contents.json | 12 + .../radio_button_icon.pdf | Bin 0 -> 1288 bytes .../redAlert.imageset/Contents.json | 21 + .../redAlert.imageset/redAlert.pdf | Bin 0 -> 1583 bytes .../rewardWidget/Contents.json | 6 + .../hexagon.imageset/Contents.json | 23 + .../hexagon.imageset/Vector (Stroke).png | Bin 0 -> 327 bytes .../hexagon.imageset/Vector (Stroke)@2x.png | Bin 0 -> 489 bytes .../hexagon.imageset/Vector (Stroke)@3x.png | Bin 0 -> 640 bytes .../hexagonBigger.imageset/Contents.json | 21 + .../hexagonBigger.imageset/hexagonBigger.pdf | Bin 0 -> 3386 bytes .../search.imageset/Contents.json | 21 + .../search.imageset/search.pdf | Bin 0 -> 2818 bytes .../settings.imageset/Contents.json | 12 + .../settings.imageset/moreOptions.pdf | Bin 0 -> 1522 bytes .../settingsGear.imageset/Contents.json | 12 + .../settingsGear.imageset/gear-solid 1.pdf | Bin 0 -> 3252 bytes .../share.imageset/Contents.json | 21 + .../Assets.xcassets/share.imageset/share.pdf | Bin 0 -> 1822 bytes .../share_icon.imageset/Contents.json | 12 + .../share_icon.imageset/share_icon.pdf | Bin 0 -> 3243 bytes .../toggleCheckIcon.imageset/Contents.json | 21 + .../toggleCheckIcon.pdf | Bin 0 -> 2479 bytes .../toggleCheckmark.imageset/Contents.json | 21 + .../toggleCheckmark.pdf | Bin 0 -> 2479 bytes .../toggleXIcon.imageset/Contents.json | 21 + .../toggleXIcon.imageset/toggleXIcon.pdf | Bin 0 -> 2848 bytes .../toggleXMark.imageset/Contents.json | 12 + .../toggleXMark.imageset/toggleXMark.pdf | Bin 0 -> 2852 bytes .../Contents.json | 12 + .../update_firmware_icon.pdf | Bin 0 -> 4087 bytes .../user.imageset/Contents.json | 12 + .../Assets.xcassets/user.imageset/Icon.pdf | Bin 0 -> 1686 bytes .../warning_icon.imageset/Contents.json | 21 + .../warning_icon.imageset/warning_icon.pdf | Bin 0 -> 2974 bytes .../weatherMetrics/Contents.json | 6 + .../humidity.imageset/Contents.json | 23 + .../humidity.imageset/Vector.png | Bin 0 -> 338 bytes .../humidity.imageset/Vector@2x.png | Bin 0 -> 607 bytes .../humidity.imageset/Vector@3x.png | Bin 0 -> 892 bytes .../precipitation.imageset/Contents.json | 23 + .../precipitation.imageset/Vector.png | Bin 0 -> 347 bytes .../precipitation.imageset/Vector@2x.png | Bin 0 -> 616 bytes .../precipitation.imageset/Vector@3x.png | Bin 0 -> 946 bytes .../pressure.imageset/Contents.json | 23 + .../pressure.imageset/Vector.png | Bin 0 -> 398 bytes .../pressure.imageset/Vector@2x.png | Bin 0 -> 763 bytes .../pressure.imageset/Vector@3x.png | Bin 0 -> 1181 bytes .../temperature.imageset/Contents.json | 23 + .../temperature.imageset/Vector.png | Bin 0 -> 333 bytes .../temperature.imageset/Vector@2x.png | Bin 0 -> 517 bytes .../temperature.imageset/Vector@3x.png | Bin 0 -> 806 bytes .../uvindex.imageset/Contents.json | 23 + .../uvindex.imageset/Vector.png | Bin 0 -> 410 bytes .../uvindex.imageset/Vector@2x.png | Bin 0 -> 816 bytes .../uvindex.imageset/Vector@3x.png | Bin 0 -> 1223 bytes .../wind.imageset/Contents.json | 23 + .../weatherMetrics/wind.imageset/Vector.png | Bin 0 -> 379 bytes .../wind.imageset/Vector@2x.png | Bin 0 -> 557 bytes .../wind.imageset/Vector@3x.png | Bin 0 -> 863 bytes .../weatherXMLogo.imageset/Contents.json | 12 + .../weatherXMLogo.imageset/White on dark.pdf | Bin 0 -> 6154 bytes .../Contents.json | 21 + .../WeatherXM_Discord_Logo_Animation-1 1.pdf | Bin 0 -> 49682 bytes .../weatherXMLogoSmall.imageset/Contents.json | 21 + .../exmLogoSmall.pdf | Bin 0 -> 4242 bytes .../wetherForecastWidget/Contents.json | 6 + .../chevron-right.imageset/Contents.json | 23 + .../chevron-right.imageset/Vector.png | Bin 0 -> 261 bytes .../chevron-right.imageset/Vector@2x.png | Bin 0 -> 330 bytes .../chevron-right.imageset/Vector@3x.png | Bin 0 -> 401 bytes .../partly-cloud.imageset/Contents.json | 23 + .../partly-cloudy-day 3.png | Bin 0 -> 1329 bytes .../partly-cloudy-day 3@2x.png | Bin 0 -> 3129 bytes .../partly-cloudy-day 3@3x.png | Bin 0 -> 5437 bytes .../umbrella.imageset/Contents.json | 23 + .../umbrella.imageset/Vector.png | Bin 0 -> 350 bytes .../umbrella.imageset/Vector@2x.png | Bin 0 -> 603 bytes .../umbrella.imageset/Vector@3x.png | Bin 0 -> 885 bytes .../wonderFace.imageset/Contents.json | 21 + .../wonderFace.imageset/wonderFace.pdf | Bin 0 -> 4530 bytes .../wxmDevice.imageset/Contents.json | 12 + .../wxmDevice.imageset/WXM-M5 1.pdf | Bin 0 -> 18992 bytes .../xm_search_logo.imageset/Contents.json | 22 + .../xm_search_logo 1.pdf | Bin 0 -> 4816 bytes .../xm_search_logo.pdf | Bin 0 -> 6000 bytes .../CommonAssets.xcassets/Contents.json | 6 + .../Contents.json | 12 + .../errorExclamationIcon.pdf | Bin 0 -> 2976 bytes .../helium.imageset/Contents.json | 12 + .../helium.imageset/helium.pdf | Bin 0 -> 3727 bytes .../home.imageset/Contents.json | 12 + .../home.imageset/home.pdf | Bin 0 -> 1013 bytes .../locked_icon.imageset/Contents.json | 12 + .../locked_icon.imageset/locked_icon.pdf | Bin 0 -> 6031 bytes .../no_data_icon.imageset/Contents.json | 12 + .../no_data_icon.imageset/no_data_icon.pdf | Bin 0 -> 4552 bytes .../no_wifi_icon.imageset/Contents.json | 12 + .../no_wifi_icon.imageset/no_wifi_icon.pdf | Bin 0 -> 4055 bytes .../weatherFields/Contents.json | 6 + .../dew_point_icon.imageset/Contents.json | 12 + .../dew_point_icon.pdf | Bin 0 -> 2929 bytes .../humidity_icon.imageset/Contents.json | 12 + .../humidity_icon.imageset/humidity_icon.pdf | Bin 0 -> 3219 bytes .../Contents.json | 12 + .../humidity_icon_small.pdf | Bin 0 -> 3244 bytes .../precipitation_icon.imageset/Contents.json | 12 + .../precipitation_icon.pdf | Bin 0 -> 4250 bytes .../pressure_icon.imageset/Contents.json | 12 + .../pressure_icon.imageset/pressrue_icon.pdf | Bin 0 -> 3815 bytes .../Contents.json | 12 + .../pressure_icon_small.pdf | Bin 0 -> 3875 bytes .../rain_icon_small.imageset/Contents.json | 12 + .../rain_icon_small.pdf | Bin 0 -> 4235 bytes .../solar_icon.imageset/Contents.json | 12 + .../solar_icon.imageset/uv_icon.pdf | Bin 0 -> 3428 bytes .../solar_icon_small.imageset/Contents.json | 12 + .../solar_icon_small.pdf | Bin 0 -> 4107 bytes .../temperature_icon.imageset/Contents.json | 12 + .../temperature_icon.pdf | Bin 0 -> 4591 bytes .../umbrella_icon.imageset/Contents.json | 12 + .../umbrella_icon.imageset/umbrella_icon.pdf | Bin 0 -> 3379 bytes .../Contents.json | 12 + .../umbrella_icon_small.pdf | Bin 0 -> 3615 bytes .../Contents.json | 12 + .../wind_dir_icon_small.pdf | Bin 0 -> 1975 bytes .../wind_icon.imageset/Contents.json | 12 + .../wind_icon.imageset/wind_icon.pdf | Bin 0 -> 3809 bytes .../wifi.imageset/Contents.json | 12 + .../wifi.imageset/wifi.pdf | Bin 0 -> 3470 bytes .../Assets/helium_countries_frequencies.json | 248 + .../Resources/Colors.xcassets/Contents.json | 6 + .../accent.colorset/Contents.json | 38 + .../Colors.xcassets/bg.colorset/Contents.json | 38 + .../blueTint.colorset/Contents.json | 38 + .../chartPrimary.colorset/Contents.json | 38 + .../chartSecondary.colorset/Contents.json | 38 + .../chartSecondaryLine.colorset/Contents.json | 38 + .../clear.colorset/Contents.json | 38 + .../crypto.colorset/Contents.json | 38 + .../darkGrey.colorset/Contents.json | 38 + .../darkestBlue.colorset/Contents.json | 38 + .../error.colorset/Contents.json | 20 + .../errorTint.colorset/Contents.json | 38 + .../favoriteHeart.colorset/Contents.json | 20 + .../launchSreenBg.colorset/Contents.json | 20 + .../layer1.colorset/Contents.json | 38 + .../layer2.colorset/Contents.json | 38 + .../lightestBlue.colorset/Contents.json | 38 + .../mapPin.colorset/Contents.json | 20 + .../midGrey.colorset/Contents.json | 38 + .../netStatsFabColor.colorset/Contents.json | 38 + .../Contents.json | 38 + .../primary.colorset/Contents.json | 38 + .../Contents.json | 56 + .../reward_score_high.colorset/Contents.json | 56 + .../reward_score_low.colorset/Contents.json | 56 + .../Contents.json | 56 + .../Contents.json | 56 + .../Contents.json | 56 + .../Contents.json | 38 + .../Contents.json | 38 + .../Contents.json | 38 + .../Contents.json | 38 + .../success.colorset/Contents.json | 20 + .../successTint.colorset/Contents.json | 38 + .../text.colorset/Contents.json | 38 + .../toastErrorBg.colorset/Contents.json | 20 + .../toastErrorText.colorset/Contents.json | 20 + .../toastInfoBg.colorset/Contents.json | 38 + .../top.colorset/Contents.json | 38 + .../warning.colorset/Contents.json | 20 + .../warningTint.colorset/Contents.json | 38 + .../Font Awesome 6 Brands-Regular-400.otf | Bin 0 -> 529444 bytes .../Font Awesome 6 Duotone-Solid-900.otf | Bin 0 -> 8487512 bytes .../Fonts/Font Awesome 6 Pro-Light-300.otf | Bin 0 -> 3044152 bytes .../Fonts/Font Awesome 6 Pro-Regular-400.otf | Bin 0 -> 2854148 bytes .../Fonts/Font Awesome 6 Pro-Solid-900.otf | Bin 0 -> 2321924 bytes .../Fonts/Font Awesome 6 Pro-Thin-100.otf | Bin 0 -> 3372240 bytes .../Font Awesome 6 Sharp-Regular-400.otf | Bin 0 -> 1781548 bytes .../Fonts/Font Awesome 6 Sharp-Solid-900.otf | Bin 0 -> 1383428 bytes wxm-ios/Resources/Launch Screen.storyboard | 48 + .../Localizable+RewardDetails.swift | 89 + .../Localizable/Localizable.xcstrings | 6502 +++++++++++++++++ .../Localizable/LocalizableConstants.swift | 616 ++ .../LocalizableString+Analytics.swift | 51 + .../LocalizableString+AppUpdate.swift | 39 + .../LocalizableString+Bluetooth.swift | 62 + .../LocalizableString+ClaimDevice.swift | 289 + .../LocalizableString+DeleteAccount.swift | 104 + .../LocalizableString+Errors.swift | 229 + .../LocalizableString+Filters.swift | 66 + .../Localizable/LocalizableString+Home.swift | 48 + .../LocalizableString+NetStats.swift | 129 + .../LocalizableString+Profile.swift | 86 + .../LocalizableString+Search.swift | 52 + .../LocalizableString+SelectFrequency.swift | 73 + ...alizableString+SelectStationLocation.swift | 46 + .../LocalizableString+Settings.swift | 48 + .../LocalizableString+StationDetails.swift | 114 + .../LocalizableString+UpdateFirmware.swift | 95 + .../LocalizableString+Wallet.swift | 104 + .../LocalizableString+Widget.swift | 51 + .../LottieJSON/anim_empty_devices.json | 1 + .../LottieJSON/anim_empty_generic.json | 1 + wxm-ios/Resources/LottieJSON/anim_error.json | 1 + wxm-ios/Resources/LottieJSON/anim_loader.json | 1955 +++++ .../Resources/LottieJSON/anim_loading.json | 1 + .../LottieJSON/anim_not_available.json | 1 + .../Resources/LottieJSON/anim_success.json | 1 + .../LottieJSON/anim_weather_clear_day.json | 1 + .../LottieJSON/anim_weather_clear_night.json | 1 + .../LottieJSON/anim_weather_cloudy.json | 1 + .../LottieJSON/anim_weather_drizzle.json | 1 + .../LottieJSON/anim_weather_fog.json | 1 + .../LottieJSON/anim_weather_overcast_day.json | 1 + .../anim_weather_overcast_night.json | 1 + .../anim_weather_partly_cloudy_day.json | 1 + .../anim_weather_partly_cloudy_night.json | 1 + .../LottieJSON/anim_weather_rain.json | 1 + .../LottieJSON/anim_weather_sleet.json | 1 + .../LottieJSON/anim_weather_snow.json | 1 + .../anim_weather_thunderstorms_rain.json | 1 + .../LottieJSON/anim_weather_wind.json | 1 + wxm-ios/Settings.bundle/Root.plist | 39 + wxm-ios/Settings.bundle/en.lproj/Root.strings | Bin 0 -> 600 bytes wxm-ios/Swinject/SwinjectHelper.swift | 198 + .../Toolkit/Toolkit.xcodeproj/project.pbxproj | 639 ++ .../xcshareddata/xcschemes/Toolkit.xcscheme | 67 + wxm-ios/Toolkit/Toolkit/Constants.swift | 11 + .../Firebase Manager/AnalyticsConstants.swift | 589 ++ .../Firebase Manager/FirebaseManager.swift | 85 + .../FirebaseManagerTypes.swift | 52 + .../RemoteConfigManager.swift | 144 + wxm-ios/Toolkit/Toolkit/Logger/Logger.swift | 60 + .../Toolkit/Toolkit/Logger/LoggerTypes.swift | 23 + .../Toolkit/Toolkit/Logger/MockLogger.swift | 18 + .../Toolkit/Toolkit/Logger/RemoteLogger.swift | 60 + wxm-ios/Toolkit/Toolkit/Toolkit.h | 18 + .../Toolkit/Toolkit/Types/CallbackTypes.swift | 13 + .../Toolkit/Utils/AsyncOperations.swift | 49 + wxm-ios/Toolkit/Toolkit/Utils/Bundle+.swift | 59 + .../Toolkit/Toolkit/Utils/Cancellables.swift | 24 + wxm-ios/Toolkit/Toolkit/Utils/Combine+.swift | 29 + .../Utils/CompactNumberFormatter.swift | 110 + .../Toolkit/Toolkit/Utils/DateExtension.swift | 258 + .../Toolkit/Utils/FoundationExtensions.swift | 195 + .../Toolkit/Utils/StorageWrapper.swift | 15 + .../Toolkit/Toolkit/Utils/ThreadSafe.swift | 29 + .../Toolkit/Utils/TimeValidationCache.swift | 82 + wxm-ios/Toolkit/Toolkit/Utils/UIDevice+.swift | 81 + wxm-ios/Toolkit/Toolkit/Utils/UIImage+.swift | 38 + .../Toolkit/Toolkit/Utils/UnitConverter.swift | 78 + .../Toolkit/Toolkit/Utils/WEIConverter.swift | 42 + .../Toolkit/Utils/WXMLocationManager.swift | 129 + .../UpdateFirmwareView+Content.swift | 98 + .../Update Firmware/UpdateFirmwareView.swift | 53 + wxm-ios/wxm-ios.entitlements | 19 + 850 files changed, 70437 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/ask_a_question.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .gitignore create mode 100644 .swift-version create mode 100644 .swiftlint.yml create mode 100644 BuildTools/Empty.swift create mode 100644 BuildTools/Package.swift create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Configuration/Debug/ConfigDebug.xcconfig-template create mode 100644 Configuration/Mock/ConfigMock.xcconfig-template create mode 100644 Configuration/Production/Config.xcconfig-template create mode 100644 LICENSE create mode 100644 PresentationLayer/Constants/AnimationsEnums.swift create mode 100644 PresentationLayer/Constants/AssetEnum.swift create mode 100644 PresentationLayer/Constants/ColorEnum.swift create mode 100644 PresentationLayer/Constants/CoordinatesEnum.swift create mode 100644 PresentationLayer/Constants/Dimensions.swift create mode 100644 PresentationLayer/Constants/FailAPICodeEnum.swift create mode 100644 PresentationLayer/Constants/FontSizeEnum.swift create mode 100644 PresentationLayer/Constants/FontsEnum.swift create mode 100644 PresentationLayer/Constants/ShadowEnum.swift create mode 100644 PresentationLayer/Constants/Strings/DisplayedLinks.swift create mode 100644 PresentationLayer/Constants/Strings/StringConstants.swift create mode 100644 PresentationLayer/Constants/Strings/SuccessFailEnum.swift create mode 100644 PresentationLayer/Constants/Strings/TextFieldError.swift create mode 100644 PresentationLayer/Constants/ThemeEnum.swift create mode 100644 PresentationLayer/Constants/WeatherFields.swift create mode 100644 PresentationLayer/ContentView.swift create mode 100644 PresentationLayer/Extensions/Common/Color+.swift create mode 100644 PresentationLayer/Extensions/Common/Functions+Units.swift create mode 100644 PresentationLayer/Extensions/Common/Image+.swift create mode 100644 PresentationLayer/Extensions/Common/String+Common.swift create mode 100644 PresentationLayer/Extensions/Date+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/BluetoothState+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/Common/Common.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/Common/CurrentWeather+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/Common/DeviceDetails+Common.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/Common/NetworkErrorResponse+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/Common/UserDeviceFollowState+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/DeviceDetails+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/DeviceRewardsOverview+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/ExplorerLocationError+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/Filters+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/Firmware+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/Network+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/NetworkDeviceIDTokensTransactionsResponse+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/NetworkDevicesInfoResponse+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/NetworkSearchResponse+.swift create mode 100644 PresentationLayer/Extensions/Domain Extensions/UnitsProtocol+.swift create mode 100644 PresentationLayer/Extensions/Numeric/CGFloat+.swift create mode 100644 PresentationLayer/Extensions/Numeric/Double+.swift create mode 100644 PresentationLayer/Extensions/Numeric/Int+.swift create mode 100644 PresentationLayer/Extensions/Numeric/Numeric+.swift create mode 100644 PresentationLayer/Extensions/Shape+.swift create mode 100644 PresentationLayer/Extensions/String+.swift create mode 100644 PresentationLayer/Extensions/Text+.swift create mode 100644 PresentationLayer/Extensions/UIColor+.swift create mode 100644 PresentationLayer/Extensions/UIImage+.swift create mode 100644 PresentationLayer/Extensions/View+.swift create mode 100644 PresentationLayer/Navigation/DeepLinkHandler.swift create mode 100644 PresentationLayer/Navigation/Router.swift create mode 100644 PresentationLayer/Navigation/RouterView.swift create mode 100644 PresentationLayer/Protocols/SwinjectInterface.swift create mode 100644 PresentationLayer/UI Components/Base Components/Account Confirmation/AccountConfirmationView.swift create mode 100644 PresentationLayer/UI Components/Base Components/Account Confirmation/AccountConfirmationViewModel.swift create mode 100644 PresentationLayer/UI Components/Base Components/AddButton.swift create mode 100644 PresentationLayer/UI Components/Base Components/AttributedLabel.swift create mode 100644 PresentationLayer/UI Components/Base Components/AttributedTextView.swift create mode 100644 PresentationLayer/UI Components/Base Components/Badge.swift create mode 100644 PresentationLayer/UI Components/Base Components/Base Text Field/BaseTextField.swift create mode 100644 PresentationLayer/UI Components/Base Components/Base Text Field/BaseTextFieldEnum.swift create mode 100644 PresentationLayer/UI Components/Base Components/CTAView.swift create mode 100644 PresentationLayer/UI Components/Base Components/CardWarningView.swift create mode 100644 PresentationLayer/UI Components/Base Components/CircleRadius.swift create mode 100644 PresentationLayer/UI Components/Base Components/Custom Sheet/BottomSheetModifier.swift create mode 100644 PresentationLayer/UI Components/Base Components/Custom Sheet/CustomSheet.swift create mode 100644 PresentationLayer/UI Components/Base Components/Custom Sheet/OverlayAnimator.swift create mode 100644 PresentationLayer/UI Components/Base Components/CustomPicker.swift create mode 100644 PresentationLayer/UI Components/Base Components/CustomSegmentView.swift create mode 100644 PresentationLayer/UI Components/Base Components/DeviceUpdatesLoadingView.swift create mode 100644 PresentationLayer/UI Components/Base Components/Fail Success /FailSuccessStateObject.swift create mode 100644 PresentationLayer/UI Components/Base Components/Fail Success /FailSuccessViewModifier.swift create mode 100644 PresentationLayer/UI Components/Base Components/Fail Success /FailView.swift create mode 100644 PresentationLayer/UI Components/Base Components/Fail Success /SuccessView.swift create mode 100644 PresentationLayer/UI Components/Base Components/Indication/Indication.swift create mode 100644 PresentationLayer/UI Components/Base Components/InfoView.swift create mode 100644 PresentationLayer/UI Components/Base Components/Lazy Loading Pager/LazyLoadingPagerView.swift create mode 100644 PresentationLayer/UI Components/Base Components/Lottie/LottieView.swift create mode 100644 PresentationLayer/UI Components/Base Components/PercentageGridLayoutView.swift create mode 100644 PresentationLayer/UI Components/Base Components/ProgressBarStyle.swift create mode 100644 PresentationLayer/UI Components/Base Components/QrScannerView.swift create mode 100644 PresentationLayer/UI Components/Base Components/Scrolling Picker/ScrollingPagerView.swift create mode 100644 PresentationLayer/UI Components/Base Components/Shimmer Effect Loader/ShimmerEffectLoader.swift create mode 100644 PresentationLayer/UI Components/Base Components/Shimmer Effect Loader/ShimmerEffectLoaderView.swift create mode 100644 PresentationLayer/UI Components/Base Components/Spinning Loader/SpinningLoader.swift create mode 100644 PresentationLayer/UI Components/Base Components/Spinning Loader/SpinningLoaderView.swift create mode 100644 PresentationLayer/UI Components/Base Components/StaticButtonStyle.swift create mode 100644 PresentationLayer/UI Components/Base Components/StationLastActiveView.swift create mode 100644 PresentationLayer/UI Components/Base Components/StepsNavView.swift create mode 100644 PresentationLayer/UI Components/Base Components/StepsView.swift create mode 100644 PresentationLayer/UI Components/Base Components/TabViewWrapper.swift create mode 100644 PresentationLayer/UI Components/Base Components/Toast/ToastView.swift create mode 100644 PresentationLayer/UI Components/Base Components/TrackableScrollView/TabBarVisibilityHandler.swift create mode 100644 PresentationLayer/UI Components/Base Components/TrackableScrollView/TrackableScrollView.swift create mode 100644 PresentationLayer/UI Components/Base Components/UberTextField.swift create mode 100644 PresentationLayer/UI Components/Base Components/WXMAlert/WXMAlertModifier.swift create mode 100644 PresentationLayer/UI Components/Base Components/WXMAlert/WXMAlertView.swift create mode 100644 PresentationLayer/UI Components/Base Components/WXMDivider.swift create mode 100644 PresentationLayer/UI Components/Base Components/WXMEmptyView.swift create mode 100644 PresentationLayer/UI Components/Base Components/Weather Overview/WeatherOverviewView+Content.swift create mode 100644 PresentationLayer/UI Components/Base Components/Weather Overview/WeatherOverviewView.swift create mode 100644 PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCard+Content.swift create mode 100644 PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCard.swift create mode 100644 PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCardView.swift create mode 100644 PresentationLayer/UI Components/Base Components/WebView/WebContainerView.swift create mode 100644 PresentationLayer/UI Components/MapBox/MapBoxClaimDevice.swift create mode 100644 PresentationLayer/UI Components/MapBox/MapBoxMapView.swift create mode 100644 PresentationLayer/UI Components/Modifiers/ChartModifier.swift create mode 100644 PresentationLayer/UI Components/Modifiers/ConditionalModifier.swift create mode 100644 PresentationLayer/UI Components/Modifiers/CornerRadius.swift create mode 100644 PresentationLayer/UI Components/Modifiers/CustomColorButtonStyle.swift create mode 100644 PresentationLayer/UI Components/Modifiers/GeneralButtonStyle.swift create mode 100644 PresentationLayer/UI Components/Modifiers/OnAnimationCompletedModifier.swift create mode 100644 PresentationLayer/UI Components/Modifiers/SizeObserver.swift create mode 100644 PresentationLayer/UI Components/Modifiers/StrokeBorderModifier.swift create mode 100644 PresentationLayer/UI Components/Modifiers/TabBarModifier.swift create mode 100644 PresentationLayer/UI Components/Modifiers/TextfieldClearButton.swift create mode 100644 PresentationLayer/UI Components/Modifiers/WXMButtonStyle.swift create mode 100644 PresentationLayer/UI Components/Modifiers/WXMCardStyle.swift create mode 100644 PresentationLayer/UI Components/Modifiers/WXMPopover.swift create mode 100644 PresentationLayer/UI Components/Modifiers/WXMShadow.swift create mode 100644 PresentationLayer/UI Components/Modifiers/WXMToggleStyle.swift create mode 100644 PresentationLayer/UI Components/Navigation/CustomNavigationLinkView.swift create mode 100644 PresentationLayer/UI Components/Navigation/NavigationContainerView.swift create mode 100644 PresentationLayer/UI Components/Screens/Analytics/AnalyticsView.swift create mode 100644 PresentationLayer/UI Components/Screens/Analytics/AnalyticsViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/App Update/AppUpdateView.swift create mode 100644 PresentationLayer/UI Components/Screens/App Update/AppUpdateViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Change Frequency/ChangeFrequencyView.swift create mode 100644 PresentationLayer/UI Components/Screens/Change Frequency/ChangeFrequencyViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Change Frequency/SelectFrequencyView.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/ClaimDeviceViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/ClaimDeviceNavView.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Bluetooth/BluetoothMessageView.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Bluetooth/BluetoothScanView.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceBluetooth.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceConnection.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceFrequency.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceLocation.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceReset.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceVerify.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Location/ClaimDeviceLocationMapView.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/HeliumClaimingStatusView+Content.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/HeliumClaimingStatusView.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/UITextField+StationSerialNumber.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/SelectDeviceTypeView.swift create mode 100644 PresentationLayer/UI Components/Screens/ClaimDevice/SuccessFailureView.swift create mode 100644 PresentationLayer/UI Components/Screens/ConnectWallet/MyWalletView.swift create mode 100644 PresentationLayer/UI Components/Screens/ConnectWallet/MyWalletViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/ConnectWallet/TextFieldsWallet.swift create mode 100644 PresentationLayer/UI Components/Screens/Daily Rewards /RewardDetailsView.swift create mode 100644 PresentationLayer/UI Components/Screens/Daily Rewards /RewardDetailsViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Device Info/DeviceInfoRowView.swift create mode 100644 PresentationLayer/UI Components/Screens/Device Info/DeviceInfoView.swift create mode 100644 PresentationLayer/UI Components/Screens/Device Info/DeviceInfoViewModel+Content.swift create mode 100644 PresentationLayer/UI Components/Screens/Device Info/DeviceInfoViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Device Info/StationInfoView.swift create mode 100644 PresentationLayer/UI Components/Screens/Explorer Stations List/ExplorerStationsListView.swift create mode 100644 PresentationLayer/UI Components/Screens/Explorer Stations List/ExplorerStationsListViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Explorer/ExplorerView.swift create mode 100644 PresentationLayer/UI Components/Screens/Explorer/ExplorerViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Explorer/Search/ExplorerSearchViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Explorer/Search/SearchView+Content.swift create mode 100644 PresentationLayer/UI Components/Screens/Explorer/Search/SearchView.swift create mode 100644 PresentationLayer/UI Components/Screens/ExplorerSignIn/RegisterView.swift create mode 100644 PresentationLayer/UI Components/Screens/ExplorerSignIn/RegisterViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/ExplorerSignIn/ResetPasswordView.swift create mode 100644 PresentationLayer/UI Components/Screens/ExplorerSignIn/ResetPasswordViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/ExplorerSignIn/SignInView.swift create mode 100644 PresentationLayer/UI Components/Screens/ExplorerSignIn/SignInViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/HistoryScreen/History Container/HistoryContainerView.swift create mode 100644 PresentationLayer/UI Components/Screens/HistoryScreen/History Container/HistoryContainerViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartCardTypes.swift create mode 100644 PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartCardView.swift create mode 100644 PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartsContainer.swift create mode 100644 PresentationLayer/UI Components/Screens/HistoryScreen/History View/HistoryView.swift create mode 100644 PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/ChartsFactory.swift create mode 100644 PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/HistoryChartModels.swift create mode 100644 PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/HistoryViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/LoggedInViews/LoggedInTabViewContainer.swift create mode 100644 PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabBarView.swift create mode 100644 PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabItemView.swift create mode 100644 PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabSelectionEnum.swift create mode 100644 PresentationLayer/UI Components/Screens/Main Screen/MainScreen.swift create mode 100644 PresentationLayer/UI Components/Screens/Main Screen/MainScreenViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Multiple Alerts/AlertsViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Multiple Alerts/MultipleAlertsView.swift create mode 100644 PresentationLayer/UI Components/Screens/Network Statistics/NetworkStats+Content.swift create mode 100644 PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsView.swift create mode 100644 PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsViewModel+Factory.swift create mode 100644 PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Network Statistics/StationDetailsGridView.swift create mode 100644 PresentationLayer/UI Components/Screens/Network Statistics/StatisticsChart.swift create mode 100644 PresentationLayer/UI Components/Screens/Profile/ProfileTypes.swift create mode 100644 PresentationLayer/UI Components/Screens/Profile/ProfileView.swift create mode 100644 PresentationLayer/UI Components/Screens/Profile/ProfileViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Reboot Station/RebootStationView.swift create mode 100644 PresentationLayer/UI Components/Screens/Reboot Station/RebootStationViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Select Station Location/SelectStationLocationView.swift create mode 100644 PresentationLayer/UI Components/Screens/Select Station Location/SelectStationLocationViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/Components/DeleteAccountModalView.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/Components/FailedDeleteView.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/Components/SettingsButtonView.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/Components/SettingsSectionTitle.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/Components/SuccessfulDeleteView.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/Components/UnitsOptionView.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/Components/UnitsOptionsModalView.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/DeleteAccountView.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/DeleteAccountViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/SettingsEnum.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/SettingsView.swift create mode 100644 PresentationLayer/UI Components/Screens/Settings/SettingsViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Update Firmware/UpdateFirmwareView+Content.swift create mode 100644 PresentationLayer/UI Components/Screens/Update Firmware/UpdateFirmwareView.swift create mode 100644 PresentationLayer/UI Components/Screens/Update Firmware/View Model/FirmwareUpdateUtils.swift create mode 100644 PresentationLayer/UI Components/Screens/Update Firmware/View Model/UpdateFirmwareViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardDatePoint.swift create mode 100644 PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/BaseRewardsCard.swift create mode 100644 PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/ProgressBar.swift create mode 100644 PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/RewardsScoreItem.swift create mode 100644 PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionDetailsView.swift create mode 100644 PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionDetailsViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionsPagination.swift create mode 100644 PresentationLayer/UI Components/Screens/Weather Charts/CustomYAxisFormatter.swift create mode 100644 PresentationLayer/UI Components/Screens/Weather Charts/NetStatsChartViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Weather Charts/WeatherChartDataModel.swift create mode 100644 PresentationLayer/UI Components/Screens/Weather Charts/WeatherLineChart.swift create mode 100644 PresentationLayer/UI Components/Screens/Weather Charts/XAxisValueFormatter.swift create mode 100644 PresentationLayer/UI Components/Screens/Weather Charts/YAxisValueFormatter.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Home/Filters/FilterView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Home/Filters/FilterViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Home/WeatherStationsHomeView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Home/WeatherStationsHomeViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/CustomRangeSlider.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForeCastCardView+Content.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastCardView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Observations/ObservationsView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Observations/ObservationsViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationLostRewardsView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardTypes.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsCardView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsErrorView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsOverviewView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsTimelineView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/StationDetailsContainerView.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/StationDetailsTypes.swift create mode 100644 PresentationLayer/UI Components/Screens/WeatherStations/Station Details/StationDetailsViewModel.swift create mode 100644 PresentationLayer/UI Components/Screens/WebView/WebView.swift create mode 100644 PresentationLayer/UI Components/ViewModelsFactory.swift create mode 100644 PresentationLayer/Utils/DateRange.swift create mode 100644 PresentationLayer/Utils/HelperFunctions.swift create mode 100644 PresentationLayer/Utils/LoaderView.swift create mode 100644 PresentationLayer/Utils/MapBox/MapBoxConstants.swift create mode 100644 PresentationLayer/Utils/MapBox/MapBoxSnapshotUrlGenerator.swift create mode 100644 PresentationLayer/Utils/SwiftUIUtils.swift create mode 100644 PresentationLayer/Utils/Toast.swift create mode 100644 PresentationLayer/Utils/UIKit Utils/Alert Helper/AlertHelper+Follow.swift create mode 100644 PresentationLayer/Utils/UIKit Utils/Alert Helper/AlertHelper.swift create mode 100644 PresentationLayer/Utils/UIKit Utils/UIKitUtils.swift create mode 100644 PresentationLayer/Utils/Weather/UnitConstants.swift create mode 100644 PresentationLayer/Utils/Weather/WeatherFormatter.swift create mode 100644 PresentationLayer/Utils/Weather/WeatherUnitsConverter.swift create mode 100644 PresentationLayer/Utils/Weather/WeatherUnitsManager.swift create mode 100644 PresentationLayer/ViewModels/HistoryViewModel.swift create mode 100644 README.md create mode 100644 Scripts/sort-Xcode-project-file create mode 100644 Scripts/sort_all_projects.sh create mode 100755 ci_scripts/ci_post_clone.sh create mode 100644 station-intent/Info.plist create mode 100644 station-intent/IntentHandler.swift create mode 100644 station-intent/station-intent.entitlements create mode 100644 station-widget/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 station-widget/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 station-widget/Assets.xcassets/Contents.json create mode 100644 station-widget/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 station-widget/Assets.xcassets/clear-day.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/clear-day.imageset/clear-day.svg create mode 100644 station-widget/Assets.xcassets/clear-night.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/clear-night.imageset/clear-night.svg create mode 100644 station-widget/Assets.xcassets/cloudy.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/cloudy.imageset/cloudy.svg create mode 100644 station-widget/Assets.xcassets/drizzle.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/drizzle.imageset/drizzle.svg create mode 100644 station-widget/Assets.xcassets/fog.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/fog.imageset/fog.svg create mode 100644 station-widget/Assets.xcassets/overcast-day.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/overcast-day.imageset/overcast-day.svg create mode 100644 station-widget/Assets.xcassets/overcast-night.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/overcast-night.imageset/overcast-night.svg create mode 100644 station-widget/Assets.xcassets/partly-cloudy-day.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/partly-cloudy-day.imageset/partly-cloudy-day.svg create mode 100644 station-widget/Assets.xcassets/partly-cloudy-night.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/partly-cloudy-night.imageset/partly-cloudy-night.svg create mode 100644 station-widget/Assets.xcassets/rain.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/rain.imageset/rain.svg create mode 100644 station-widget/Assets.xcassets/sleet.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/sleet.imageset/sleet.svg create mode 100644 station-widget/Assets.xcassets/snow.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/snow.imageset/snow.svg create mode 100644 station-widget/Assets.xcassets/thunderstorms-rain.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/thunderstorms-rain.imageset/thunderstorms-rain.svg create mode 100644 station-widget/Assets.xcassets/wind.imageset/Contents.json create mode 100644 station-widget/Assets.xcassets/wind.imageset/wind.svg create mode 100644 station-widget/Extenstions/DeviceDetails+Widget.swift create mode 100644 station-widget/Extenstions/View+.swift create mode 100644 station-widget/Info.plist create mode 100644 station-widget/StationTimelineEntry.swift create mode 100644 station-widget/StationWidgetConfiguration.intentdefinition create mode 100644 station-widget/Views/ErrorView.swift create mode 100644 station-widget/Views/LoggedOutView.swift create mode 100644 station-widget/Views/StationWidgetView.swift create mode 100644 station-widget/WidgetConstants.swift create mode 100644 station-widget/station_widget.swift create mode 100644 station-widget/station_widgetBundle.swift create mode 100644 station-widgetExtension.entitlements create mode 100644 wxm-ios.xcodeproj/project.pbxproj create mode 100644 wxm-ios.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 wxm-ios.xcodeproj/xcshareddata/xcschemes/station-widgetExtension.xcscheme create mode 100644 wxm-ios.xcodeproj/xcshareddata/xcschemes/wxm-ios-mock.xcscheme create mode 100644 wxm-ios.xcodeproj/xcshareddata/xcschemes/wxm-ios-release.xcscheme create mode 100644 wxm-ios.xcodeproj/xcshareddata/xcschemes/wxm-ios.xcscheme create mode 100644 wxm-ios/AppCore/AppDelegate.swift create mode 100644 wxm-ios/AppCore/MainApp.swift create mode 100644 wxm-ios/Colors.xcassets/favoriteHeart.colorset/Contents.json create mode 100644 wxm-ios/DataLayer/DataLayer.xcodeproj/project.pbxproj create mode 100644 wxm-ios/DataLayer/DataLayer.xcodeproj/xcshareddata/xcschemes/DataLayer.xcscheme create mode 100644 wxm-ios/DataLayer/DataLayer/DataLayer.h create mode 100644 wxm-ios/DataLayer/DataLayer/Database/DBExplorerAddress+CoreDataClass.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Database/DBExplorerAddress+CoreDataProperties.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Database/DBExplorerDevice+CoreDataClass.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Database/DBExplorerDevice+CoreDataProperties.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Database/DBExplorerSearchEntity+CoreDataClass.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Database/DBExplorerSearchEntity+CoreDataProperties.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Database/DBProtocols.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Database/DBWeather+CoreDataClass.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Database/DBWeather+CoreDataProperties.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Database/DatabaseService.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Database/Model.xcdatamodeld/.xccurrentversion create mode 100644 wxm-ios/DataLayer/DataLayer/Database/Model.xcdatamodeld/Model.xcdatamodel/contents create mode 100644 wxm-ios/DataLayer/DataLayer/Entities/EmailPasswordCodable.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/ApiClient.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/ApiRequestBuilders/AuthApiRequestBuilder.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/ApiRequestBuilders/CellRequestBuilder.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/ApiRequestBuilders/DevicesApiRequestBuilder.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/ApiRequestBuilders/MeApiRequestBuilder.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/ApiRequestBuilders/NetworkApiRequestBuilder.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Connectivity.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Constants/NetworkConstants.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Constants/ParameterConstants.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Interceptors/AuthInterceptor.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Interceptors/RequestHeadersAdapter.swift create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_device_history.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_device_history_24_samples.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_device_info_helium.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_device_info_m5.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_network_search.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_network_stats.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_network_stats_alt.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_transactions.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_transactions_empty.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_user_device.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_user_device_forecast.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_user_device_just_claimed.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_user_device_rewards.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_user_devices.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_user_rewards.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons/get_user_wallet.json create mode 100644 wxm-ios/DataLayer/DataLayer/Networking/Mock/MockProtocol.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/AuthRepositoryImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/Bluetooth/BTActionsWrapper.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/Bluetooth/BTPerformCommandDelegate.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/Bluetooth/BluetoothManager.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/Bluetooth/BluetoothUtils.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/Bluetooth/HeliumDeviceBluetoothHelper.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/BluetoothDevicesRepositoryImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/DeviceInfoRepositoryImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/DevicesRepositoryImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/ExplorerRepositoryImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/FiltersRepositoryImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/Firmware Update/FirmwareUpdater.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/FirmwareUpdateImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/KeychainRepositoryImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/LocationRepositoryImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/MeRepositoryImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/NetworkRepositoryImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/SettingsRepositoryImpl.swift create mode 100644 wxm-ios/DataLayer/DataLayer/RepositoryImplementations/UserDefaultsRepositoryImp.swift create mode 100644 wxm-ios/DataLayer/DataLayer/UserDefaultsService/UserDefaultsService.swift create mode 100644 wxm-ios/DataLayer/DataLayer/countries_information.json create mode 100644 wxm-ios/DataLayer/FiltersService.swift create mode 100644 wxm-ios/DataLayer/KeychainHelperService/KeychainConstants.swift create mode 100644 wxm-ios/DataLayer/KeychainHelperService/KeychainHelperService+User.swift create mode 100644 wxm-ios/DataLayer/KeychainHelperService/KeychainHelperService.swift create mode 100644 wxm-ios/DataLayer/UserDevicesService.swift create mode 100644 wxm-ios/DataLayer/UserInfoService.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer.xcodeproj/project.pbxproj create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainLayer.h create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/AuthRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/BluetoothDevicesRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/DeviceInfoRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/DeviceLocationRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/DevicesRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/ExplorerRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/FiltersRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/FirmwareUpdateRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/KeychainRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/MeRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/NetworkRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/SettingsRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/DomainRepositoryInterfaces/UserDefaultsRepository.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Auth/NetworkTokenResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Cells/PublicDevice.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Cells/PublicHex.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Connectivity.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Devices/NetworkDevicesInfoResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Devices/NetworkDevicesResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Devices/NetworkDevicesTypes.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/EmptyEntity.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Error/NetworkErrorResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/LocationCoordinates.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Me/Body/ClaimDeviceBody.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Me/Network/NetworkDeviceForecastResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Me/Network/NetworkDeviceHistoryResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Me/Network/NetworkDeviceIDTransactionsResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Me/Network/NetworkDeviceRewards.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Me/Network/NetworkDeviceTokensResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Me/Network/NetworkFirmwareResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Me/Network/NetworkUserInfoResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Me/Network/NetworkUserRewardsResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Network/NetworkSearchResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Network/NetworkStatsResponse.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/DomainErrors/PublicHexError.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/DomainModels/CountryInfo.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/DomainModels/DeviceAnnotation.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/DomainModels/DeviceDetails.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/DomainModels/DeviceLocation.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/DomainModels/Explorer/ExplorerData.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/DomainModels/Filters.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/DomainModels/Frequency.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/DomainModels/HeliumDevice.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/DomainModels/UITransaction.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Entities/DomainModels/WeatherField.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Extensions/Collection+.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Extensions/Double+.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Extensions/UserDefaults+Constants.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/KeychainHelperService/InfoKeychainConstants.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/KeychainHelperService/KeychainHelperService.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/AuthUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/DeviceDetailsUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/DeviceInfoUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/DeviceLocationUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/DevicesUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/ExplorerUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/FiltersUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/HistoryUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/KeychainUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/MainUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/MeUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/NetworkUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/RewardsUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/SettingsUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/TokenUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/UpdateFirmwareUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/UseCases/WidgetUseCase.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Utils/Geocoder.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Utils/NetworkResultsConverter.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Utils/UnitsEnum.swift create mode 100644 wxm-ios/DomainLayer/DomainLayer/Utils/WeatherUnitFormatter.swift create mode 100644 wxm-ios/Info.plist create mode 100644 wxm-ios/PresentationLayer/PresentationLayer.h create mode 100644 wxm-ios/PresentationLayer/PresentationLayer.xcodeproj/project.pbxproj create mode 100644 wxm-ios/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/AppIcon.appiconset/1025.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/DeleteFailureIcon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/DeleteFailureIcon.imageset/DeleteFailureIcon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/DeleteSuccessIcon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/DeleteSuccessIcon.imageset/DeleteSuccessIcon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/accountBalanceWallet.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/accountBalanceWallet.imageset/account_balance_wallet.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/account_filled_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/account_filled_icon.imageset/account_filled_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/analytics_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/analytics_icon.imageset/analytics_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/backArrow.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/backArrow.imageset/backArrow.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/badge.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/badge.imageset/badge.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/check.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/check.imageset/check.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/checkBoxOutlined.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/checkBoxOutlined.imageset/checkBoxOutlined.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/chevronDown.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/chevronDown.imageset/chevronDown.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/circleCheckmark.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/circleCheckmark.imageset/circleCheckmark.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/bluetoothGray.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/bluetoothGray.imageset/bluetoothGray.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/claimBluetoothButton.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/claimBluetoothButton.imageset/claimBluetoothButton.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/claimHelium.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/claimHelium.imageset/claimHelium.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/claimWiFi.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/claimWiFi.imageset/claimWiFi.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/infoIcon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/infoIcon.imageset/infoIcon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/stationResetSchematic.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDevice/stationResetSchematic.imageset/stationResetSchematic.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDeviceFailure.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/claimDeviceFailure.imageset/claimDeviceFailure.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/clear_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/clear_icon.imageset/clear_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/closeIcon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/closeIcon.imageset/closeIcon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/close_button.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/close_button.imageset/icon.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/close_button.imageset/icon@2x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/close_button.imageset/icon@3x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/detectLocation.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/detectLocation.imageset/detectLocation.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/downArrow.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/downArrow.imageset/downArrow.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/edit_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/edit_icon.imageset/editIcon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/email.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/email.imageset/mail_outline.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/error_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/error_icon.imageset/error_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/eye.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/eye.imageset/visibility.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/globe.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/globe.imageset/globe.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/info.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/info.imageset/infoIcon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/keyboardArrowLeft.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/keyboardArrowLeft.imageset/keyboard_arrow_left.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/keyboardArrowRight.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/keyboardArrowRight.imageset/keyboardArrowRight.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/locationMap.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/locationMap.imageset/locationMap.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/lock.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/lock.imageset/lock.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/m5Video.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/m5Video.imageset/m5Video.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/mapStaticImage.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/mapStaticImage.imageset/mapStaticImage.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/markerDefault.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/markerDefault.imageset/place_black_24dp_1.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/network_stats_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/network_stats_icon.imageset/network_stats_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/offline_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/offline_icon.imageset/offline_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/open_external_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/open_external_icon.imageset/open_external_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/panoramaFishEye.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/panoramaFishEye.imageset/panoramaFishEye.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/panoramaFishEyeFilled.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/panoramaFishEyeFilled.imageset/panoramaFishEyeFilled.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/partlyCloudyDay.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/partlyCloudyDay.imageset/partlyCloudyDay2.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/pin.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/pin.imageset/pin.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/plain_info_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/plain_info_icon.imageset/plain_info_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/plus.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/plus.imageset/add_24px.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/qrCode.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/qrCode.imageset/qrcode-solid.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/qrCodeBlue.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/qrCodeBlue.imageset/qrCodeBlue.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/radio_button_active_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/radio_button_active_icon.imageset/radio_button_active_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/radio_button_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/radio_button_icon.imageset/radio_button_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/redAlert.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/redAlert.imageset/redAlert.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/rewardWidget/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/rewardWidget/hexagon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/rewardWidget/hexagon.imageset/Vector (Stroke).png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/rewardWidget/hexagon.imageset/Vector (Stroke)@2x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/rewardWidget/hexagon.imageset/Vector (Stroke)@3x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/rewardWidget/hexagonBigger.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/rewardWidget/hexagonBigger.imageset/hexagonBigger.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/search.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/search.imageset/search.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/settings.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/settings.imageset/moreOptions.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/settingsGear.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/settingsGear.imageset/gear-solid 1.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/share.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/share.imageset/share.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/share_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/share_icon.imageset/share_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/toggleCheckIcon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/toggleCheckIcon.imageset/toggleCheckIcon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/toggleCheckmark.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/toggleCheckmark.imageset/toggleCheckmark.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/toggleXIcon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/toggleXIcon.imageset/toggleXIcon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/toggleXMark.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/toggleXMark.imageset/toggleXMark.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/update_firmware_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/update_firmware_icon.imageset/update_firmware_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/user.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/user.imageset/Icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/warning_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/warning_icon.imageset/warning_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/humidity.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/humidity.imageset/Vector.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/humidity.imageset/Vector@2x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/humidity.imageset/Vector@3x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/precipitation.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/precipitation.imageset/Vector.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/precipitation.imageset/Vector@2x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/precipitation.imageset/Vector@3x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/pressure.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/pressure.imageset/Vector.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/pressure.imageset/Vector@2x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/pressure.imageset/Vector@3x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/temperature.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/temperature.imageset/Vector.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/temperature.imageset/Vector@2x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/temperature.imageset/Vector@3x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/uvindex.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/uvindex.imageset/Vector.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/uvindex.imageset/Vector@2x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/uvindex.imageset/Vector@3x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/wind.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/wind.imageset/Vector.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/wind.imageset/Vector@2x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherMetrics/wind.imageset/Vector@3x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherXMLogo.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherXMLogo.imageset/White on dark.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherXMLogoLaunchScreen.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherXMLogoLaunchScreen.imageset/WeatherXM_Discord_Logo_Animation-1 1.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherXMLogoSmall.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/weatherXMLogoSmall.imageset/exmLogoSmall.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/chevron-right.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/chevron-right.imageset/Vector.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/chevron-right.imageset/Vector@2x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/chevron-right.imageset/Vector@3x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/partly-cloud.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/partly-cloud.imageset/partly-cloudy-day 3.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/partly-cloud.imageset/partly-cloudy-day 3@2x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/partly-cloud.imageset/partly-cloudy-day 3@3x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/umbrella.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/umbrella.imageset/Vector.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/umbrella.imageset/Vector@2x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wetherForecastWidget/umbrella.imageset/Vector@3x.png create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wonderFace.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wonderFace.imageset/wonderFace.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wxmDevice.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/wxmDevice.imageset/WXM-M5 1.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/xm_search_logo.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/xm_search_logo.imageset/xm_search_logo 1.pdf create mode 100644 wxm-ios/Resources/App Assets/Assets.xcassets/xm_search_logo.imageset/xm_search_logo.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/errorExclamationIcon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/errorExclamationIcon.imageset/errorExclamationIcon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/helium.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/helium.imageset/helium.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/home.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/home.imageset/home.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/locked_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/locked_icon.imageset/locked_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/no_data_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/no_data_icon.imageset/no_data_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/no_wifi_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/no_wifi_icon.imageset/no_wifi_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/dew_point_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/dew_point_icon.imageset/dew_point_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/humidity_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/humidity_icon.imageset/humidity_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/humidity_icon_small.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/humidity_icon_small.imageset/humidity_icon_small.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/precipitation_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/precipitation_icon.imageset/precipitation_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/pressure_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/pressure_icon.imageset/pressrue_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/pressure_icon_small.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/pressure_icon_small.imageset/pressure_icon_small.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/rain_icon_small.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/rain_icon_small.imageset/rain_icon_small.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/solar_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/solar_icon.imageset/uv_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/solar_icon_small.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/solar_icon_small.imageset/solar_icon_small.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/temperature_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/temperature_icon.imageset/temperature_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/umbrella_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/umbrella_icon.imageset/umbrella_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/umbrella_icon_small.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/umbrella_icon_small.imageset/umbrella_icon_small.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/wind_dir_icon_small.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/wind_dir_icon_small.imageset/wind_dir_icon_small.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/wind_icon.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/weatherFields/wind_icon.imageset/wind_icon.pdf create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/wifi.imageset/Contents.json create mode 100644 wxm-ios/Resources/App Assets/CommonAssets.xcassets/wifi.imageset/wifi.pdf create mode 100644 wxm-ios/Resources/Assets/helium_countries_frequencies.json create mode 100644 wxm-ios/Resources/Colors.xcassets/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/accent.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/bg.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/blueTint.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/chartPrimary.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/chartSecondary.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/chartSecondaryLine.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/clear.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/crypto.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/darkGrey.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/darkestBlue.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/error.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/errorTint.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/favoriteHeart.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/launchSreenBg.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/layer1.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/layer2.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/lightestBlue.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/mapPin.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/midGrey.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/netStatsFabColor.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/netStatsFabTextColor.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/primary.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/reward_score_average.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/reward_score_high.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/reward_score_low.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/reward_score_unknown.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/reward_score_very_high.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/reward_score_very_low.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/stationChipOfflineStateBg.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/stationChipOfflineStateText.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/stationChipOnlineStateBg.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/stationChipOnlineStateText.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/success.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/successTint.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/text.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/toastErrorBg.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/toastErrorText.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/toastInfoBg.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/top.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/warning.colorset/Contents.json create mode 100644 wxm-ios/Resources/Colors.xcassets/warningTint.colorset/Contents.json create mode 100644 wxm-ios/Resources/Fonts/Font Awesome 6 Brands-Regular-400.otf create mode 100644 wxm-ios/Resources/Fonts/Font Awesome 6 Duotone-Solid-900.otf create mode 100644 wxm-ios/Resources/Fonts/Font Awesome 6 Pro-Light-300.otf create mode 100644 wxm-ios/Resources/Fonts/Font Awesome 6 Pro-Regular-400.otf create mode 100644 wxm-ios/Resources/Fonts/Font Awesome 6 Pro-Solid-900.otf create mode 100644 wxm-ios/Resources/Fonts/Font Awesome 6 Pro-Thin-100.otf create mode 100644 wxm-ios/Resources/Fonts/Font Awesome 6 Sharp-Regular-400.otf create mode 100644 wxm-ios/Resources/Fonts/Font Awesome 6 Sharp-Solid-900.otf create mode 100644 wxm-ios/Resources/Launch Screen.storyboard create mode 100644 wxm-ios/Resources/Localizable/Localizable+RewardDetails.swift create mode 100644 wxm-ios/Resources/Localizable/Localizable.xcstrings create mode 100644 wxm-ios/Resources/Localizable/LocalizableConstants.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+Analytics.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+AppUpdate.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+Bluetooth.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+ClaimDevice.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+DeleteAccount.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+Errors.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+Filters.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+Home.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+NetStats.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+Profile.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+Search.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+SelectFrequency.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+SelectStationLocation.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+Settings.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+StationDetails.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+UpdateFirmware.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+Wallet.swift create mode 100644 wxm-ios/Resources/Localizable/LocalizableString+Widget.swift create mode 100644 wxm-ios/Resources/LottieJSON/anim_empty_devices.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_empty_generic.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_error.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_loader.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_loading.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_not_available.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_success.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_clear_day.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_clear_night.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_cloudy.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_drizzle.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_fog.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_overcast_day.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_overcast_night.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_partly_cloudy_day.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_partly_cloudy_night.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_rain.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_sleet.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_snow.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_thunderstorms_rain.json create mode 100644 wxm-ios/Resources/LottieJSON/anim_weather_wind.json create mode 100644 wxm-ios/Settings.bundle/Root.plist create mode 100644 wxm-ios/Settings.bundle/en.lproj/Root.strings create mode 100644 wxm-ios/Swinject/SwinjectHelper.swift create mode 100644 wxm-ios/Toolkit/Toolkit.xcodeproj/project.pbxproj create mode 100644 wxm-ios/Toolkit/Toolkit.xcodeproj/xcshareddata/xcschemes/Toolkit.xcscheme create mode 100644 wxm-ios/Toolkit/Toolkit/Constants.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Firebase Manager/AnalyticsConstants.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Firebase Manager/FirebaseManager.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Firebase Manager/FirebaseManagerTypes.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Firebase Manager/RemoteConfigManager.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Logger/Logger.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Logger/LoggerTypes.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Logger/MockLogger.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Logger/RemoteLogger.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Toolkit.h create mode 100644 wxm-ios/Toolkit/Toolkit/Types/CallbackTypes.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/AsyncOperations.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/Bundle+.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/Cancellables.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/Combine+.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/CompactNumberFormatter.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/DateExtension.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/FoundationExtensions.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/StorageWrapper.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/ThreadSafe.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/TimeValidationCache.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/UIDevice+.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/UIImage+.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/UnitConverter.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/WEIConverter.swift create mode 100644 wxm-ios/Toolkit/Toolkit/Utils/WXMLocationManager.swift create mode 100644 wxm-ios/Update Firmware/UpdateFirmwareView+Content.swift create mode 100644 wxm-ios/Update Firmware/UpdateFirmwareView.swift create mode 100644 wxm-ios/wxm-ios.entitlements diff --git a/.github/ISSUE_TEMPLATE/ask_a_question.md b/.github/ISSUE_TEMPLATE/ask_a_question.md new file mode 100644 index 00000000..d6e45252 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ask_a_question.md @@ -0,0 +1,15 @@ +--- +name: Ask a question +about: Ask a question about the app. +labels: question, needs-attention +assignees: '' + +--- + +## **Your Question** + +A clear and concise description of your question regarding the app. + +### **Additional context** + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..3395766f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,40 @@ +--- +name: Bug report +about: Create a report to help us improve +labels: bug, needs-attention +assignees: '' + +--- + +## **Describe the bug** + +A clear and concise description of what the bug is. + +### **How To Reproduce** + +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +### **Expected behavior** + +A clear and concise description of what you expected to happen. + +### **Screenshots** + +If applicable, add screenshots to help explain your problem. + +### **If Applicable: Smartphone (please complete the following information that are needed for this + +bug):** + +- Device: [e.g. iPhone 13 Pro Max] +- iOS Version [e.g. iOS 17.0.1] +- iOS App Version [e.g. 1.9.0], you can find it on the in-app Settings + +### **Additional context** + +Add any other context about the problem here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..e5495a5c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest a new feature for the iOS App +labels: needs-attention, enhancement +assignees: '' + +--- + +### **Is your feature request related to a problem? Please describe.** + +A clear and concise description of what the requested feature is. + +### **Describe the solution you'd like** + +A clear and concise description of what you want to happen. + +### **Describe alternatives you've considered** + +A clear and concise description of any alternative solutions you've considered. + +### **Additional context** + +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..eb45441f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,10 @@ +## **Why?** +A clear and concise description of what is this PR about. +### **How?** +A clear and concise description of how you managed to tackle the issue. +### **Testing** +Describe the testing process or steps taken to verify the changes made in this pull request. +### **Screenshots (if applicable)** +If your changes include visual updates, please provide screenshots or gifs. +### **Additional context** +Add any other context about the PR here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e6bdbcde --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Xcode Project +**/*.xcodeproj/xcuserdata/ +**/*.xcworkspace/xcuserdata/ +**/.swiftpm/xcode/xcuserdata/ +**/*.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +**/*.xcworkspace/xcshareddata/*.xccheckout +**/*.xcworkspace/xcshareddata/*.xcscmblueprint +**/*.playground/**/timeline.xctimeline +.idea/ + +# Build +Scripts/build/ +build/ +DerivedData/ +*.ipa + +# CSV +*.orig +.svn + +# Other +*~ +.DS_Store +*.swp +*.save +._* +*.bak +BuildTools/.build/* +BuildTools/.build +BuildTools/.swiftpm +*.xcconfig +*GoogleService-Info.plist diff --git a/.swift-version b/.swift-version new file mode 100644 index 00000000..0062ac97 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +5.0.0 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 00000000..97722229 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,38 @@ +disabled_rules: + - void_function_in_ternary + - vertical_parameter_alignment +trailing_whitespace: + ignores_empty_lines: true +line_length: 180 +file_length: + warning: 500 + error: 700 +type_name: + allowed_symbols: "_" + min_length: 3 # only warning + max_length: # warning and error + warning: 40 + error: 50 +identifier_name: + min_length: 2 + allowed_symbols: "_" +switch_case_alignment: + indented_cases: true +function_body_length: + error: 150 +cyclomatic_complexity: + ignores_case_statements: true +nesting: + type_level: + warning: 2 +force_try: + severity: warning +force_cast: + severity: warning +large_tuple: + warning: 3 + error: 4 +excluded: # paths to ignore during linting. Takes precedence over `included`. + - Pods + - R.generated.swift + - .build # Where Swift Package Manager checks out dependency sources diff --git a/BuildTools/Empty.swift b/BuildTools/Empty.swift new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/BuildTools/Empty.swift @@ -0,0 +1 @@ + diff --git a/BuildTools/Package.swift b/BuildTools/Package.swift new file mode 100644 index 00000000..ce291adc --- /dev/null +++ b/BuildTools/Package.swift @@ -0,0 +1,11 @@ +// swift-tools-version:5.1 +import PackageDescription + +let package = Package( + name: "BuildTools", + platforms: [.macOS(.v10_11)], + dependencies: [ + .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.49.0"), + ], + targets: [.target(name: "BuildTools", path: "")] +) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..675e0af1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,69 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is +officially representing the community in public spaces. Examples of representing our community +include using an official e-mail address, posting via an official social media account, or acting as +an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at +support@weatherxm.com. +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..2796ff75 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,202 @@ +# Welcome to WeatherXM's iOS App contributing guide. + +First of all, thank you for investing your time to contribute in WeatherXM! Any contribution you +make will be reflected on +our [iOS App](https://apps.apple.com/ca/app/weatherxm/id1629841929). + +We use **[Github Flow](https://githubflow.github.io/)** as our branching model and *Clean +Architecture*. + +In this guide you will get an overview of the contribution workflow from opening an issue, creating +a PR, reviewing, and merging the PR. + +## Table Of Contents + +[Code of Conduct](#code-of-conduct) + +[Building & Environment](#building--environment) + +* [Environment Variables](#environment-variables) +* [Different Schemes](#different-schemes) +* [Google Services JSON](#google-services-json) + +[How to ask a question, report a bug or suggest a potential new feature/improvement?](#how-to-ask-a-question-report-a-bug-or-suggest-a-potential-new-featureimprovement) + +* [Do you have a question](#do-you-have-a-question) +* [Reporting Bugs](#did-you-find-a-bug) +* [Suggesting Enhancements](#do-you-want-to-suggest-a-potential-improvement-or-a-new-feature) + +[How to Contribute?](#how-to-contribute) + +[Styleguide](#styleguide) + +[Additional Notes](#additional-notes) + +* [Issue Labels](#issue-labels) + +## Code of Conduct + +This project and everyone participating in it is governed by the +[Code of Conduct](blob/main/CODE_OF_CONDUCT.md). +By participating, you are expected to uphold this code to keep our community approachable and +respectable. + +## Building & Environment + +### Environment Variables + +To build the app from source, you need to pass the following environment variables, by creating a `*.xcconfig` file in the [Configuration](/Configuration) directory, +according to +the [template](https://github.com/WeatherXM/wxm-ios/Config.xcconfig-template) file, +that will be +automatically read into env variables. You must use your own environmental +variables +for contributing to the project. + +All environment variables have descriptive comments on the template, but some extra information +should be given +regarding the Firebase and the Mapbox ones. + +The `ApiUrl` is specified via these `*.xcconfig` files. In order to use a different `ApiUrl` than the +one provided +in `Config.xcconfig-template`, for each different scheme (see below about our different schemes) +you need to create a different `{scheme}.xcconfig` file based on the following map: +Mock -> `ConfigMock.xcconfig` and add it under `Configuration/Mock` folder +Debug -> `ConfigDebug.xcconfig` and add it under `Configuration/Debug` folder +Production -> `Config.xcconfig` and add it under `Configuration/Production` folder + +#### Mapbox Variables + +We have a variable for Mapbox: + +- `MBXAccessToken` + This is required for building the project. For creating this token you have to create a [Mapbox account](https://account.mapbox.com). + To download the mapBox SDK you should add `.netrc` file as described [here](https://docs.mapbox.com/ios/maps/guides/install/). + +You can view Mapbox guide on Access +Token [here](https://docs.mapbox.com/help/getting-started/access-tokens/). + +### Different Schemes + +We have 3 different app schemes. For each scheme a +different `.xcconfig` file should be created for different environment variables such as `ApiUrl`. + +The 3 different app flavors are: + +1. **wxm-ios**: This scheme is mainly used for development. Creates an app with a `-DB` suffix. Uses the `ConfigDebug.xcconfig` file as mentioned [below](#environment-variables) and build the app with debug configuration. +2. **wxm-ios-release**: This scheme is used to install an app with `release` configuration. Creates an app with no suffix. Uses the `Config.xcconfig` file as mentioned [below](#environment-variables). +3. **wxm-ios-mock**: This scheme is mainly used only for development purposes and to "mock" specific case. Uses the `ConfigMock.xcconfig` file as mentioned [below](#environment-variables). +Points to the injected `ApiUrl`. The difference here is that for each endpoint we can provide a mock response (`wxm-ios/DataLayer/DataLayer/Networking/Mock/Jsons`). +If it is provided, it retrurns the mock response, otherwise fetches the remote response. + +### Google Services JSON + +The `GoogleService-Info.plist` configuration file is required for building the app. Depending on the +scheme you want to use, +you should create your own Firebase project and download that file. A guide on how to do it can be +found [here](https://firebase.google.com/docs/ios/setup#console). +**For each scheme you should place this file under `wxm-ios/Resources/Debug/` folder for debug builds and under `wxm-ios/Resources/Release/` for any other case** + +You can use, and is recommended during development, the `-WXMFirebaseDisabled YES` argument to disable every logging and monitoring functionality + +## How to ask a question, report a bug or suggest a potential new feature/improvement? + +### **Do you have a question?** + +* **Ensure your question was not already asked** by searching on GitHub + under [Issues](https://github.com/WeatherXM/wxm-ios/issues?q=is%3Aopen+is%3Aissue+label%3Aquestion) under the + label _Question_. + +* If you're unable to find a response to your + question , [open a new issue](https://github.com/WeatherXM/wxm-ios/issues/new/choose) by using + the [**Ask a question** template](https://github.com/WeatherXM/wxm-ios/blob/main/.github/ISSUE_TEMPLATE/ask_a_question.md). + Using this template is mandatory. Make sure to have a **clear title** and include as many details + as possible as that information helps to answer your question as soon as possible. + +### **Did you find a bug?** + +* **Ensure the bug was not already reported** by searching on GitHub + under [Issues](https://github.com/WeatherXM/wxm-ios/issues?q=is%3Aopen+is%3Aissue+label%3Abug) under the label + _Bug_. + +* If you're unable to find an open issue addressing the + problem, [open a new issue](https://github.com/WeatherXM/wxm-ios/issues/new/choose) by using + the [**Bug Report** template](https://github.com/WeatherXM/wxm-ios/blob/main/.github/ISSUE_TEMPLATE/bug_report.md). + Using this template is mandatory. Make sure to have a **clear title** and include as many details + as possible as that information helps to resolve issues faster. + +### **Do you want to suggest a potential improvement or a new feature?** + +* **Ensure this suggestion was not already reported** by searching on GitHub + under [Issues](https://github.com/WeatherXM/wxm-ios/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement) under the + label _Enhancement_. + +* If you're unable to find that + suggestion, [create a new issue](https://github.com/WeatherXM/beta-issue-tracker/issues/new/choose) + by using the [**Feature Request** template](https://github.com/WeatherXM/beta-issue-tracker/blob/main/.github/ISSUE_TEMPLATE/feature_request.md). + Using this template is mandatory. Make sure to have a **clear title** and include as many details + as possible. + +## How to contribute? + +We are open to contributions on [current issues](https://github.com/WeatherXM/wxm-ios/issues), +if the bug/feature/improvement you would like to work on isn't documented, please open a new issue +so we can approve it before you start working on it. + +### Fix a bug, implement a new feature or conduct an optimization + +Scan through our existing [issues](https://github.com/WeatherXM/wxm-ios/issues) to find one that +interests you (or create a new one). You can narrow down the search using `labels` as filters. +See [Issue Labels](#issue-and-pull-request-labels) for more information. Please don't start working +on issues that are currently assigned to someone else or have the `in-progress` label. If you find +an issue to work on, please comment in it to get it assigned to you and you are welcome to open a PR +with a fix/implementation. + +### Pull Request + +When you're finished with the changes, create a pull request, also known as a PR. + +- Fill the "Ready for review" template so that we can review your PR. This template helps reviewers + understand your changes as well as the purpose of your pull request. +- Don't forget + to [link PR to issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) + if you are solving one. +- Enable the checkbox + to [allow maintainer edits](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork) + so the branch can be updated for a merge. + Once you submit your PR, a WeatherXM team member will review your PR. We may ask questions or + request additional information. +- We may ask for changes to be made before a PR can be merged. You can implement those changes in + your fork, then commit them to your branch in order to update the PR. +- As you update your PR and apply changes, mark each conversation + as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations). + +### Your PR is merged! + +Congratulations 🥳 The WeatherXM team thanks you! We really appreciate your effort and help! ♥️ + +Once your PR is merged, your contributions will be publicly visible on +our [iOS App](https://apps.apple.com/ca/app/weatherxm/id1629841929) on the next +release. + +## Styleguide + +We use **[Github Flow](https://githubflow.github.io/)** as our branching model and *Clean +Architecture*. + +## Additional Notes + +### Issue Labels + +This section lists the labels we use to help us track and manage issues. + +#### Type of Issue Labels + +| Label name | Description | +|-------------------|---------------------------------------------------------------------------| +| `enhancement` | New feature request or improvement | +| `bug` | Confirmed bugs or reports that are very likely to be bugs. | +| `question` | Questions more than bug reports or feature requests (e.g. how do I do X). | +| `in-progress` | A bug, feature or improvement that is currently a Work-In-Progress. | +| `needs-attention` | An issue that needs attention to be put under specific categories/labels. | +| `wontfix` | An issue that won't be worked on. | diff --git a/Configuration/Debug/ConfigDebug.xcconfig-template b/Configuration/Debug/ConfigDebug.xcconfig-template new file mode 100644 index 00000000..11fcb345 --- /dev/null +++ b/Configuration/Debug/ConfigDebug.xcconfig-template @@ -0,0 +1,19 @@ + +MBXAccessToken = {mapbox access token}; + +UserAccessTokenService = accessTokenService; + +UserRefreshTokenService = refreshTokenService; + +Account = weatherXM; + +TeamId = {The developer team id}; + + // A trick to avoid parse issues caused form double slashes of the following urls is to add "$()" after "https:/", + // eg. https:/$()/api.myapi.com/api/v1" + +ApiUrl = {The api url}; + +ClaimTokenUrl = {The DApp url for the claim flow}; + +AppStoreUrl = {The app store url}; diff --git a/Configuration/Mock/ConfigMock.xcconfig-template b/Configuration/Mock/ConfigMock.xcconfig-template new file mode 100644 index 00000000..11fcb345 --- /dev/null +++ b/Configuration/Mock/ConfigMock.xcconfig-template @@ -0,0 +1,19 @@ + +MBXAccessToken = {mapbox access token}; + +UserAccessTokenService = accessTokenService; + +UserRefreshTokenService = refreshTokenService; + +Account = weatherXM; + +TeamId = {The developer team id}; + + // A trick to avoid parse issues caused form double slashes of the following urls is to add "$()" after "https:/", + // eg. https:/$()/api.myapi.com/api/v1" + +ApiUrl = {The api url}; + +ClaimTokenUrl = {The DApp url for the claim flow}; + +AppStoreUrl = {The app store url}; diff --git a/Configuration/Production/Config.xcconfig-template b/Configuration/Production/Config.xcconfig-template new file mode 100644 index 00000000..11fcb345 --- /dev/null +++ b/Configuration/Production/Config.xcconfig-template @@ -0,0 +1,19 @@ + +MBXAccessToken = {mapbox access token}; + +UserAccessTokenService = accessTokenService; + +UserRefreshTokenService = refreshTokenService; + +Account = weatherXM; + +TeamId = {The developer team id}; + + // A trick to avoid parse issues caused form double slashes of the following urls is to add "$()" after "https:/", + // eg. https:/$()/api.myapi.com/api/v1" + +ApiUrl = {The api url}; + +ClaimTokenUrl = {The DApp url for the claim flow}; + +AppStoreUrl = {The app store url}; diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/PresentationLayer/Constants/AnimationsEnums.swift b/PresentationLayer/Constants/AnimationsEnums.swift new file mode 100644 index 00000000..41f3e581 --- /dev/null +++ b/PresentationLayer/Constants/AnimationsEnums.swift @@ -0,0 +1,79 @@ +// +// AnimationsEnums.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 3/6/22. +// + +import Foundation + +enum AnimationsEnums: String { + case notAvailable = "not-available" + case clearDay = "clear-day" + case clearNight = "clear-night" + case partlyCloudyDay = "partly-cloudy-day" + case partlyCloudyNight = "partly-cloudy-night" + case overcastDay = "overcast-day" + case overcastNight = "overcast-night" + case drizzle + case rain + case thunderstormsRain = "thunderstorms-rain" + case snow + case sleet + case wind + case fog + case cloudy + case success + case fail + case loading + case loader + case emptyDevices + case emptyGeneric + + var animationString: String { + switch self { + case .emptyDevices: + return "anim_empty_devices" + case .notAvailable: + return "anim_not_available" + case .clearDay: + return "anim_weather_clear_day" + case .clearNight: + return "anim_weather_clear_night" + case .partlyCloudyDay: + return "anim_weather_partly_cloudy_day" + case .partlyCloudyNight: + return "anim_weather_partly_cloudy_night" + case .overcastDay: + return "anim_weather_overcast_day" + case .overcastNight: + return "anim_weather_overcast_night" + case .drizzle: + return "anim_weather_drizzle" + case .rain: + return "anim_weather_rain" + case .thunderstormsRain: + return "anim_weather_thunderstorms_rain" + case .snow: + return "anim_weather_snow" + case .sleet: + return "anim_weather_sleet" + case .wind: + return "anim_weather_wind" + case .fog: + return "anim_weather_fog" + case .cloudy: + return "anim_weather_cloudy" + case .success: + return "anim_success" + case .fail: + return "anim_error" + case .loading: + return "anim_loading" + case .loader: + return "anim_loader" + case .emptyGeneric: + return "anim_empty_generic" + } + } +} diff --git a/PresentationLayer/Constants/AssetEnum.swift b/PresentationLayer/Constants/AssetEnum.swift new file mode 100644 index 00000000..f5702be0 --- /dev/null +++ b/PresentationLayer/Constants/AssetEnum.swift @@ -0,0 +1,110 @@ +// +// AssetEnum.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 10/5/22. +// + +import SwiftUI + +public enum AssetEnum: String { + case eye + case email + case errorExclamationIcon + case lock + case qrCode + case qrCodeBlue + case user + case mapStaticImage + case weatherXMLogo + case weatherXMLogoSmall + case settings + case globe + case home + case badge + case backArrow + case wxmDevice + case keyboardArrowLeft + case keyboardArrowRight + case panoramaFishEye + case panoramaFishEyeFilled + case locationMap + case claimDeviceFailure + case claimWiFi + case claimHelium + case claimBluetoothButton + case bluetoothGray + case check + case checkBoxOutlined + case sunnyDay + case snowyDay + case rainyDay + case partlyCloudyDay + case rainyThunderDay + case plus + case hexagon + case hexagonBigger + case accountBalanceWallet + case settingsGear + case redAlert + case markerDefault + case info + case infoIcon + case share + case weatherXMLogoLaunchScreen + case temperature + case precipitation + case wind + case humidity + case pressure + case umbrella + case uvindex + case humidityUmbrella + case closeIcon + case closeButton + case stationResetSchematic + case circleCheckmark + case openExternalIcon = "open_external_icon" + case toggleXMark + case toggleCheckmark + case search + case wonderFace + case chevronDown + case detectLocation + case m5Video + case wifi + case humidityIcon = "humidity_icon" + case precipitationIcon = "precipitation_icon" + case windIcon = "wind_icon" + case offlineIcon = "offline_icon" + case warningIcon = "warning_icon" + case errorIcon = "error_icon" + case noDataIcon = "no_data_icon" + case helium + case updateFirmwareIcon = "update_firmware_icon" + case pin + case shareIcon = "share_icon" + case dewPointIcon = "dew_point_icon" + case solarIcon = "solar_icon" + case pressureIcon = "pressure_icon" + case downArrow = "downArrow" + case umbrellaIcon = "umbrella_icon" + case umbrellaIconSmall = "umbrella_icon_small" + case rainIconSmall = "rain_icon_small" + case solarIconSmall = "solar_icon_small" + case pressureIconSmall = "pressure_icon_small" + case humidityIconSmall = "humidity_icon_small" + case windDirIconSmall = "wind_dir_icon_small" + case temperatureIcon = "temperature_icon" + case editIcon = "edit_icon" + case analyticsIcon = "analytics_icon" + case accountFilledIcon = "account_filled_icon" + case noWifiIcon = "no_wifi_icon" + case networkStatsIcon = "network_stats_icon" + case plainInfoIcon = "plain_info_icon" + case clearIcon = "clear_icon" + case xmSearchLogo = "xm_search_logo" + case lockedIcon = "locked_icon" + case radioButton = "radio_button_icon" + case radioButtonActive = "radio_button_active_icon" +} diff --git a/PresentationLayer/Constants/ColorEnum.swift b/PresentationLayer/Constants/ColorEnum.swift new file mode 100644 index 00000000..6c3b1d8d --- /dev/null +++ b/PresentationLayer/Constants/ColorEnum.swift @@ -0,0 +1,51 @@ +// +// ColorEnum.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 10/5/22. +// + +import Foundation + +public enum ColorEnum: String { + case bg + case clear + case error + case success + case reward_score_average + case reward_score_high + case reward_score_low + case reward_score_unknown + case reward_score_very_high + case reward_score_very_low + case darkestBlue + case blueTint + case successTint + case errorTint + case primary + case midGrey + case warningTint + case warning + case darkGrey + case text + case top + case lightestBlue + case layer2 + case layer1 + case crypto + case accent + case chartSecondaryLine + case toastInfoBg + case toastErrorBg + case toastErrorText + case netStatsFabColor + case netStatsFabTextColor + case chartPrimary + case chartSecondary + case stationChipOfflineStateBg + case stationChipOfflineStateText + case stationChipOnlineStateBg + case stationChipOnlineStateText + case favoriteHeart + case mapPin +} diff --git a/PresentationLayer/Constants/CoordinatesEnum.swift b/PresentationLayer/Constants/CoordinatesEnum.swift new file mode 100644 index 00000000..7898a2bc --- /dev/null +++ b/PresentationLayer/Constants/CoordinatesEnum.swift @@ -0,0 +1,19 @@ +// +// CoordinatesEnum.swift +// PresentationLayer +// +// Created by Hristos Condrea on 3/6/22. +// + +import CoreLocation +import Foundation +enum CoordinatesEnum { + case claimDeviceMapBox + + var imageName: String { + switch self { + case .claimDeviceMapBox: + return "red_pin" + } + } +} diff --git a/PresentationLayer/Constants/Dimensions.swift b/PresentationLayer/Constants/Dimensions.swift new file mode 100644 index 00000000..13e79de0 --- /dev/null +++ b/PresentationLayer/Constants/Dimensions.swift @@ -0,0 +1,93 @@ +// +// Dimensions.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 25/4/23. +// + +import Foundation + +enum Dimension { + case minimumPadding + case smallSidePadding + case smallToMediumSidePadding + case mediumSidePadding + case defaultSidePadding + case mediumToLargeSidePadding + case largeSidePadding + case XLSidePadding + + case minimumSpacing + case smallSpacing + case smallToMediumSpacing + case mediumSpacing + case defaultSpacing + case largeSpacing + case XLSpacing + + case lightCornerRadius + case cardCornerRadius + case buttonCornerRadius + case tabBarCornerRadius + case fabButtonsDimension + + case weatherIconMinDimension + case weatherIconLargeDimension + case weatherIconDefaultDimension +} + +extension Dimension { + var value: CGFloat { + switch self { + case .minimumPadding: + return 4.0 + case .smallSidePadding: + return 8.0 + case .smallToMediumSidePadding: + return 12.0 + case .mediumSidePadding: + return 16.0 + case .defaultSidePadding: + return 20.0 + case .mediumToLargeSidePadding: + return 24.0 + case .largeSidePadding: + return 30.0 + case .XLSidePadding: + return 40.0 + + case .minimumSpacing: + return 4.0 + case .smallSpacing: + return 8.0 + case .smallToMediumSpacing: + return 12.0 + case .mediumSpacing: + return 16.0 + case .defaultSpacing: + return 20.0 + case .largeSpacing: + return 30.0 + case .XLSpacing: + return 40.0 + + case .lightCornerRadius: + return 4.0 + case .cardCornerRadius: + return 20.0 + case .buttonCornerRadius: + return 10.0 + case .tabBarCornerRadius: + return 60.0 + case .fabButtonsDimension: + return 60.0 + + case .weatherIconMinDimension: + return 50.0 + case .weatherIconLargeDimension: + return 60.0 + case .weatherIconDefaultDimension: + return 70.0 + } + } +} diff --git a/PresentationLayer/Constants/FailAPICodeEnum.swift b/PresentationLayer/Constants/FailAPICodeEnum.swift new file mode 100644 index 00000000..ad41f0cb --- /dev/null +++ b/PresentationLayer/Constants/FailAPICodeEnum.swift @@ -0,0 +1,33 @@ +// +// FailAPICodeEnum.swift +// PresentationLayer +// +// Created by Hristos Condrea on 6/6/22. +// + +import Foundation + +enum FailAPICodeEnum: String { + case invalidUsername = "InvalidUsername" + case invalidPassword = "InvalidPassword" + case invalidCredentials = "InvalidCredentials" + case userAlreadyExists = "UserAlreadyExists" + case invalidAccessToken = "InvalidAccessToken" + case deviceNotFound = "DeviceNotFound" + case invalidWalletAddress = "InvalidWalletAddress" + case invalidFriendlyName = "InvalidFriendlyName" + case invalidFromDate = "InvalidFromDate" + case invalidToDate = "InvalidToDate" + case invalidTimezone = "InvalidTimezone" + case invalidClaimId = "InvalidClaimId" + case invalidClaimLocation = "InvalidClaimLocation" + case deviceAlreadyClaimed = "DeviceAlreadyClaimed" + case deviceClaiming = "DeviceClaiming" + case unauthorized = "Unauthorized" + case userNotFound = "UserNotFound" + case forbidden = "Forbidden" + case validation = "Validation" + case notFound = "NotFound" + case walletAddressNotFound = "WalletAddressNotFound" + case unsupportedApplicationVersion = "UnsupportedApplicationVersion" +} diff --git a/PresentationLayer/Constants/FontSizeEnum.swift b/PresentationLayer/Constants/FontSizeEnum.swift new file mode 100644 index 00000000..149a0f8c --- /dev/null +++ b/PresentationLayer/Constants/FontSizeEnum.swift @@ -0,0 +1,52 @@ +// +// FontSizeEnum.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 11/5/22. +// + +import SwiftUI + +enum FontSizeEnum { + case caption + case smallFontSize + case normalFontSize + case normalMediumFontSize + case mediumFontSize + case largeFontSize + case smallTitleFontSize + case titleFontSize + case largeTitleFontSize + case littleCaption + case XLTitleFontSize + case XXLTitleFontSize + + var sizeValue: CGFloat { + switch self { + case .littleCaption: + return 11 + case .caption: + return 12 + case .smallFontSize: + return 13 + case .normalFontSize: + return 14 + case .normalMediumFontSize: + return 15 + case .mediumFontSize: + return 16 + case .largeFontSize: + return 18 + case .smallTitleFontSize: + return 20 + case .titleFontSize: + return 22 + case .largeTitleFontSize: + return 24 + case .XLTitleFontSize: + return 28 + case .XXLTitleFontSize: + return 50.0 + } + } +} diff --git a/PresentationLayer/Constants/FontsEnum.swift b/PresentationLayer/Constants/FontsEnum.swift new file mode 100644 index 00000000..71fe254d --- /dev/null +++ b/PresentationLayer/Constants/FontsEnum.swift @@ -0,0 +1,47 @@ +// +// FontsEnum.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 22/6/23. +// + +import SwiftUI + +enum FontAwesome: String { + case FAPro = "FontAwesome6Pro-Regular" + case FAProLight = "FontAwesome6Pro-Light" + case FAProSolid = "FontAwesome6Pro-Solid" + case FAProThin = "FontAwesome6Pro-Thin" + case FADuotone = "FontAwesome6Duotone-Solid" + case FASharp = "FontAwesome6Sharp-Regular" + case FASharpSolid = "FontAwesome6Sharp-Solid" + case FABrands = "FontAwesome6Brands-Regular" +} + +extension Font { + static func fontAwesome(font: FontAwesome, size: CGFloat) -> Font { + return .custom(font.rawValue, size: size) + } +} + +enum FontIcon: String { + case locationDot = "location-dot" + case gear + case threeDots = "ellipsis-vertical" + case infoCircle = "info-circle" + case externalLink = "arrow-up-right-from-square" + case home = "home" + case hexagon + case share = "share-nodes" + case heart + case lock + case calendar + case sliders + case pointUp = "hand-back-point-up" + case badgeCheck = "badge-check" + case triangleExclamation = "triangle-exclamation" + case cog + case coins + case wallet + case cart = "cart-shopping" +} diff --git a/PresentationLayer/Constants/ShadowEnum.swift b/PresentationLayer/Constants/ShadowEnum.swift new file mode 100644 index 00000000..852c027e --- /dev/null +++ b/PresentationLayer/Constants/ShadowEnum.swift @@ -0,0 +1,50 @@ +// +// ShadowEnum.swift +// PresentationLayer +// +// Created by Hristos Condrea on 13/5/22. +// + +import Foundation + +enum ShadowEnum { + case tabBar + case baseButton + + case deviceIcon + case addButton + case stationCard + + var radius: Double { + switch self { + case .deviceIcon: + return 1 + case .tabBar: + return 5 + case .baseButton, .addButton: + return 2 + case .stationCard: + return 4.0 + } + } + + var xVal: Double { + switch self { + case .tabBar, .baseButton, .addButton, .stationCard: + return 0 + case .deviceIcon: + return 1 + } + } + + var yVal: Double { + switch self { + case .baseButton, .deviceIcon: + return 1 + case .tabBar, .stationCard: + return 4 + case .addButton: + return 5 + } + } +} diff --git a/PresentationLayer/Constants/Strings/DisplayedLinks.swift b/PresentationLayer/Constants/Strings/DisplayedLinks.swift new file mode 100644 index 00000000..4827380c --- /dev/null +++ b/PresentationLayer/Constants/Strings/DisplayedLinks.swift @@ -0,0 +1,93 @@ +// +// DisplayedLinks.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 2/6/22. +// + +import Alamofire +import Foundation +import Toolkit + +enum DisplayedLinks { + case emptyValue + case termsLink + case contactLink + case weatherXMWebsiteLink + case readMoreAboutWalletsLink + case arbitrumAddressWebsiteFormat + case documentationLink + case m5Troubleshooting + case heliumTroubleshooting + case heliumRegionFrequencies + case shopLink + case tokenomics + case shareDevice + case rewardMechanism + case polAlgorithm + case qodAlgorithm + case troubleshooting + case cellCapacity + case claimToken + case appstore + + var linkURL: String { + switch self { + case .emptyValue: + return "" + case .termsLink: + return "https://weatherxm.com/mobile/terms/" + case .contactLink: + return "https://weatherxm.com/contact/" + case .weatherXMWebsiteLink: + return "https://weatherxm.com/" + case .readMoreAboutWalletsLink: + return "https://docs.weatherxm.com/wallet/add-edit-wallet-address#how-to-create-wallet-on-metamask" + case .arbitrumAddressWebsiteFormat: + return "https://sepolia.arbiscan.io/address/%@#tokentxns" + case .documentationLink: + return "https://docs.weatherxm.com/" + case .m5Troubleshooting: + return "https://docs.weatherxm.com/wifi-m5-bundle/m5-troubleshooting" + case .heliumTroubleshooting: + return "https://docs.weatherxm.com/helium-bundle/helium-troubleshooting" + case .heliumRegionFrequencies: + return "https://docs.helium.com/iot/lorawan-region-plans" + case .shopLink: + return "https://shop.weatherxm.com" + case .tokenomics: + return "https://docs.weatherxm.com/tokenomics" + case .shareDevice: + return "https://explorer.weatherxm.com/stations/" + case .rewardMechanism: + return "https://docs.weatherxm.com/reward-mechanism" + case .polAlgorithm: + return "https://docs.weatherxm.com/project/proof-of-location" + case .qodAlgorithm: + return "https://docs.weatherxm.com/project/quality-of-data" + case .troubleshooting: + return "https://docs.weatherxm.com/faqs#troubleshooting" + case .cellCapacity: + return "https://docs.weatherxm.com/project/cell-capacity" + case .claimToken: + return Bundle.main.getConfiguration(for: .claimTokenUrl) ?? "" + case .appstore: + return Bundle.main.getConfiguration(for: .appStoreUrl) ?? "" + } + } +} + +extension DisplayedLinks { + static var networkAddressWebsiteFormat: Self { + .arbitrumAddressWebsiteFormat + } +} + +enum DisplayLinkParams: String { + case theme + case amount + case wallet + case network + case redirectUrl = "redirect_url" + case claimedAmount = "claimed_amount" +} diff --git a/PresentationLayer/Constants/Strings/StringConstants.swift b/PresentationLayer/Constants/Strings/StringConstants.swift new file mode 100644 index 00000000..fa886454 --- /dev/null +++ b/PresentationLayer/Constants/Strings/StringConstants.swift @@ -0,0 +1,13 @@ +// +// StringConstants.swift +// PresentationLayer +// +// Created by Hristos Condrea on 12/5/22. +// + +import Foundation +enum StringConstants { + static let plusSign = "+" + static let wxm = "WXM" + static let wxmCurrency = "$WXM" +} diff --git a/PresentationLayer/Constants/Strings/SuccessFailEnum.swift b/PresentationLayer/Constants/Strings/SuccessFailEnum.swift new file mode 100644 index 00000000..18024dfa --- /dev/null +++ b/PresentationLayer/Constants/Strings/SuccessFailEnum.swift @@ -0,0 +1,107 @@ +// +// SuccessFailEnum.swift +// PresentationLayer +// +// Created by Hristos Condrea on 8/6/22. +// + +import Foundation + +enum SuccessFailEnum: CustomStringConvertible { + case settings + case register + case weatherStations + case claimDeviceFlow + case otaFlow + case resetPassword + case noView + case noTransactions + case rebootStation + case changeFrequency + case stationOffline + case explorerDeviceList + case explorerDeviceDetail + case networkStats + case myWallet + case observations + case stationForecast + case stationRewards + case stationRewardsIssue + case deviceInfo + case history + case profile + case deleteAccount + + var description: String { + switch self { + case .settings: + return "" + case .register: + return Constants.noActivationEmailTitle + case .weatherStations: + return "" + case .claimDeviceFlow: + return Constants.cannotClaimDeviceTitleEmail + case .otaFlow: + return Constants.cannotCompleteOTATitleEmail + case .resetPassword: + return "" + case .noView: + return "" + case .noTransactions: + return "" + case .rebootStation: + return Constants.cannotRebootTitleEmail + case .changeFrequency: + return Constants.cannotChangeFrequencyTitleEmail + case .stationOffline: + return Constants.weatherStationOffline + case .explorerDeviceList: + return Constants.explorerDeviceList + case .explorerDeviceDetail: + return Constants.explorerDeviceDetails + case .networkStats: + return Constants.networkStats + case .myWallet: + return Constants.myWallet + case .observations: + return Constants.observations + case .stationForecast: + return Constants.stationForecast + case .stationRewards: + return Constants.stationRewards + case .deviceInfo: + return Constants.deviceInfo + case .history: + return Constants.history + case .stationRewardsIssue: + return Constants.stationRewardsIssue + case .profile: + return Constants.profile + case .deleteAccount: + return "" + } + } +} + +private extension SuccessFailEnum { + enum Constants { + static let weatherStationOffline = "Weather Station Offline" + static let cannotClaimDeviceTitleEmail = "Cannot claim device" + static let cannotCompleteOTATitleEmail = "Cannot complete OTA update" + static let cannotRebootTitleEmail = "Cannot reboot station" + static let cannotChangeFrequencyTitleEmail = "Cannot chage station frequency" + static let explorerDeviceList = "Explorer Device List" + static let explorerDeviceDetails = "Explorer Device Details" + static let networkStats = "Network Stats" + static let myWallet = "My Wallet" + static let observations = "Observations" + static let stationForecast = "Station Forecast" + static let stationRewards = "Station Rewards" + static let deviceInfo = "Device Info" + static let history = "History" + static let noActivationEmailTitle = "No activation email" + static let stationRewardsIssue = "Station Rewards Issue" + static let profile = "Profile" + } +} diff --git a/PresentationLayer/Constants/Strings/TextFieldError.swift b/PresentationLayer/Constants/Strings/TextFieldError.swift new file mode 100644 index 00000000..a62db92e --- /dev/null +++ b/PresentationLayer/Constants/Strings/TextFieldError.swift @@ -0,0 +1,27 @@ +// +// TextFieldError.swift +// PresentationLayer +// +// Created by Hristos Condrea on 2/6/22. +// + +import Foundation + +enum TextFieldError: String { + case emptyField, invalidSerialNumber, invalidNewAddress, invalidPassword, invalidDeviceEUIKey + + var description: String { + switch self { + case .emptyField: + return "Cannot be empty" + case .invalidSerialNumber: + return "Invalid serial number" + case .invalidNewAddress: + return "Invalid WXM Address" + case .invalidPassword: + return "Wrong password!" + case .invalidDeviceEUIKey: + return LocalizableString.ClaimDevice.heliumInvalidEUIKey.localized + } + } +} diff --git a/PresentationLayer/Constants/ThemeEnum.swift b/PresentationLayer/Constants/ThemeEnum.swift new file mode 100644 index 00000000..130db58e --- /dev/null +++ b/PresentationLayer/Constants/ThemeEnum.swift @@ -0,0 +1,81 @@ +// +// ThemeEnum.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 4/4/23. +// + +import Foundation +import SwiftUI +import Toolkit + +enum Theme: String, CaseIterable, CustomStringConvertible { + case light + case dark + case system + + var description: String { + switch self { + case .light: + return LocalizableString.light.localized + case .dark: + return LocalizableString.dark.localized + case .system: + return LocalizableString.system.localized + } + } + + var colorScheme: ColorScheme? { + switch self { + case .light: + return .light + case .dark: + return .dark + case .system: + return nil + } + } + + var analyticsValue: ParameterValue { + switch self { + case .light: + return .light + case .dark: + return .dark + case .system: + return .system + } + } + + var interfaceStyle: UIUserInterfaceStyle { + switch self { + case .light: + return .light + case .dark: + return .dark + case .system: + return .unspecified + } + } + + init?(description: String) { + guard let theme = Self.allCases.first(where: { $0.description == description }) else { + return nil + } + + self = theme + } + + init?(interfaceStyle: UIUserInterfaceStyle) { + switch interfaceStyle { + case .unspecified: + return nil + case .light: + self = .light + case .dark: + self = .dark + @unknown default: + return nil + } + } +} diff --git a/PresentationLayer/Constants/WeatherFields.swift b/PresentationLayer/Constants/WeatherFields.swift new file mode 100644 index 00000000..2ddf1eab --- /dev/null +++ b/PresentationLayer/Constants/WeatherFields.swift @@ -0,0 +1,317 @@ +// +// WeatherFields.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 16/3/23. +// + +import Foundation +import SwiftUI +import DomainLayer +import Toolkit + +extension WeatherField: CustomStringConvertible { + + static var mainFields: [WeatherField] { + [.humidity, .wind, .precipitationRate] + } + + static var secondaryFields: [WeatherField] { + [.windGust, .dailyPrecipitation, .pressure, .dewPoint, .solarRadiation, .uv] + } + + static var hourlyFields: [WeatherField] { + [.precipitationProbability, .dailyPrecipitation, .wind, .humidity, .pressure, .uv] + } + + public var description: String { + switch self { + case .temperature: + return LocalizableString.temperature.localized + case .feelsLike: + return LocalizableString.feelsLike.localized + case .humidity: + return LocalizableString.humidity.localized + case .wind: + return LocalizableString.wind.localized + case .windDirection: + return LocalizableString.windDirection.localized + case .precipitationRate: + return LocalizableString.precipitationRate.localized + case .precipitationProbability: + return LocalizableString.precipitationProbability.localized + case .dailyPrecipitation: + return LocalizableString.dailyPrecipitation.localized + case .windGust: + return LocalizableString.windGust.localized + case .pressure: + return LocalizableString.pressureAbs.localized + case .solarRadiation: + return LocalizableString.solarRadiation.localized + case .illuminance: + return LocalizableString.illuminance.localized + case .dewPoint: + return LocalizableString.dewPoint.localized + case .uv: + return LocalizableString.uv.localized + } + } + + var displayTitle: String { + switch self { + case .temperature: + return LocalizableString.temperature.localized + case .feelsLike: + return LocalizableString.feelsLike.localized + case .humidity: + return LocalizableString.humidity.localized + case .wind: + return LocalizableString.windSpeed.localized + case .windDirection: + return LocalizableString.windDirection.localized + case .precipitationRate: + return LocalizableString.precipRate.localized + case .precipitationProbability: + return LocalizableString.precipitationProbability.localized + case .dailyPrecipitation: + return LocalizableString.dailyPrecip.localized + case .windGust: + return LocalizableString.windGust.localized + case .pressure: + return LocalizableString.pressure.localized + case .solarRadiation: + return LocalizableString.solarRadiation.localized + case .illuminance: + return LocalizableString.illuminance.localized + case .dewPoint: + return LocalizableString.dewPoint.localized + case .uv: + return LocalizableString.uv.localized + } + } + + var graphHighlightTitle: String { + switch self { + case .temperature: + return LocalizableString.temperature.localized + case .feelsLike: + return LocalizableString.feelsLike.localized + case .humidity: + return LocalizableString.humidity.localized + case .wind: + return LocalizableString.speed.localized + case .windDirection: + return LocalizableString.windDirection.localized + case .precipitationRate: + return LocalizableString.rate.localized + case .precipitationProbability: + return LocalizableString.precipitationProbability.localized + case .dailyPrecipitation: + return LocalizableString.daily.localized + case .windGust: + return LocalizableString.gust.localized + case .pressure: + return LocalizableString.pressure.localized + case .solarRadiation: + return LocalizableString.solarRadiation.localized + case .illuminance: + return LocalizableString.illuminance.localized + case .dewPoint: + return LocalizableString.dewPoint.localized + case .uv: + return LocalizableString.uvIndex.localized + } + } + + var icon: AssetEnum { + switch self { + case .temperature: + return .temperatureIcon + case .feelsLike: + return .temperatureIcon + case .humidity: + return .humidityIcon + case .wind: + return .windIcon + case .windDirection: + return .windIcon + case .precipitationRate: + return .precipitationIcon + case .windGust: + return .windIcon + case .pressure: + return .pressureIcon + case .solarRadiation: + return .solarIcon + case .illuminance: + return .solarIcon + case .dewPoint: + return .dewPointIcon + case .uv: + return .solarIcon + case .precipitationProbability: + return .umbrellaIcon + case .dailyPrecipitation: + return .precipitationIcon + } + } + + var shouldHaveSpaceWithUnit: Bool { + let fieldsWithoutSpace: Set = [.temperature, + .feelsLike, + .humidity, + .precipitationProbability, + .dewPoint] + return !fieldsWithoutSpace.contains(self) + } + + func hourlyIcon() -> AssetEnum { + switch self { + case .temperature: + return .temperatureIcon + case .feelsLike: + return .temperatureIcon + case .humidity: + return .humidityIconSmall + case .wind: + return .windDirIconSmall + case .windDirection: + return .windDirIconSmall + case .precipitationRate: + return .rainIconSmall + case .precipitationProbability: + return .umbrellaIconSmall + case .dailyPrecipitation: + return .rainIconSmall + case .windGust: + return .windDirIconSmall + case .pressure: + return .pressureIconSmall + case .solarRadiation: + return .solarIconSmall + case .illuminance: + return .solarIconSmall + case .dewPoint: + return .humidityIconSmall + case .uv: + return .solarIconSmall + } + } + + func iconRotation(from weather: CurrentWeather) -> Double { + switch self { + case .wind, .windGust: + guard let direction = weather.windDirection else { + return 0.0 + } + + let index = UnitsConverter().getIndexOfCardinal(value: direction) + return 180.0 + Double(index) * 22.5 + default: + return 0.0 + } + } +} + +extension WeatherField { + /// Get `WeatherValueLiterals` for each field + /// - Parameters: + /// - weather: The weather object to extract info + /// - unitsManager: The manager which holds the selected units + /// - includeDirection: In case of wind fields (`wind` and `windGust`), include or not direction in `WeatherValueLiterals`'s `unit` field + /// - isForHourlyForecast: There is an inconsistency in the API and the precipitation accumulated value is returned in precipitation field ONLY in hourly forecast. + /// Pass true when the field will be rendered in hourly forecast. + /// - Returns: The `WeatherValueLiterals` for each field + func weatherLiterals(from weather: CurrentWeather?, + unitsManager: WeatherUnitsManager, + includeDirection: Bool = true, + isForHourlyForecast: Bool = false) -> WeatherValueLiterals? { + guard let weather else { + return nil + } + + switch self { + case .temperature: + return createWeatherLiterals(from: weather.temperature, unitsManager: unitsManager) + case .feelsLike: + return createWeatherLiterals(from: weather.feelsLike, unitsManager: unitsManager) + case .humidity: + return createWeatherLiterals(from: Double(weather.humidity ?? 0), unitsManager: unitsManager) + case .wind: + return createWeatherLiterals(from: weather.windSpeed, + addditonalInfo: weather.windDirection, + unitsManager: unitsManager, + includeDirection: includeDirection, + isForHourlyForecast: isForHourlyForecast) + case .windDirection: + return nil + case .precipitationRate: + return createWeatherLiterals(from: weather.precipitation, unitsManager: unitsManager) + case .dailyPrecipitation: + /// In case of hourly weather forecast the `precipitationAccumulated` is received in `precipitation` property + /// So ONLY for this case we generate the precipitation accumulated string from different property + if isForHourlyForecast { + return createWeatherLiterals(from: weather.precipitation, unitsManager: unitsManager) + } + return createWeatherLiterals(from: weather.precipitationAccumulated, unitsManager: unitsManager) + case .windGust: + return createWeatherLiterals(from: weather.windGust, + addditonalInfo: weather.windDirection, + unitsManager: unitsManager, + includeDirection: includeDirection) + case .pressure: + return createWeatherLiterals(from: weather.pressure, unitsManager: unitsManager) + case .solarRadiation: + return createWeatherLiterals(from: weather.solarIrradiance, unitsManager: unitsManager) + case .illuminance: + return nil + case .dewPoint: + return createWeatherLiterals(from: weather.dewPoint, unitsManager: unitsManager) + case .uv: + return createWeatherLiterals(from: Double(weather.uvIndex ?? 0), unitsManager: unitsManager) + case .precipitationProbability: + return createWeatherLiterals(from: weather.precipitationProbability, unitsManager: unitsManager) + } + } + + func createWeatherLiterals(from value: Double?, + addditonalInfo: Any? = nil, + unitsManager: WeatherUnitsManager, + includeDirection: Bool = true, + isForHourlyForecast: Bool = false, + shouldConvertUnits: Bool = true) -> WeatherValueLiterals? { + guard let value, !value.isNaN else { + return nil + } + + let formatter = WeatherFormatter(shouldConvert: shouldConvertUnits) + switch self { + case .temperature, .feelsLike, .dewPoint: + return formatter.getTemperatureLiterals(temperature: value, unit: unitsManager.temperatureUnit) + case .humidity: + return formatter.getHumidityLiterals(value: Int(value)) + case .wind, .windGust: + return formatter.getWindValueLiterals(value: value, + windDirection: addditonalInfo as? Int, + speedUnit: unitsManager.windSpeedUnit, + directionUnit: unitsManager.windDirectionUnit, + includeDirection: includeDirection) + case .windDirection: + return nil + case .precipitationRate: + return formatter.getPrecipitationLiterals(value: value, unit: unitsManager.precipitationUnit) + case .precipitationProbability: + return formatter.getPrecipitationProbabilityLiterals(value: value) + case .dailyPrecipitation: + return formatter.getPrecipitationAccumulatedLiterals(from: value, unit: unitsManager.precipitationUnit) + case .pressure: + return formatter.getPressureLiterals(pressure: value, unit: unitsManager.pressureUnit) + case .solarRadiation: + return formatter.getSolarRadiationLiterals(value: value) + case .illuminance: + return nil + case .uv: + return formatter.getUVLiterals(value: Int(value)) + } + } +} diff --git a/PresentationLayer/ContentView.swift b/PresentationLayer/ContentView.swift new file mode 100644 index 00000000..3bf32244 --- /dev/null +++ b/PresentationLayer/ContentView.swift @@ -0,0 +1,25 @@ +// +// ContentView.swift +// PresentationLayer +// +// Created by Hristos Condrea on 6/5/22. +// + +import SwiftUI + +public struct ContentView: View { + @State var isSignInViewEnabled = true + @State var selectedTab: TabSelectionEnum = .homeTab + + public init() {} + + public var body: some View { + WeatherStationsHomeView(selectedTab: $selectedTab) + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/PresentationLayer/Extensions/Common/Color+.swift b/PresentationLayer/Extensions/Common/Color+.swift new file mode 100644 index 00000000..9f10696a --- /dev/null +++ b/PresentationLayer/Extensions/Common/Color+.swift @@ -0,0 +1,15 @@ +// +// Color+.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 10/5/22. +// + +import Foundation +import SwiftUI + +extension Color { + init(colorEnum: ColorEnum) { + self.init(colorEnum.rawValue) + } +} diff --git a/PresentationLayer/Extensions/Common/Functions+Units.swift b/PresentationLayer/Extensions/Common/Functions+Units.swift new file mode 100644 index 00000000..947f5ddb --- /dev/null +++ b/PresentationLayer/Extensions/Common/Functions+Units.swift @@ -0,0 +1,78 @@ +// +// Functions+Units.swift +// PresentationLayer +// +// Created by Lampros Zouloumis on 6/9/22. +// + +import Foundation + +var cardinalValues: [String] { + [ + "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW" + ] +} + +func celsiusToFahrenheit(celsius: Double) -> Double { + return celsius * 9 / 5 + 32 +} + +func millimetersToInches(mm: Double) -> Double { + return mm / 25.4 +} + +func hpaToInHg(hpa: Double) -> Double { + return hpa * 0.02953 +} + +func msToKmh(ms: Double) -> Double { + return ms * 3.6 +} + +func msToMph(ms: Double) -> Double { + return ms * 2.237 +} + +func msToKnots(ms: Double) -> Double { + return ms * 1.944 +} + +func msToBeaufort(ms: Double) -> Int { + if ms < 0.2 { + return 0 + } else if ms < 1.5 { + return 1 + } else if ms < 3.3 { + return 2 + } else if ms < 5.4 { + return 3 + } else if ms < 7.9 { + return 4 + } else if ms < 10.7 { + return 5 + } else if ms < 13.8 { + return 6 + } else if ms < 17.1 { + return 7 + } else if ms < 20.7 { + return 8 + } else if ms < 24.4 { + return 9 + } else if ms < 28.4 { + return 10 + } else if ms < 32.6 { + return 11 + } else { + return 12 + } +} + +func degreesToCardinal(value: Int) -> String { + cardinalValues[safe: getIndexOfCardinal(value: value)] ?? "-" +} + +func getIndexOfCardinal(value: Int) -> Int { + let normalized = Int(floor((Double(value) / 22.5) + 0.5)) + return normalized % 16 +} diff --git a/PresentationLayer/Extensions/Common/Image+.swift b/PresentationLayer/Extensions/Common/Image+.swift new file mode 100644 index 00000000..be62c4a1 --- /dev/null +++ b/PresentationLayer/Extensions/Common/Image+.swift @@ -0,0 +1,21 @@ +// +// Image+.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 10/5/22. +// + +import Foundation +import SwiftUI + +extension Image { + init(asset: AssetEnum) { + self.init(asset.rawValue) + } +} + +extension UIImage { + convenience init?(named asset: AssetEnum) { + self.init(named: asset.rawValue) + } +} diff --git a/PresentationLayer/Extensions/Common/String+Common.swift b/PresentationLayer/Extensions/Common/String+Common.swift new file mode 100644 index 00000000..66ccbf8a --- /dev/null +++ b/PresentationLayer/Extensions/Common/String+Common.swift @@ -0,0 +1,41 @@ +// +// String+Common.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 13/10/23. +// + +import Foundation + +extension String { + var attributedMarkdown: AttributedString? { + let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + return try? AttributedString(markdown: self, options: options) + } + + func trimWhiteSpaces() -> String { + return trimmingCharacters(in: .whitespaces) + } + + func lastActiveTime() -> String { + guard case let lastActiveDate = timestampToDate(), + lastActiveDate != .distantPast + else { + return "" + } + var relativeDate: String + let currentDate = Date() + let minutes = currentDate.minutes(from: lastActiveDate) + + let relativeDateFormatter = RelativeDateTimeFormatter() + relativeDateFormatter.locale = Locale(identifier: "en_US_POSIX") + relativeDateFormatter.unitsStyle = .full + + if minutes <= 1 { + relativeDate = LocalizableString.justNow.localized + } else { + relativeDate = relativeDateFormatter.localizedString(for: lastActiveDate, relativeTo: currentDate) + } + return relativeDate + } +} diff --git a/PresentationLayer/Extensions/Date+.swift b/PresentationLayer/Extensions/Date+.swift new file mode 100644 index 00000000..69bac3b8 --- /dev/null +++ b/PresentationLayer/Extensions/Date+.swift @@ -0,0 +1,110 @@ +// +// Date+.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 3/6/22. +// + +import Foundation + +extension Date { + + var isToday: Bool { + Calendar.current.isDateInToday(self) + } + + var isTomorrow: Bool { + Calendar.current.isDateInTomorrow(self) + } + + var dayAfter: Date? { + return Calendar.current.date(byAdding: .day, value: 1, to: self) + } + + var start: Date { + Calendar.current.startOfDay(for: self) + } + + func isIn(range: ClosedRange) -> Bool { + range.contains(self) + } + + func isSameDay(with date: Date, calendar: Calendar = .current) -> Bool { + calendar.isDate(self, inSameDayAs: date) + } + + func seconds(from date: Date) -> Int { + return Calendar.current.dateComponents([.second], from: date, to: self).second ?? 0 + } + + func minutes(from date: Date) -> Int { + return Calendar.current.dateComponents([.minute], from: date, to: self).minute ?? 0 + } + + func days(from date: Date) -> Int { + return Calendar.current.dateComponents([.day], from: date.start, to: self).minute ?? 0 + } + + func toCurrentTimezone() -> Date { + let timeZoneDifference = + TimeInterval(TimeZone.current.secondsFromGMT()) + return addingTimeInterval(timeZoneDifference) + } + + func getFormattedDate() -> String { + let calendar = NSCalendar.current + let dateFormatter = DateFormatter() + if calendar.isDateInToday(self) { + dateFormatter.dateFormat = "'Today,' HH:mm" + } else { + dateFormatter.dateFormat = "dd/MM/yyyy HH:mm" + } + return dateFormatter.string(from: self) + } + + func getDateWithDateFormat(dateFormat: String = "HH:mm") -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = dateFormat + return dateFormatter.string(from: self) + } + + func getFormattedDate(format: String) -> String { + let dateformat = DateFormatter() + dateformat.locale = Locale(identifier: "en_us") + dateformat.dateFormat = format + return dateformat.string(from: self) + } + + func getDayOfDate() -> String { + let dayFormat = "EEEE" + let dateformat = DateFormatter() + dateformat.locale = Locale(identifier: "en_US_POSIX") + dateformat.dateFormat = dayFormat + return dateformat.string(from: self) + } + + func getDayMonthOfDate() -> String { + let dayMonthFormat = "dd/M" + let dateformat = DateFormatter() + dateformat.dateFormat = dayMonthFormat + return dateformat.string(from: self) + } + + func localizedDateString(dateStyle: DateFormatter.Style = .medium, timeStyle: DateFormatter.Style = .medium) -> String { + let dateformatter = DateFormatter() + dateformatter.dateStyle = dateStyle + dateformatter.timeStyle = timeStyle + let dateString = dateformatter.string(from: self) + return dateString + } +} + +extension TimeInterval { + static var minute: Self { + 60.0 + } + + static var hour: Self { + 60.0 * 60.0 + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/BluetoothState+.swift b/PresentationLayer/Extensions/Domain Extensions/BluetoothState+.swift new file mode 100644 index 00000000..812b0c0f --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/BluetoothState+.swift @@ -0,0 +1,23 @@ +// +// BluetoothState+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 10/3/23. +// + +import DomainLayer + +extension BluetoothState { + var errorDescription: String? { + switch self { + case .unsupported: + return LocalizableString.Bluetooth.unsupportedTitle.localized + case .unauthorized: + return LocalizableString.Bluetooth.noAccessTitle.localized + case .poweredOff: + return LocalizableString.Bluetooth.title.localized + case .poweredOn, .unknown, .resetting: + return nil + } + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/Common/Common.swift b/PresentationLayer/Extensions/Domain Extensions/Common/Common.swift new file mode 100644 index 00000000..fd0a07d9 --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/Common/Common.swift @@ -0,0 +1,18 @@ +// +// Common.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 27/3/23. +// + +import Foundation + +/// The icon color for each state +func activeStateColor(isActive: Bool) -> ColorEnum { + isActive ? .stationChipOnlineStateText : .stationChipOfflineStateText +} + +/// The tint color for for each state +func activeStateTintColor(isActive: Bool) -> ColorEnum { + isActive ? .stationChipOnlineStateBg : .stationChipOfflineStateBg +} diff --git a/PresentationLayer/Extensions/Domain Extensions/Common/CurrentWeather+.swift b/PresentationLayer/Extensions/Domain Extensions/Common/CurrentWeather+.swift new file mode 100644 index 00000000..ac0a87cc --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/Common/CurrentWeather+.swift @@ -0,0 +1,45 @@ +// +// CurrentWeather+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 16/3/23. +// + +import Foundation +import DomainLayer + +extension CurrentWeather: Identifiable { + public var id: String { + timestamp ?? "" + } + + func updatedAtString(with timeZone: TimeZone) -> String? { + guard let date = timestamp?.timestampToDate(timeZone: timeZone) else { + return nil + } + + return LocalizableString.lastUpdated(date.localizedDateString()).localized + } +} + +// MARK: - Mock + +extension CurrentWeather { + static var mockInstance: CurrentWeather { + var currentWeather = CurrentWeather() + currentWeather.timestamp = Date().ISO8601Format() + currentWeather.temperature = 15.0 + currentWeather.temperatureMin = 10.0 + currentWeather.temperatureMax = 16.0 + currentWeather.humidity = 30 + currentWeather.windGust = 25.7 + currentWeather.windSpeed = 47.5 + currentWeather.windDirection = 130 + currentWeather.uvIndex = 7 + currentWeather.precipitation = 1.1 + currentWeather.pressure = 603.77 + currentWeather.feelsLike = 18.9 + currentWeather.icon = "drizzle" + return currentWeather + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/Common/DeviceDetails+Common.swift b/PresentationLayer/Extensions/Domain Extensions/Common/DeviceDetails+Common.swift new file mode 100644 index 00000000..d9ca58d8 --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/Common/DeviceDetails+Common.swift @@ -0,0 +1,40 @@ +// +// DeviceDetails+Common.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 13/10/23. +// + +import Foundation +import DomainLayer + +extension DeviceDetails { + + var displayName: String { + guard let friendlyName = friendlyName, !friendlyName.isEmpty else { + return name + } + return friendlyName + } + + /// The icon to show according to profile + var icon: AssetEnum { + guard let profile else { + return .wifi + } + return profile.icon + } + +} + +extension Profile { + /// The icon to show according to profile + var icon: AssetEnum { + switch self { + case .m5: + return .wifi + case .helium: + return .helium + } + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/Common/NetworkErrorResponse+.swift b/PresentationLayer/Extensions/Domain Extensions/Common/NetworkErrorResponse+.swift new file mode 100644 index 00000000..da8973e9 --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/Common/NetworkErrorResponse+.swift @@ -0,0 +1,108 @@ +// +// NetworkErrorResponse+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 4/7/23. +// + +import Foundation +import DomainLayer +import Toolkit + +extension NetworkErrorResponse { + struct UIInfo { + let title: String + let description: String? + + #if MAIN_APP + func defaultFailObject(type: SuccessFailEnum, retryAction: VoidCallback?) -> FailSuccessStateObject { + let obj = FailSuccessStateObject(type: type, + title: title, + subtitle: description?.attributedMarkdown, + cancelTitle: nil, + retryTitle: LocalizableString.retry.localized, + contactSupportAction: { + HelperFunctions().openContactSupport(successFailureEnum: type, + email: MainScreenViewModel.shared.userInfo?.email, + serialNumber: nil, + errorString: description) + }, + cancelAction: nil, + retryAction: retryAction) + + return obj + } + #endif + } + + var uiInfo: UIInfo { + let title = LocalizableString.Error.genericMessage.localized + var description: String? + if let error = (initialError.underlyingError as? URLError) { + if error.code == .notConnectedToInternet || error.code == .timedOut { + description = LocalizableString.Error.noInternetAccess.localized + } else { + description = error.localizedDescription + } + } else if let backendError = backendError { + let code = FailAPICodeEnum(rawValue: backendError.code) + description = code?.description ?? backendError.message + } + + return UIInfo(title: title, description: description) + } +} + +private extension FailAPICodeEnum { + var description: String? { + switch self { + case .invalidUsername: + // Will be handled from the caller + return nil + case .invalidPassword: + // Will be handled from the caller + return nil + case .invalidCredentials: + return LocalizableString.Error.loginInvalidCredentials.localized + case .userAlreadyExists: + // Will be handled from the caller + return nil + case .invalidAccessToken: + return LocalizableString.ClaimDevice.errorGeneric.localized + case .deviceNotFound: + return LocalizableString.Error.userDeviceNotFound.localized + case .invalidWalletAddress: + return LocalizableString.Wallet.connectErrorInvalidAddress.localized + case .invalidFriendlyName: + return nil + case .invalidFromDate: + return nil + case .invalidToDate: + return nil + case .invalidTimezone: + return nil + case .invalidClaimId: + return nil + case .invalidClaimLocation: + return LocalizableString.ClaimDevice.errorInvalidLocation.localized + case .deviceAlreadyClaimed: + return LocalizableString.ClaimDevice.alreadyClaimed.localized + case .deviceClaiming: + return nil + case .unauthorized: + return nil + case .userNotFound: + return nil + case .forbidden: + return nil + case .validation: + return nil + case .notFound: + return nil + case .walletAddressNotFound: + return nil + case .unsupportedApplicationVersion: + return LocalizableString.Error.unsupportedApplicationVersion.localized + } + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/Common/UserDeviceFollowState+.swift b/PresentationLayer/Extensions/Domain Extensions/Common/UserDeviceFollowState+.swift new file mode 100644 index 00000000..6e9500da --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/Common/UserDeviceFollowState+.swift @@ -0,0 +1,41 @@ +// +// UserDeviceFollowState+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 8/8/23. +// + +import DomainLayer + +typealias StateFontAwesome = (icon: FontIcon, color: ColorEnum, font: FontAwesome) + +extension UserDeviceFollowState { + static let defaultFAIcon: StateFontAwesome = (FontIcon.heart, ColorEnum.favoriteHeart, FontAwesome.FAPro) + + enum State { + case owned + case followed + + var FAIcon: StateFontAwesome { + switch self { + case .owned: + return (FontIcon.home, ColorEnum.text, FontAwesome.FAProSolid) + case .followed: + return (FontIcon.heart, ColorEnum.favoriteHeart, FontAwesome.FAProSolid) + } + } + + var isActionable: Bool { + switch self { + case .owned: + return false + case .followed: + return true + } + } + } + + var state: State { + return relation == .followed ? .followed : .owned + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/DeviceDetails+.swift b/PresentationLayer/Extensions/Domain Extensions/DeviceDetails+.swift new file mode 100644 index 00000000..b33b25bf --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/DeviceDetails+.swift @@ -0,0 +1,81 @@ +// +// DeviceDetails+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 31/7/23. +// + +import Foundation +import DomainLayer + +struct FirmwareVersion: Codable { + let installDate: Date + let version: String +} + +extension DeviceDetails: Identifiable { } + +extension DeviceDetails { + + var stationLastActiveConf: StationLastActiveView.Configuration { + StationLastActiveView.Configuration(lastActiveAt: lastActiveAt, + icon: profile?.icon ?? .wifi, + stateColor: activeStateColor(isActive: isActive), + tintColor: activeStateTintColor(isActive: isActive)) + } + + /// Label without occurences of ":" + var convertedLabel: String? { + label?.convertedDeviceIdentifier + } + + /// The icon color for each state + var isActiveStateColor: ColorEnum { + activeStateColor(isActive: isActive) + } + + /// The tint color for for each state + var isActiveStateTintColor: ColorEnum { + activeStateTintColor(isActive: isActive) + } + + /// The url rouble shooting according to profile type + var troubleShootingUrl: String? { + guard let profile else { + return nil + } + + switch profile { + case .m5: + return DisplayedLinks.m5Troubleshooting.linkURL + case .helium: + return DisplayedLinks.heliumTroubleshooting.linkURL + } + } + + var explorerUrl: String { + let name = name.replacingOccurrences(of: " ", with: "-") + return DisplayedLinks.shareDevice.linkURL + name.lowercased() + } +} + +// MARK: - Mock + +extension DeviceDetails { + static var mockDevice: DeviceDetails { + var device = DeviceDetails.emptyDeviceDetails + device.name = "Test name" + device.id = "0" + device.label = "AE:66:F7:21:1F:21:75:11:EC" + device.address = "This is an address" + device.profile = .m5 + device.isActive = false + device.firmware = Firmware(assigned: "1.0.0", current: "1.0.1") + device.cellCenter = .init(lat: 0.0, long: 0.0) + + let currentWeather = CurrentWeather.mockInstance + device.weather = currentWeather + + return device + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/DeviceRewardsOverview+.swift b/PresentationLayer/Extensions/Domain Extensions/DeviceRewardsOverview+.swift new file mode 100644 index 00000000..393a3508 --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/DeviceRewardsOverview+.swift @@ -0,0 +1,272 @@ +// +// DeviceRewardsOverview+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 27/10/23. +// + +import Foundation +import DomainLayer +import Toolkit + +extension DeviceAnnotations { + func getAnnotationsList(for rewardScore: Int) -> [DeviceAnnotation] { + var showQod = true + if let threshold = RemoteConfigManager.shared.rewardsHideAnnotationThreshold { + showQod = rewardScore < threshold + } + + let qod = showQod ? self.qod : [] + return [qod, pol, rm].compactMap { $0 }.flatMap { $0 }.compactMap { $0.annotation != nil ? $0 : nil } + } +} + +extension DeviceRewardsOverview { + func toRewardsCardOverview(title: String, errorButtonTitle: String) -> StationRewardsCardOverview { + StationRewardsCardOverview(title: title, + date: timestamp, + fromDate: fromDate, + toDate: toDate, + actualReward: actualReward ?? 0.0, + lostPercentage: lostPercentage, + lostAmount: lostAmount, + rewardScore: rewardScore, + maxRewards: periodMaxReward, + annnotationsList: annotationsList, + timelineEntries: timeline?.rewardScores, + timelineAxis: timelineAxis, + timelineCaption: timelineCaption, + errorButtonTitle: errorButtonTitle) + } + + var annotationsList: [DeviceAnnotation] { + annotations?.getAnnotationsList(for: rewardScore ?? 0) ?? [] + } + + var lostPercentage: Int { + 100 - (rewardScore ?? 0) + } + + var lostAmount: Double { + guard let periodMaxReward, let actualReward else { + return 0.0 + } + + return periodMaxReward - actualReward + } + + var dateString: String? { + guard let timestamp else { + return timelineDateRangeString + } + + return timestamp.getFormattedDate(format: .monthLiteralDayTime, relativeFormat: true).localizedCapitalized + } + + var timelineAxis: [String]? { + guard let from = fromDate?.getFormattedDate(format: .monthLiteralDay, timezone: .UTCTimezone).localizedCapitalized, + let to = toDate?.getFormattedDate(format: .monthLiteralDay, timezone: .UTCTimezone).localizedCapitalized else { + if let refDate = timeline?.referenceDate, + let middleOfDay = refDate.middleOfDay, + let endOfDay = refDate.setHour(23) { + let begin = refDate.startOfDay() + return [begin.transactionsTimeFormat(), middleOfDay.transactionsTimeFormat(), endOfDay.transactionsTimeFormat()] + } + + return nil + } + + return [from, to] + } + + var timelineCaption: String? { + guard let timestamp = timeline?.referenceDate else { + return nil + } + + let val = timestamp.getFormattedDate(format: .monthLiteralDayYearShort, timezone: TimeZone.UTCTimezone).localizedCapitalized + let relativeDay = timestamp.relativeDayStringIfExists(timezone: TimeZone.UTCTimezone ?? .current) ?? "" + let comma = relativeDay.isEmpty ? "" : ", " + let valueString = "\(relativeDay)\(comma)\(val)" + return LocalizableString.StationDetails.rewardsTimelineCaption(valueString).localized + } + + private var timelineDateRangeString: String? { + guard let from = fromDate?.getFormattedDate(format: .fullMonthLiteralDay).localizedCapitalized, + let to = toDate?.getFormattedDate(format: .fullMonthLiteralDay).localizedCapitalized else { + return nil + } + + return "\(from) - \(to)" + } +} + +extension DeviceAnnotation { + + var affectedFieldsListString: String { + guard let affectedFields: [String] = affects?.compactMap({ $0.parameter?.displayTitle.lowercased() }), !affectedFields.isEmpty else { + return "" + } + + return "(\(affectedFields.joined(separator: ", ")))" + } + + var title: String { + guard let annotation else { + return "" + } + + switch annotation { + case .obc: + return LocalizableString.Error.obcTitle.localized + case .spikes: + return LocalizableString.Error.spikesTitle.localized + case .unidentifiedSpike: + return LocalizableString.Error.unidentifiedSpikeTitle.localized + case .noMedian: + return LocalizableString.Error.noMedianTitle.localized + case .noData: + return LocalizableString.Error.noDataTitle.localized + case .shortConst: + return LocalizableString.Error.shortConstTitle.localized + case .longConst: + return LocalizableString.Error.longConstTitle.localized + case .frozenSensor: + return LocalizableString.Error.frozenSensorTitle.localized + case .anomIncrease: + return LocalizableString.Error.anomIncreaseTitle.localized + case .unidentifiedAnomalousChange: + return LocalizableString.Error.unidentifiedAnomalousChangeTitle.localized + case .locationNotVerified: + return LocalizableString.Error.locationNotVerifiedTitle.localized + case .noLocationData: + return LocalizableString.Error.noLocationDataTitle.localized + case .noWallet: + return LocalizableString.Error.noWalletTitle.localized + case .relocated: + return LocalizableString.Error.relocatedTitle.localized + case .cellCapacityReached: + return LocalizableString.Error.cellCapacityReachedTitle.localized + case .polThresholdNotReached: + return LocalizableString.Error.polThresholdNotReachedTitle.localized + case .qodThresholdNotReached: + return LocalizableString.Error.qodThresholdNotReachedTitle.localized + case .unknown: + return LocalizableString.Error.unknownTitle.localized + } + } + + public func dercription(for profile: Profile?, followState: UserDeviceFollowState?) -> String { + guard let annotation else { + return "" + } + + switch annotation { + case .obc: + return LocalizableString.Error.obcDescription(affectedFieldsListString).localized + case .spikes: + if followState?.relation == .owned { + return LocalizableString.Error.spikesDescription(affectedFieldsListString).localized + } + + return LocalizableString.Error.unownedSpikesDescription(affectedFieldsListString).localized + case .unidentifiedSpike: + if followState?.relation == .owned { + return LocalizableString.Error.unidentifiedSpikeDescription(affectedFieldsListString).localized + } + + return LocalizableString.Error.unownedUnidentifiedSpikeDescription(affectedFieldsListString).localized + case .noMedian: + if followState?.relation == .owned { + return LocalizableString.Error.noMedianDescription.localized + } + + return LocalizableString.Error.unownedNoMedianDescription.localized + case .noData: + if followState?.relation == .owned { + return LocalizableString.Error.noDataDescription.localized + } + + return LocalizableString.Error.unownedNoDataDescription.localized + case .shortConst: + if followState?.relation == .owned { + return LocalizableString.Error.shortConstDescription(affectedFieldsListString).localized + } + + return LocalizableString.Error.unownedShortConstDescription(affectedFieldsListString).localized + case .longConst: + if followState?.relation == .owned { + return LocalizableString.Error.longConstDescription(affectedFieldsListString).localized + } + + return LocalizableString.Error.unownedLongConstDescription(affectedFieldsListString).localized + case .frozenSensor: + return LocalizableString.Error.frozenSensorDescription.localized + case .anomIncrease: + if followState?.relation == .owned { + return LocalizableString.Error.anomIncreaseDescription(affectedFieldsListString).localized + } + + return LocalizableString.Error.unownedAnomIncreaseDescription(affectedFieldsListString).localized + case .unidentifiedAnomalousChange: + if followState?.relation == .owned { + return LocalizableString.Error.unidentifiedAnomalousChangeDescription(affectedFieldsListString).localized + } + + return LocalizableString.Error.unownedUnidentifiedAnomalousChangeDescription(affectedFieldsListString).localized + + case .locationNotVerified: + guard followState?.relation == .owned else { + return LocalizableString.Error.locationNotVerifiedDescription.localized + } + + return LocalizableString.Error.locationNotVerifiedDescription.localized + case .noLocationData: + guard followState?.relation == .owned else { + return LocalizableString.Error.unownedNoLocationDataDescription.localized + } + + let polLink = DisplayedLinks.polAlgorithm.linkURL + switch profile { + case .m5: + return LocalizableString.Error.noLocationDataM5Description.localized + case .helium: + return LocalizableString.Error.noLocationDataHeliumDescription.localized + case nil: + return LocalizableString.Error.noLocationDataM5Description.localized + } + case .noWallet: + if followState?.relation == .owned { + return LocalizableString.Error.noWalletDescription.localized + } + + return LocalizableString.Error.unownedNoWalletDescription.localized + case .cellCapacityReached: + if followState?.relation == .owned { + return LocalizableString.Error.cellCapacityReachedDescription.localized + } + + return LocalizableString.Error.unownedCellCapacityReachedDescription.localized + case .polThresholdNotReached: + if followState?.relation == .owned { + return LocalizableString.Error.polThresholdNotReachedDescription.localized + } + + return LocalizableString.Error.unownedPolThresholdNotReachedDescription.localized + case .qodThresholdNotReached: + if followState?.relation == .owned { + return LocalizableString.Error.qodThresholdNotReachedDescription.localized + } + + return LocalizableString.Error.unownedQodThresholdNotReachedDescription.localized + case .relocated: + return LocalizableString.Error.relocatedDescription.localized + case .unknown: + if followState?.relation == .owned { + return LocalizableString.Error.unknownDescription.localized + } + + return LocalizableString.Error.unownedUnknownDescription.localized + } + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/ExplorerLocationError+.swift b/PresentationLayer/Extensions/Domain Extensions/ExplorerLocationError+.swift new file mode 100644 index 00000000..5b34d0ac --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/ExplorerLocationError+.swift @@ -0,0 +1,19 @@ +// +// ExplorerLocationError+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 24/5/23. +// + +import DomainLayer + +extension ExplorerLocationError: CustomStringConvertible { + public var description: String { + switch self { + case .locationNotFound: + return LocalizableString.explorerLocationNotFound.localized + case .permissionDenied: + return "" + } + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/Filters+.swift b/PresentationLayer/Extensions/Domain Extensions/Filters+.swift new file mode 100644 index 00000000..a028a32d --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/Filters+.swift @@ -0,0 +1,98 @@ +// +// Filters+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 15/9/23. +// + +import DomainLayer +import Toolkit + +protocol FilterPresentable: CustomStringConvertible where Self: CaseIterable { + static var title: String { get } + var analyticsParameterValue: ParameterValue { get } +} + +extension GroupBy: FilterPresentable { + static var title: String { + LocalizableString.Filters.groupByTitle.localized + } + + public var description: String { + switch self { + case .noGroup: + LocalizableString.Filters.groupByNoGrouping.localized + case .relationship: + LocalizableString.Filters.groupByRelationship.localized + case .status: + LocalizableString.Filters.groupByStatus.localized + } + } + + var analyticsParameterValue: ParameterValue { + switch self { + case .noGroup: + .noGrouping + case .relationship: + .relationship + case .status: + .status + } + } +} + +extension SortBy: FilterPresentable { + static var title: String { + LocalizableString.Filters.sortByTitle.localized + } + + public var description: String { + switch self { + case .dateAdded: + LocalizableString.Filters.sortByDateAdded.localized + case .name: + LocalizableString.Filters.sortByName.localized + case .lastActive: + LocalizableString.Filters.sortByLastActive.localized + } + } + + var analyticsParameterValue: ParameterValue { + switch self { + case .dateAdded: + .dateAdded + case .name: + .name + case .lastActive: + .lastActive + } + } +} + +extension Filter: FilterPresentable { + static var title: String { + LocalizableString.Filters.filterTitle.localized + } + + public var description: String { + switch self { + case .all: + LocalizableString.Filters.filterShowAll.localized + case .ownedOnly: + LocalizableString.Filters.filterOwnedOnly.localized + case .favoritesOnly: + LocalizableString.Filters.filterFavoritesOnly.localized + } + } + + var analyticsParameterValue: ParameterValue { + switch self { + case .all: + .all + case .ownedOnly: + .owned + case .favoritesOnly: + .favorites + } + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/Firmware+.swift b/PresentationLayer/Extensions/Domain Extensions/Firmware+.swift new file mode 100644 index 00000000..4fb63f3b --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/Firmware+.swift @@ -0,0 +1,58 @@ +// +// Firmware+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 29/9/23. +// + +import Foundation +import DomainLayer + +extension DeviceDetails { + private static let firmwareUpdateInterval: TimeInterval = .hour + + func needsUpdate(persistedVersion: FirmwareVersion?) -> Bool { + guard let version = persistedVersion?.version, + let timestamp = persistedVersion?.installDate + else { + return checkFirmwareIfNeedsUpdate() + } + + if version == firmware?.current, Date.now.timeIntervalSince(timestamp) < Self.firmwareUpdateInterval { + return false + } + + return checkFirmwareIfNeedsUpdate() + } + + func checkFirmwareIfNeedsUpdate() -> Bool { + guard profile == .helium, + let current = firmware?.current, + let assigned = firmware?.assigned else { + return false + } + + return assigned != current + } + + /// True if the stations current version is different from the assigned + func needsUpdate(mainVM: MainScreenViewModel, followState: UserDeviceFollowState?) -> Bool { + guard profile == .helium, followState?.state == .owned else { + return false + } + return needsUpdate(persistedVersion: mainVM.getInstalledFirmwareVersion(for: id ?? "")) + } + + func alertsCount(mainVM: MainScreenViewModel, followState: UserDeviceFollowState?) -> Int { + [!isActive, needsUpdate(mainVM: mainVM, followState: followState)].reduce(0) { $0 + ($1 ? 1 : 0) } + } +} + +extension Firmware { + var versionUpdateString: String? { + guard let current, let assigned else { + return nil + } + return "\(current) → \(assigned)" + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/Network+.swift b/PresentationLayer/Extensions/Domain Extensions/Network+.swift new file mode 100644 index 00000000..ca8e8c91 --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/Network+.swift @@ -0,0 +1,21 @@ +// +// Network+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 22/6/23. +// + +import DomainLayer + +extension Connectivity { + var icon: AssetEnum { + switch self { + case .wifi: + return .wifi + case .helium: + return .helium + case .cellular: + return .wifi + } + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/NetworkDeviceIDTokensTransactionsResponse+.swift b/PresentationLayer/Extensions/Domain Extensions/NetworkDeviceIDTokensTransactionsResponse+.swift new file mode 100644 index 00000000..27772ebe --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/NetworkDeviceIDTokensTransactionsResponse+.swift @@ -0,0 +1,100 @@ +// +// NetworkDeviceIDTokensTransactionsResponse+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 21/3/23. +// + +import Foundation +import DomainLayer +import Toolkit + +extension NetworkDeviceIDTokensTransactionsResponse { + func actualRewardForLast(days: Int) -> Double { + let now = Date.now + guard let endDate = Calendar.current.date(byAdding: .day, value: -days, to: now) else { + return 0.0 + } + + let dateRange = endDate...now + let totalReward = data?.reduce(0.0) { $0 + ($1.timestamp!.stringToDate().isIn(range: dateRange) ? ($1.actualReward ?? 0.0) : 0.0)} + + return totalReward ?? 0.0 + } + + func rewardsPerDayForLast(days: Int) -> [Double] { + let now = Date.now + let dates = Array(0.. ColorEnum { + guard let validationScore else { + return .reward_score_unknown + } + + switch validationScore { + case 0.0 ..< 0.2: + return .reward_score_very_low + case 0.2 ..< 0.4: + return .reward_score_low + case 0.4 ..< 0.6: + return .reward_score_average + case 0.6 ..< 0.8: + return .reward_score_high + case 0.8 ... 1.0: + return .reward_score_very_high + default: + return .reward_score_unknown + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/NetworkDevicesInfoResponse+.swift b/PresentationLayer/Extensions/Domain Extensions/NetworkDevicesInfoResponse+.swift new file mode 100644 index 00000000..b482f7f6 --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/NetworkDevicesInfoResponse+.swift @@ -0,0 +1,20 @@ +// +// NetworkDevicesInfoResponse+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 28/3/23. +// + +import Foundation +import DomainLayer + +extension NetworkDevicesInfoResponse.BatState: CustomStringConvertible { + public var description: String { + switch self { + case .low: + return LocalizableString.low.localized + case .ok: + return LocalizableString.good.localized + } + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/NetworkSearchResponse+.swift b/PresentationLayer/Extensions/Domain Extensions/NetworkSearchResponse+.swift new file mode 100644 index 00000000..2f6a7ffa --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/NetworkSearchResponse+.swift @@ -0,0 +1,48 @@ +// +// NetworkSearchResponse+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 29/6/23. +// + +import DomainLayer + +protocol NetworkSearchModel { + var lat: Double? { get } + var lon: Double? { get } + var deviceId: String? { get } + var cellIndex: String? { get } +} + +extension NetworkSearchDevice: NetworkSearchModel { + + var lat: Double? { + cellCenter?.lat + } + + var lon: Double? { + cellCenter?.lon + } + + var deviceId: String? { + id + } +} + +extension NetworkSearchAddress: NetworkSearchModel { + var lat: Double? { + center?.lat + } + + var lon: Double? { + center?.lon + } + + var deviceId: String? { + nil + } + + var cellIndex: String? { + nil + } +} diff --git a/PresentationLayer/Extensions/Domain Extensions/UnitsProtocol+.swift b/PresentationLayer/Extensions/Domain Extensions/UnitsProtocol+.swift new file mode 100644 index 00000000..181892e5 --- /dev/null +++ b/PresentationLayer/Extensions/Domain Extensions/UnitsProtocol+.swift @@ -0,0 +1,127 @@ +// +// UnitsProtocol+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 10/5/23. +// + +import Foundation +import DomainLayer + +protocol UnitsProtocolPresentable: UnitsProtocol { + var unit: String { get } + var settingUnitFriendlyName: String { get } +} + +extension TemperatureUnitsEnum: UnitsProtocolPresentable { + public var unit: String { + switch self { + case .celsius: + return UnitConstants.CELCIUS + case .fahrenheit: + return UnitConstants.FAHRENHEIT + } + } + + public var settingUnitFriendlyName: String { + switch self { + case .celsius: + return "Celsius (°C)" + case .fahrenheit: + return "Fahrenheit (°F)" + } + } +} + +extension PrecipitationUnitsEnum: UnitsProtocolPresentable { + public var unit: String { + switch self { + case .millimeters: + return UnitConstants.MILLIMETERS + case .inches: + return UnitConstants.INCHES + } + } + + public var settingUnitFriendlyName: String { + switch self { + case .millimeters: + return "Millimeters (mm)" + case .inches: + return "Inches (in)" + } + } +} + +extension WindSpeedUnitsEnum: UnitsProtocolPresentable { + public var unit: String { + switch self { + case .kilometersPerHour: + return UnitConstants.KILOMETERS_PER_HOUR + case .milesPerHour: + return UnitConstants.MILES_PER_HOUR + case .metersPerSecond: + return UnitConstants.METERS_PER_SECOND + case .knots: + return UnitConstants.KNOTS + case .beaufort: + return UnitConstants.BEAUFORT + } + } + + public var settingUnitFriendlyName: String { + switch self { + case .kilometersPerHour: + return "Kilometers per hour (km/h)" + case .milesPerHour: + return "Miles per hour (mph)" + case .metersPerSecond: + return "Meters per second (m/s)" + case .knots: + return "Knots (kn)" + case .beaufort: + return "Beaufort (bf)" + } + } +} + +extension WindDirectionUnitsEnum: UnitsProtocolPresentable { + public var unit: String { + switch self { + case .cardinal: + return UnitConstants.CARDINAL + case .degrees: + return UnitConstants.DEGREES + } + } + + public var settingUnitFriendlyName: String { + switch self { + case .cardinal: + return "Cardinal (e.g. N, SW, NE, etc)" + case .degrees: + return "Degrees" + } + } +} + +extension PressureUnitsEnum: UnitsProtocolPresentable { + public var unit: String { + switch self { + case .hectopascal: + return UnitConstants.HECTOPASCAL + case .inchOfMercury: + return UnitConstants.INCH_OF_MERCURY + } + } + + public var settingUnitFriendlyName: String { + switch self { + case .hectopascal: + return "Hectopascal (hPa)" + case .inchOfMercury: + return "Inch of mercury (inHg)" + } + } + +} diff --git a/PresentationLayer/Extensions/Numeric/CGFloat+.swift b/PresentationLayer/Extensions/Numeric/CGFloat+.swift new file mode 100644 index 00000000..be60ba3a --- /dev/null +++ b/PresentationLayer/Extensions/Numeric/CGFloat+.swift @@ -0,0 +1,19 @@ +// +// CGFloat+.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 10/5/22. +// + +import Foundation +import SwiftUI + +extension CGFloat { + init(_ dimension: Dimension) { + self.init(dimension.value) + } + + init(_ fontSizeEnum: FontSizeEnum) { + self.init(fontSizeEnum.sizeValue) + } +} diff --git a/PresentationLayer/Extensions/Numeric/Double+.swift b/PresentationLayer/Extensions/Numeric/Double+.swift new file mode 100644 index 00000000..09feb459 --- /dev/null +++ b/PresentationLayer/Extensions/Numeric/Double+.swift @@ -0,0 +1,63 @@ +// +// Double+.swift +// PresentationLayer +// +// Created by Lampros Zouloumis on 21/6/22. +// + +import Foundation +import DomainLayer +// https://stackoverflow.com/questions/27338573/rounding-a-double-value-to-x-number-of-decimal-places-in-swift +extension Double { + func rounded(toPlaces places: Int) -> Double { + let divisor = pow(10.0, Double(places)) + return (self * divisor).rounded() / divisor + } + + func reduceScale(to places: Int) -> Double { + let multiplier = pow(10, Double(places)) + let newDecimal = multiplier * self // move the decimal right + let truncated = Double(Int(newDecimal)) // drop the fraction + let originalDecimal = truncated / multiplier // move the decimal back + return originalDecimal + } + + var intValueRounded: Int { + var roundedValue = self + roundedValue.round(.toNearestOrAwayFromZero) + return Int(roundedValue) + } + + var roundedToken: Double { + let behavior = NSDecimalNumberHandler(roundingMode: .plain, scale: 2, raiseOnExactness: false, raiseOnOverflow: false, raiseOnUnderflow: false, raiseOnDivideByZero: true) + return NSDecimalNumber(value: self).rounding(accordingToBehavior: behavior).doubleValue + } + + var toWXMTokenPrecisionString: String { + toPrecisionString(minDecimals: 2, precision: 4) + } + + func toPrecisionString(minDecimals: Int = 0, precision: Int) -> String { + return formatted(.number.rounded(rule: .up).precision(.fractionLength(minDecimals...precision))) + } + + func toTemeratureUnit(_ unit: TemperatureUnitsEnum) -> Double { + switch unit { + case .celsius: + return self + case .fahrenheit: + let value = celsiusToFahrenheit(celsius: self) + return value + } + } + + func toTemeratureString(for unit: TemperatureUnitsEnum) -> String { + let value = Int(toTemeratureUnit(unit).rounded(toPlaces: 0)) + switch unit { + case .celsius: + return "\(value)\(LocalizableString.celsiusSymbol.localized)" + case .fahrenheit: + return "\(value)\(LocalizableString.fahrenheitSymbol.localized)" + } + } +} diff --git a/PresentationLayer/Extensions/Numeric/Int+.swift b/PresentationLayer/Extensions/Numeric/Int+.swift new file mode 100644 index 00000000..1eee3a45 --- /dev/null +++ b/PresentationLayer/Extensions/Numeric/Int+.swift @@ -0,0 +1,43 @@ +// +// Int+.swift +// PresentationLayer +// +// Created by Lampros Zouloumis on 22/8/22. +// + +import Foundation +import Toolkit +// https://stackoverflow.com/questions/48371093/swift-4-formatting-numbers-into-friendly-ks +extension Int { + func formatNumber() -> String { + let num = abs(Double(self)) + let sign = (self < 0) ? "-" : "" + + switch num { + case 1_000_000_000...: + var formatted = num / 1_000_000_000 + formatted = formatted.reduceScale(to: 1) + return "\(sign)\(formatted)B" + + case 1_000_000...: + var formatted = num / 1_000_000 + formatted = formatted.reduceScale(to: 1) + return "\(sign)\(formatted)M" + + case 1000...: + var formatted = num / 1000 + formatted = formatted.reduceScale(to: 1) + return "\(sign)\(formatted)K" + + case 0...: + return "\(self)" + + default: + return "\(sign)\(self)" + } + } + + var localizedFormatted: String { + NumberFormatter.localizedString(from: NSNumber(value: self), number: .decimal) + } +} diff --git a/PresentationLayer/Extensions/Numeric/Numeric+.swift b/PresentationLayer/Extensions/Numeric/Numeric+.swift new file mode 100644 index 00000000..276319b6 --- /dev/null +++ b/PresentationLayer/Extensions/Numeric/Numeric+.swift @@ -0,0 +1,16 @@ +// +// Numeric+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 29/6/23. +// + +import Foundation +import Toolkit + +extension Numeric { + var toCompactDecimaFormat: String? { + let statsFormatter = CompactNumberFormatter() + return statsFormatter.string(for: self) + } +} diff --git a/PresentationLayer/Extensions/Shape+.swift b/PresentationLayer/Extensions/Shape+.swift new file mode 100644 index 00000000..7a2ff1a4 --- /dev/null +++ b/PresentationLayer/Extensions/Shape+.swift @@ -0,0 +1,28 @@ +// +// Shape+.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 1/10/22. +// + +import SwiftUI + +extension Shape { + func style( + withStroke strokeContent: S, + lineWidth: CGFloat = 1, + fill fillContent: F + ) -> some View { + stroke(strokeContent, lineWidth: lineWidth) + .background(fill(fillContent)) + } +} + +struct Line: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: rect.width, y: 0)) + return path + } +} diff --git a/PresentationLayer/Extensions/String+.swift b/PresentationLayer/Extensions/String+.swift new file mode 100644 index 00000000..fe9962a5 --- /dev/null +++ b/PresentationLayer/Extensions/String+.swift @@ -0,0 +1,183 @@ +// +// String+.swift +// PresentationLayer +// + +// Created by Hristos Condrea on 30/5/22. +// + +import Foundation +import SwiftUI +import Toolkit + +extension Optional where Wrapped == String { + /// If nil returns a much earlier date + /// - Returns: DistantPast constant + func stringToDate() -> Date { + guard let self else { + return .distantPast + } + + let dateFormatter = ISO8601DateFormatter() + let date = dateFormatter.date(from: self) ?? Date() + return date + } +} + +extension String { + + var convertedDeviceIdentifier: String { + replacingOccurrences(of: ":", with: "") + } + + init(_ displayedLinksEnum: DisplayedLinks) { + self.init(displayedLinksEnum.linkURL) + } + + func stringToDate() -> Date { + let dateFormatter = ISO8601DateFormatter() + let date = dateFormatter.date(from: self) ?? Date() + return date + } + + func tokenRewardsTimestampToDate(deviceTimeZone: String) -> Date { + let formatter = DateFormatter() + formatter.timeZone = TimeZone(identifier: deviceTimeZone) + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + if let date = formatter.date(from: self) { + return date + } else { + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" + if let nextValidDate = formatter.date(from: self) { + return nextValidDate + } else { + return Date() + } + } + } + + func getAnimationString() -> String { + return AnimationsEnums(rawValue: self)?.animationString ?? AnimationsEnums.notAvailable.animationString + } + + func isTextEmpty() -> Bool { + if trimWhiteSpaces().isEmpty { + return true + } else { + return false + } + } + + func removeSpaces() -> String { + replacingOccurrences(of: " ", with: "") + } + + func containsSpaces() -> Bool { + rangeOfCharacter(from: .whitespacesAndNewlines) != nil + } + + func matches(_ regex: String) -> Bool { + return range(of: regex, options: .regularExpression, range: nil, locale: nil) != nil + } + + func isValidEmail() -> Bool { + let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + return (matches(emailRegex)) + } + + func dsnValidation() -> TextFieldError? { + let regex = "[^G-Z|^a-z]{18}" + if replaceColonOcurrancies().matches(regex) { + return nil + } else if isTextEmpty() { + return .emptyField + } else { + return .invalidSerialNumber + } + } + + func newAddressValidation() -> TextFieldError? { + let regex = "^0x[a-fA-F0-9]{40}$" + if matches(regex) { + return nil + } else if isTextEmpty() { + return .emptyField + } else { + return .invalidNewAddress + } + } + + func replaceColonOcurrancies() -> String { + return replacingOccurrences(of: ":", with: "") + } + + func getDateForLatestDateWeatherDetail() -> String { + if self == "-" { + return "-" + } + let relativeDateFormatter = DateFormatter() + relativeDateFormatter.timeStyle = .none + relativeDateFormatter.dateStyle = .medium + relativeDateFormatter.doesRelativeDateFormatting = true + let inputFormatter = DateFormatter() + inputFormatter.dateStyle = .none + inputFormatter.timeStyle = .short + let date = timestampToDate() + let dateOfString = relativeDateFormatter.string(from: date) + let timeOfDate = inputFormatter.string(from: date) + return String("\(dateOfString), \(timeOfDate)") + } + + func getWeekDayAndDate() -> String { + let date = timestampToDate() + let dayFormatter = DateFormatter() + dayFormatter.doesRelativeDateFormatting = true + dayFormatter.dateStyle = .medium + if !date.isToday && !date.isTomorrow { + dayFormatter.setLocalizedDateFormatFromTemplate("EEEE dd/MM") + } + let dayStr = dayFormatter.string(from: date) + return dayStr + } + + func getTimeForLatestDateWeatherDetail() -> String { + let inputFormatter = DateFormatter() + inputFormatter.dateStyle = .none + inputFormatter.timeStyle = .short + + let date = timestampToDate() + return inputFormatter.string(from: date) + } + + func firstIndex(substring: String) -> Index? { + range(of: substring)?.lowerBound + } + + /// Returns an AttributedString, with the passed text in bold and in passed color + /// - Parameters: + /// - text: The text to highlight + /// - color: The highlighted text's color + /// - Returns: An Attributed string + func withHighlightedPart(text: String, color: Color) -> AttributedString? { + var currentText = text + + var range = range(of: currentText, options: .caseInsensitive) + while range == nil && !currentText.isEmpty { + currentText.removeLast() + range = self.range(of: currentText, options: .caseInsensitive) + } + + if let range { + let substring = self[range] + var attributedString = replacingOccurrences(of: substring, with: "**\(substring)**").attributedMarkdown + + if let attributedStringRange = attributedString?.range(of: substring) { + attributedString?[attributedStringRange].foregroundColor = color + } + + return attributedString + } + + return self.attributedMarkdown + } +} diff --git a/PresentationLayer/Extensions/Text+.swift b/PresentationLayer/Extensions/Text+.swift new file mode 100644 index 00000000..f5271e51 --- /dev/null +++ b/PresentationLayer/Extensions/Text+.swift @@ -0,0 +1,15 @@ +// +// Text+.swift +// PresentationLayer +// +// Created by Hristos Condrea on 16/5/22. +// + +import SwiftUI + +extension Text { + + init(_ doubleValue: Double, specifier: String) { + self.init("\(doubleValue, specifier: specifier)") + } +} diff --git a/PresentationLayer/Extensions/UIColor+.swift b/PresentationLayer/Extensions/UIColor+.swift new file mode 100644 index 00000000..7683ff03 --- /dev/null +++ b/PresentationLayer/Extensions/UIColor+.swift @@ -0,0 +1,15 @@ +// +// UIColor+.swift +// PresentationLayer +// +// Created by Hristos Condrea on 13/5/22. +// + +import Foundation +import SwiftUI + +extension UIColor { + convenience init(colorEnum: ColorEnum) { + self.init(Color(colorEnum: colorEnum)) + } +} diff --git a/PresentationLayer/Extensions/UIImage+.swift b/PresentationLayer/Extensions/UIImage+.swift new file mode 100644 index 00000000..7de3268d --- /dev/null +++ b/PresentationLayer/Extensions/UIImage+.swift @@ -0,0 +1,42 @@ +// +// UIImage+.swift +// DomainLayer +// +// Created by Lampros Zouloumis on 29/8/22. +// + +import struct UIKit.CGAffineTransform +import struct UIKit.CGFloat +import struct UIKit.CGPoint +import struct UIKit.CGRect +import func UIKit.UIGraphicsBeginImageContextWithOptions +import func UIKit.UIGraphicsEndImageContext +import func UIKit.UIGraphicsGetCurrentContext +import func UIKit.UIGraphicsGetImageFromCurrentImageContext +import class UIKit.UIImage +import Foundation + +extension UIImage { + func rotate(degrees: Float) -> UIImage { + let radians = degrees * .pi / 180.0 + var newSize = CGRect(origin: CGPoint.zero, size: self.size).applying(CGAffineTransform(rotationAngle: CGFloat(radians))).size + // Trim off the extremely small float value to prevent core graphics from rounding it up + newSize.width = floor(newSize.width) + newSize.height = floor(newSize.height) + + UIGraphicsBeginImageContextWithOptions(newSize, false, self.scale) + guard let context = UIGraphicsGetCurrentContext() else { return UIImage() } + + // Move origin to middle + context.translateBy(x: newSize.width / 2, y: newSize.height / 2) + // Rotate around middle + context.rotate(by: CGFloat(radians)) + // Draw the image at its center + self.draw(in: CGRect(x: -self.size.width / 2, y: -self.size.height / 2, width: self.size.width, height: self.size.height)) + + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage ?? UIImage() + } +} diff --git a/PresentationLayer/Extensions/View+.swift b/PresentationLayer/Extensions/View+.swift new file mode 100644 index 00000000..0634f5e7 --- /dev/null +++ b/PresentationLayer/Extensions/View+.swift @@ -0,0 +1,19 @@ +// +// View+.swift +// PresentationLayer +// +// Created by Hristos Condrea on 18/5/22. +// + +import Foundation +import SwiftUI + +extension View { + var toAnyView: AnyView { + AnyView(self) + } + + func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/PresentationLayer/Navigation/DeepLinkHandler.swift b/PresentationLayer/Navigation/DeepLinkHandler.swift new file mode 100644 index 00000000..765e63eb --- /dev/null +++ b/PresentationLayer/Navigation/DeepLinkHandler.swift @@ -0,0 +1,189 @@ +// +// DeepLinkHandler.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 24/7/23. +// + +import Foundation +import DomainLayer +import Combine +import UIKit +import Toolkit + +class DeepLinkHandler { + typealias QueryParamsCallBack = GenericCallback<[String: String]?> + static let httpsScheme = "https" + static let explorerHost = "explorer.weatherxm.com" + static let explorerDevHost = "explorer-dev.weatherxm.com" + static let stationsPath = "stations" + static let cellsPath = "cells" + static let tokenClaim = "token-claim" + + let useCase: NetworkUseCase + private var searchCancellable: AnyCancellable? + + init(useCase: NetworkUseCase) { + self.useCase = useCase + } + + @discardableResult + /// Handles the passed url + /// - Parameter url: The url to handle/navigete + /// - Parameter queryParamsCallback: A callback to pass the url query params if handled successfully + /// - Returns: True if is handled successfully + func handleUrl(_ url: URL, queryParamsCallback: QueryParamsCallBack? = nil) -> Bool { + print(url) + + var handled = false + defer { + if handled { + queryParamsCallback?(url.queryItems) + } + } + + switch url.scheme { + case Self.httpsScheme: + handled = handleUniversalLink(url: url) + case Bundle.main.urlScheme: + handled = handleUrlScheme(url: url) + case widgetScheme: + handled = handleWidgetUrlScheme(url: url) + default: + let canOpen = UIApplication.shared.canOpenURL(url) + if canOpen { + UIApplication.shared.open(url) + } + + handled = canOpen + } + + return handled + } + + deinit { + print("deInit \(Self.self)") + } +} + +private extension DeepLinkHandler { + /// Handles the url scheme (not http) + /// - Parameter url: The url to handle/navigete + /// - Returns: True if is handled successfully + func handleUrlScheme(url: URL) -> Bool { + guard let path = url.host else { + Router.shared.pop() + return true + } + + let value = url.lastPathComponent + + return handleNavigation(path: path, value: value) + } + + /// Handles the widget url + /// - Parameter url: The url to handle/navigete + /// - Returns: True if is handled successfully + func handleWidgetUrlScheme(url: URL) -> Bool { + guard let host = url.host, + let widgetCase = WidgetUrlType(rawValue: host) else { + return false + } + + let pathComps = url.pathComponents + switch widgetCase { + case .station: + if let deviceId = pathComps[safe: 1] { + let route = Route.stationDetails(ViewModelsFactory.getStationDetailsViewModel(deviceId: deviceId, + cellIndex: nil, + cellCenter: nil)) + Router.shared.navigateTo(route) + return true + } + + return false + case .loggedOut: + let route = Route.signIn(ViewModelsFactory.getSignInViewModel()) + Router.shared.navigateTo(route) + return true + case .empty: + return false + case .error: + return false + case .selectStation: + return false + } + } + + /// Handles the http url + /// - Parameter url: The url to handle/navigete + /// - Returns: True if is handled successfully + func handleUniversalLink(url: URL) -> Bool { + guard let host = url.host, + host == Self.explorerHost || host == Self.explorerDevHost, + case let pathComps = url.pathComponents, + pathComps.count == 3 else { + return false + } + + let path = pathComps[1] + let value = pathComps[2] + return handleNavigation(path: path, value: value) + } + + /// Handles path to navigate + /// - Parameter path: The path to navigate + /// - Parameter value: The value to pass to the navigation path + /// - Parameter additionalInfo: Extra params to be handled + /// - Returns: True if is handled successfully + func handleNavigation(path: String, value: String) -> Bool { + switch path { + case Self.stationsPath: + moveToStation(name: value) + return true + case Self.cellsPath: + // To be handled in the future + return false + case Self.tokenClaim: + Router.shared.pop() + return true + default: + return false + } + } + + func moveToStation(name: String) { + let normalizedName = name.replacingOccurrences(of: "-", with: " ") + guard normalizedName.count > 1 else { // To perform search the term's length should be greater than 1 + if let message = LocalizableString.Error.deepLinkInvalidUrl.localized.attributedMarkdown { + Toast.shared.show(text: message) + } + return + } + + do { + searchCancellable = try useCase.search(term: normalizedName, exact: true, exclude: .places).sink { response in + if let error = response.error { + let info = error.uiInfo + if let message = info.description?.attributedMarkdown { + Toast.shared.show(text: message) + } + + return + } + + if let device = response.value?.devices?.first, + let deviceId = device.id, + let cellIndex = device.cellIndex { + let cellCenter = device.cellCenter?.toCLLocationCoordinate2D() + let route = Route.stationDetails(ViewModelsFactory.getStationDetailsViewModel(deviceId: deviceId, + cellIndex: cellIndex, + cellCenter: cellCenter)) + Router.shared.navigateTo(route) + } + } + } catch { + + } + } +} diff --git a/PresentationLayer/Navigation/Router.swift b/PresentationLayer/Navigation/Router.swift new file mode 100644 index 00000000..e16eb178 --- /dev/null +++ b/PresentationLayer/Navigation/Router.swift @@ -0,0 +1,234 @@ +// +// Router.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 25/7/23. +// + +import Foundation +import SwiftUI + +enum Route: Hashable, Equatable { + static func == (lhs: Route, rhs: Route) -> Bool { + lhs.hashValue == rhs.hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(stringRepresentation) + + switch self { + case .stationDetails(let vm): + hasher.combine(vm) + case .deviceInfo(let vm): + hasher.combine(vm) + case .viewMoreAlerts(let vm): + hasher.combine(vm) + case .wallet(let vm): + hasher.combine(vm) + case .history(let vm): + hasher.combine(vm) + case .netStats(let vm): + hasher.combine(vm) + case .transactions(let vm): + hasher.combine(vm) + case .settings(let vm): + hasher.combine(vm) + case .claimDevice(let viaBT): + hasher.combine(viaBT) + case .deleteAccount(let vm): + hasher.combine(vm) + case .survey(let userId, let appId): + hasher.combine("\(userId)-\(appId)") + case .signIn(let vm): + hasher.combine(vm) + case .register(let vm): + hasher.combine(vm) + case .resetPassword(let vm): + hasher.combine(vm) + case .explorerList(let vm): + hasher.combine(vm) + case .rewardDetails(let vm): + hasher.combine(vm) + case .webView(let title, let url, let params, _): + hasher.combine("\(title)-\(url)-\(params)") + case .selectStationLocation(let vm): + hasher.combine(vm) + } + } + + var stringRepresentation: String { + switch self { + case .stationDetails: + "stationDetails" + case .deviceInfo: + "deviceInfo" + case .viewMoreAlerts: + "viewMoreAlerts" + case .wallet: + "wallet" + case .history: + "history" + case .netStats: + "netStats" + case .transactions: + "transactions" + case .settings: + "settings" + case .claimDevice: + "claimDevice" + case .deleteAccount: + "deleteAccount" + case .survey: + "survey" + case .signIn: + "signIn" + case .register: + "register" + case .resetPassword: + "resetPassword" + case .explorerList: + "explorerList" + case .rewardDetails: + "rewardDetails" + case .webView: + "wabView" + case .selectStationLocation: + "selectStationLocation" + } + } + + case stationDetails(StationDetailsViewModel) + case deviceInfo(DeviceInfoViewModel) + case viewMoreAlerts(AlertsViewModel) + case wallet(MyWalletViewModel) + case history(HistoryContainerViewModel) + case netStats(NetworkStatsViewModel) + case transactions(TransactionDetailsViewModel) + case settings(SettingsViewModel) + case claimDevice(Bool) + case deleteAccount(DeleteAccountViewModel) + case survey(String, String) + case signIn(SignInViewModel) + case register(RegisterViewModel) + case resetPassword(ResetPasswordViewModel) + case explorerList(ExplorerStationsListViewModel) + case rewardDetails(RewardDetailsViewModel) + case webView(String, String, [DisplayLinkParams: String]?, DeepLinkHandler.QueryParamsCallBack?) + case selectStationLocation(SelectStationLocationViewModel) +} + +extension Route { + @ViewBuilder + var view: some View { + switch self { + case .stationDetails(let stationDetailsViewModel): + StationDetailsContainerView(viewModel: stationDetailsViewModel) + case .deviceInfo(let deviceInfoViewModel): + NavigationContainerView { + DeviceInfoView(viewModel: deviceInfoViewModel) + } + case .viewMoreAlerts(let viewMoreAlertsViewModel): + NavigationContainerView { + MultipleAlertsView(viewModel: viewMoreAlertsViewModel) + } + case .wallet(let myWalletViewModel): + NavigationContainerView { + MyWalletView(viewModel: myWalletViewModel) + } + case .history(let historyViewModel): + HistoryContainerView(viewModel: historyViewModel) + case .netStats(let netStatsViewModel): + NavigationContainerView { + NetworkStatsView(viewModel: netStatsViewModel) + } + case .transactions(let transactionsViewModel): + NavigationContainerView { + TransactionDetailsView(viewModel: transactionsViewModel) + } + case .settings(let settingsViewModel): + CustomNavigationLinkView { + SettingsView(settingsViewModel: settingsViewModel) + } + case .claimDevice(let viaBluetooth): + ClaimDeviceNavView( + swinjectHelper: SwinjectHelper.shared, + claimViaBluetooth: viaBluetooth + ) + case .deleteAccount(let deleteAccountViewModel): + CustomNavigationLinkView { + DeleteAccountView(viewModel: deleteAccountViewModel) + } + case .survey(let userId, let appId): + CustomNavigationLinkView { + WebView(userID: userId, appID: appId) + } + case .signIn(let signInViewModel): + CustomNavigationLinkView { + SignInView(viewModel: signInViewModel) + } + case .register(let registerViewModel): + CustomNavigationLinkView { + RegisterView(viewModel: registerViewModel) + } + case .resetPassword(let resetPassViewModel): + CustomNavigationLinkView { + ResetPasswordView(viewModel: resetPassViewModel) + } + case .explorerList(let explorerListViewModel): + NavigationContainerView { + ExplorerStationsListView(viewModel: explorerListViewModel) + } + case .rewardDetails(let rewardDetailsViewModel): + RewardDetailsView(viewModel: rewardDetailsViewModel) + case .webView(let title, let url, let params, let callback): + WebContainerView(title: title, url: url, params: params, redirectParamsCallback: callback) + case .selectStationLocation(let selectStationLocationViewModel): + NavigationContainerView { + SelectStationLocationView(viewModel: selectStationLocationViewModel) + } + } + } +} + +class Router: ObservableObject { + + static let shared = Router() + + @Published var path: [Route] = [] + /// We use this to add an, almost, invisible ovelray above `NavigationStack` to fix an issue with dragging gestures of sheet/popover and navigation stack + /// More info https://stackoverflow.com/questions/71714592/sheet-dismiss-gesture-with-swipe-back-gesture-causes-app-to-freeze + @Published var showDummyOverlay: Bool = false + + let navigationHost = HostingWrapper() + + private init() {} + + func navigateTo(_ route: Route) { + guard path.last != route else { + return + } + + if #available(iOS 16.0, *) { + self.path.append(route) + } else { + let hostingVC = UIHostingController(rootView: route.view) + (navigationHost.hostingController as? UINavigationController)?.pushViewController(hostingVC, animated: true) + } + } + + func popToRoot() { + if #available(iOS 16.0, *) { + self.path = .init() + } else { + (navigationHost.hostingController as? UINavigationController)?.popToRootViewController(animated: true) + } + } + + func pop() { + if #available(iOS 16.0, *) { + self.path.removeLast() + } else { + (navigationHost.hostingController as? UINavigationController)?.popViewController(animated: true) + } + } +} diff --git a/PresentationLayer/Navigation/RouterView.swift b/PresentationLayer/Navigation/RouterView.swift new file mode 100644 index 00000000..64a69a4f --- /dev/null +++ b/PresentationLayer/Navigation/RouterView.swift @@ -0,0 +1,68 @@ +// +// RouterView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 25/7/23. +// + +import SwiftUI + +struct RouterView: View { + let content: () -> Content + @StateObject private var router = Router.shared + + var body: some View { + if #available(iOS 16.0, *) { + NavigationStack(path: $router.path) { + content() + .navigationDestination(for: Route.self) { route in + route.view + } + } + .overlay( + Group { + /// Disable NavigationStack gesture when there is a popover. + /// It's a workaround to fix some interaction issues with navigation stack + /// More info https://stackoverflow.com/questions/71714592/sheet-dismiss-gesture-with-swipe-back-gesture-causes-app-to-freeze + if router.showDummyOverlay { + Color.white.opacity(0.01) + .highPriorityGesture(DragGesture(minimumDistance: 0)) + .ignoresSafeArea() + } + } + ) + } else { + // Fallback on earlier versions + RouterViewController(host: router.navigationHost) { + content() + } + .ignoresSafeArea() + } + } +} + +struct RouterView_Previews: PreviewProvider { + static var previews: some View { + RouterView { + Text(verbatim: "Hellozzz") + } + } +} + +struct RouterViewController: UIViewControllerRepresentable { + let host: HostingWrapper + let content: () -> Content + + func makeUIViewController(context: Context) -> UINavigationController { + let controller = UINavigationController(rootViewController: UIHostingController(rootView: content())) + controller.navigationBar.prefersLargeTitles = true + controller.navigationBar.tintColor = UIColor(colorEnum: .primary) + host.hostingController = controller + return controller + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { + // Hacky way to refresh state of the first view + (uiViewController.viewControllers.first as? UIHostingController)?.rootView = content() // Update content + } +} diff --git a/PresentationLayer/Protocols/SwinjectInterface.swift b/PresentationLayer/Protocols/SwinjectInterface.swift new file mode 100644 index 00000000..58f4c405 --- /dev/null +++ b/PresentationLayer/Protocols/SwinjectInterface.swift @@ -0,0 +1,13 @@ +// +// SwinjectInterface.swift +// PresentationLayer +// +// Created by Hristos Condrea on 6/5/22. +// + +import Foundation +import Swinject + +public protocol SwinjectInterface { + func getContainerForSwinject() -> Container +} diff --git a/PresentationLayer/UI Components/Base Components/Account Confirmation/AccountConfirmationView.swift b/PresentationLayer/UI Components/Base Components/Account Confirmation/AccountConfirmationView.swift new file mode 100644 index 00000000..60ddd8e0 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Account Confirmation/AccountConfirmationView.swift @@ -0,0 +1,60 @@ +// +// AccountConfirmationView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 14/3/23. +// + +import SwiftUI +import Toolkit + +struct AccountConfirmationView: View { + @StateObject var viewModel: AccountConfirmationViewModel + + var body: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + VStack(spacing: CGFloat(.smallSpacing)) { + HStack { + Text(viewModel.title) + .font(.system(size: CGFloat(.smallTitleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + + HStack { + Text(viewModel.descriptionMarkdown?.attributedMarkdown ?? "") + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + } + + BaseTextField(input: $viewModel.password, + textFieldStyle: .accountConfirmation, + error: viewModel.textFieldError) + + Button { + viewModel.confirmButtonTapped() + } label: { + Text(LocalizableString.confirm.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + .disabled(!viewModel.isConfirmButtonEnabled) + } + .WXMCardStyle() + .spinningLoader(show: $viewModel.isLoading) + .onAppear { + Logger.shared.trackScreen(.passwordConfirm) + } + } +} + +struct AccountConfirmationView_Previews: PreviewProvider { + static var previews: some View { + let vm = AccountConfirmationViewModel(title: "Confirm Password To Proceed", + descriptionMarkdown: "In order to remove your station you need to confirm you are the owner of this account.\n**Please type in your password to remove your station.**") + AccountConfirmationView(viewModel: vm) + } +} diff --git a/PresentationLayer/UI Components/Base Components/Account Confirmation/AccountConfirmationViewModel.swift b/PresentationLayer/UI Components/Base Components/Account Confirmation/AccountConfirmationViewModel.swift new file mode 100644 index 00000000..7fe7e4ba --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Account Confirmation/AccountConfirmationViewModel.swift @@ -0,0 +1,69 @@ +// +// AccountConfirmationViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 14/3/23. +// + +import Foundation +import DomainLayer +import Toolkit +import Combine + +class AccountConfirmationViewModel: ObservableObject { + @Published var password: String = "" { + didSet { + isConfirmButtonEnabled = !password.trimWhiteSpaces().isEmpty + } + } + @Published private(set) var isConfirmButtonEnabled: Bool = false + @Published private(set) var textFieldError: TextFieldError? + @Published var isLoading: Bool = false + let title: String + let descriptionMarkdown: String? + + private let useCase: AuthUseCase? + private let completion: GenericCallback? + private var cancellables: Set = [] + + init(title: String, descriptionMarkdown: String? = nil, useCase: AuthUseCase? = nil, completion: GenericCallback? = nil) { + self.title = title + self.descriptionMarkdown = descriptionMarkdown + self.useCase = useCase + self.completion = completion + } + + func confirmButtonTapped() { + performLogin() + } +} + +private extension AccountConfirmationViewModel { + func performLogin() { + do { + isLoading = true + try useCase?.passwordValidation(password: password).sink { [weak self] response in + self?.isLoading = false + + if let error = response.error { + if error.backendError?.code == FailAPICodeEnum.invalidCredentials.rawValue { + self?.textFieldError = .invalidPassword + } else if let errorMessage = response.error?.uiInfo.description?.attributedMarkdown { + Toast.shared.show(text: errorMessage) + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .failure, + .itemId: .custom(response.error?.backendError?.code ?? "")]) + } + } else { + self?.textFieldError = nil + } + + let isValid = response.error == nil + self?.completion?(isValid) + }.store(in: &cancellables) + } catch { + print(error) + isLoading = false + completion?(false) + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/AddButton.swift b/PresentationLayer/UI Components/Base Components/AddButton.swift new file mode 100644 index 00000000..02209311 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/AddButton.swift @@ -0,0 +1,50 @@ +// +// AddButton.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 18/5/22. +// + +import SwiftUI + +struct AddButton: View { + @State private var isShowingAddDeviceSheet = false + + var body: some View { + ZStack { + Button { + isShowingAddDeviceSheet.toggle() + } label: { + Image(asset: .plus) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .top)) + } + .frame(width: CGFloat(.fabButtonsDimension), height: CGFloat(.fabButtonsDimension)) + .background(Color(colorEnum: .primary)) + .cornerRadius(CGFloat(.cardCornerRadius)) + .shadow(radius: ShadowEnum.addButton.radius, x: ShadowEnum.addButton.xVal, y: ShadowEnum.addButton.yVal) + .customSheet( + isPresented: $isShowingAddDeviceSheet + ) { controller in + SelectDeviceTypeView( + dismiss: controller.dismiss, + didSelectClaimFlow: { flow in + controller.dismiss() + switch flow { + case .manual: + Router.shared.navigateTo(.claimDevice(false)) + case .bluetooth: + Router.shared.navigateTo(.claimDevice(true)) + } + } + ) + } + } + } +} + +struct Previews_AddButton_Previews: PreviewProvider { + static var previews: some View { + AddButton() + } +} diff --git a/PresentationLayer/UI Components/Base Components/AttributedLabel.swift b/PresentationLayer/UI Components/Base Components/AttributedLabel.swift new file mode 100644 index 00000000..ab26e6da --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/AttributedLabel.swift @@ -0,0 +1,53 @@ +// +// UILabel.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 6/10/22. +// + +import SwiftUI + +/** + A `UILabel` as a SwiftUI `View`. + Supports `NSAttributedString` which can act as a polyfill for `AttributedString` (which is not available pre-iOS 15). + By default it sets up the internal UILabel for word wrapping and infinite lines, but that can be changed via the `setupUILabel` + callback. + */ +struct AttributedLabel: View { + @Binding var attributedText: NSAttributedString + var setupUILabel: ((UILabel) -> Void)? + + @State private var height: CGFloat = 0 + + var body: some View { + AttributedLabelInternal( + attributedText: $attributedText, + setupUILabel: setupUILabel, + height: $height + ).frame(height: height) + } + + private struct AttributedLabelInternal: UIViewRepresentable { + @Binding var attributedText: NSAttributedString + var setupUILabel: ((UILabel) -> Void)? + @Binding var height: CGFloat + + func makeUIView(context _: Context) -> UILabel { + let label = UILabel() + label.lineBreakMode = .byWordWrapping + label.numberOfLines = 0 + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + setupUILabel?(label) + return label + } + + func updateUIView(_ uiView: UILabel, context _: Context) { + uiView.attributedText = attributedText + let size = uiView.sizeThatFits(CGSize(width: uiView.bounds.width, height: CGFloat.greatestFiniteMagnitude)) + + DispatchQueue.main.async { + height = size.height + } + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/AttributedTextView.swift b/PresentationLayer/UI Components/Base Components/AttributedTextView.swift new file mode 100644 index 00000000..3904f906 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/AttributedTextView.swift @@ -0,0 +1,66 @@ +// +// AttributedTextView.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 23/11/22. +// + +import SwiftUI + +/** + A `UITextView` as a SwiftUI `View`. + Supports `NSAttributedString` which can act as a polyfill for `AttributedString` (which is not available pre-iOS 15). + Use `setupUITextView` to configure the underlying `UITextView`. + callback. + */ +struct AttributedTextView: View { + @Binding var attributedText: NSAttributedString + var setupUITextView: ((UITextView) -> Void)? + + @State private var height: CGFloat = 0 + + var body: some View { + AttributedTextViewInternal( + attributedText: $attributedText, + setupUITextView: setupUITextView, + height: $height + ).frame(height: height) + } + + private struct AttributedTextViewInternal: UIViewRepresentable { + @Binding var attributedText: NSAttributedString + var setupUITextView: ((UITextView) -> Void)? + @Binding var height: CGFloat + + func makeUIView(context _: Context) -> UITextView { + let textView = NonSelectableLinkSupportingTextView() + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.isEditable = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + setupUITextView?(textView) + return textView + } + + func updateUIView(_ uiView: UITextView, context _: Context) { + uiView.attributedText = attributedText + DispatchQueue.main.async { + height = uiView.contentSize.height + } + } + } +} + +private class NonSelectableLinkSupportingTextView: UITextView { + override func point(inside point: CGPoint, with _: UIEvent?) -> Bool { + guard + let pos = closestPosition(to: point), + let range = tokenizer.rangeEnclosingPosition(pos, with: .character, inDirection: .layout(.left)) + else { + return false + } + + let startIndex = offset(from: beginningOfDocument, to: range.start) + return attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil + } +} diff --git a/PresentationLayer/UI Components/Base Components/Badge.swift b/PresentationLayer/UI Components/Base Components/Badge.swift new file mode 100644 index 00000000..dea33507 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Badge.swift @@ -0,0 +1,45 @@ +// +// Badge.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 19/9/23. +// + +import SwiftUI + +private struct BadgeModifier: ViewModifier { + + let show: Bool + @State private var size: CGSize = .zero + + func body(content: Content) -> some View { + content + .overlay { + if show { + VStack { + HStack { + Spacer() + Circle() + .foregroundColor(Color(colorEnum: .favoriteHeart)) + .frame(height: size.height * 0.35) + .offset(x: (size.height * 0.35) / 2.0, y: -(size.height * 0.35) / 4.0) + } + + Spacer() + } + } + } + .sizeObserver(size: $size) + } +} + +extension View { + func badge(show: Bool) -> some View { + modifier(BadgeModifier(show: show)) + } +} + +#Preview { + Text(verbatim: "geroger") + .badge(show: true) +} diff --git a/PresentationLayer/UI Components/Base Components/Base Text Field/BaseTextField.swift b/PresentationLayer/UI Components/Base Components/Base Text Field/BaseTextField.swift new file mode 100644 index 00000000..6bc26d59 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Base Text Field/BaseTextField.swift @@ -0,0 +1,182 @@ +// +// BaseTextField.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 10/5/22. +// + +import SwiftUI + +struct BaseTextField: View { + @Binding var input: String + var caption: String? + @State var showPassword: Bool + @State var showFloatingLabel: Bool + @State var isTextFieldFocused: Bool + var textFieldStyle: BaseTextFieldEnum + var error: TextFieldError? + let keyboardType: UIKeyboardType + let maskStringOffset = 5 + var isInputForDeleteAccount: Bool + + public init( + input: Binding, + caption: String? = nil, + showPassword: Bool = true, + showFloatingLabel: Bool = false, + isTextFieldFocused: Bool = false, + textFieldStyle: BaseTextFieldEnum, + error: TextFieldError? = nil, + keyboardType: UIKeyboardType = .default, + isInputForDeleteAccount: Bool = false + ) { + _input = input + self.caption = caption + self.showPassword = showPassword + self.showFloatingLabel = showFloatingLabel + self.isTextFieldFocused = isTextFieldFocused + self.textFieldStyle = textFieldStyle + self.error = error + self.keyboardType = keyboardType + self.isInputForDeleteAccount = isInputForDeleteAccount + } + + var body: some View { + VStack(spacing: CGFloat(.minimumSpacing)) { + if showFloatingLabel, !isInputForDeleteAccount { + floatingLabel + } + HStack { + inputField + } + + captionHStack + }.onChange(of: input) { changedInput in + withAnimation { + showFloatingLabel = !changedInput.isEmpty + } + }.onAppear { + showFloatingLabel = !input.isEmpty + } + } + + var inputField: some View { + HStack { + leftIcon + Group { + switch showPassword == textFieldStyle.isPassword { + case true: + SecureField(isInputForDeleteAccount ? "Type your Password" : textFieldStyle.label, text: $input) + .textContentType(.password) + .foregroundColor(Color(colorEnum: .text)) + case false: + if textFieldStyle == .currentWXMAddress { + TextField(input.maskString(offsetStart: maskStringOffset, offsetEnd: maskStringOffset, maskedCharactersToShow: maskStringOffset), text: .constant("")) + .disabled(textFieldStyle == .currentWXMAddress ? true : false) + .autocapitalization(.none) + .foregroundColor(Color(colorEnum: .text)) + } else if textFieldStyle == .user { + TextField(textFieldStyle.label, text: $input, onEditingChanged: { editingChanged in + if editingChanged { + isTextFieldFocused = true + } else { + isTextFieldFocused = false + } + }) + .foregroundColor(Color(colorEnum: .text)) + .autocapitalization(.none) + .keyboardType(keyboardType) + .textContentType(.emailAddress) + } else { + TextField(textFieldStyle.label, text: $input, onEditingChanged: { editingChanged in + if editingChanged { + isTextFieldFocused = true + } else { + isTextFieldFocused = false + } + }) + .foregroundColor(Color(colorEnum: .text)) + .keyboardType(keyboardType) + .autocapitalization(.none) + } + } + } + rightIcon + } + .foregroundColor(Color(colorEnum: .midGrey)) + .padding(CGFloat(.smallSidePadding)) + .font(.system(size: CGFloat(.normalMediumFontSize))) + .overlay(overlayViewColor) + } + + @ViewBuilder + var leftIcon: some View { + if let icon = textFieldStyle.leftIcon { + icon + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + .padding(.trailing, CGFloat(.normalMediumFontSize)) + } + } + + @ViewBuilder + var rightIcon: some View { + if let icon = textFieldStyle.rightIcon { + Button { + rightIconAction() + } label: { + icon + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + + } + } + } + + var floatingLabel: some View { + HStack { + Text(textFieldStyle.label).font(.system(size: CGFloat(.smallFontSize), weight: .bold, design: .default)).foregroundColor(Color(colorEnum: .primary)) + Spacer() + } + } + + var captionHStack: some View { + HStack { + if let error = error { + Text(error.description).font(.system(size: CGFloat(.smallFontSize))).foregroundColor(Color(colorEnum: .error)) + } + + Spacer() + + if let caption { + Text(caption).font(.system(size: CGFloat(.caption))).foregroundColor(Color(colorEnum: .text)) + } + } + } + + private func rightIconAction() { + switch textFieldStyle { + case .password, .accountConfirmation: + showPassword.toggle() + default: + break + } + } + + private var overlayViewColor: some View { + if isTextFieldFocused && error == nil { + return RoundedRectangle(cornerRadius: CGFloat(.lightCornerRadius)).stroke(Color(colorEnum: .primary)) + } else if !isTextFieldFocused && error == nil { + return RoundedRectangle(cornerRadius: CGFloat(.lightCornerRadius)).stroke(Color(colorEnum: .midGrey)) + } else if error != nil && isTextFieldFocused { + return RoundedRectangle(cornerRadius: CGFloat(.lightCornerRadius)).stroke(Color(colorEnum: .primary)) + } else { + return RoundedRectangle(cornerRadius: CGFloat(.lightCornerRadius)).stroke(Color(colorEnum: .error)) + } + } + + func shareWalletAddress(walletAddress: String) { + let activityVC = UIActivityViewController(activityItems: [walletAddress], applicationActivities: nil) + UIApplication.shared.windows.first?.rootViewController?.present(activityVC, animated: true, completion: nil) + } +} diff --git a/PresentationLayer/UI Components/Base Components/Base Text Field/BaseTextFieldEnum.swift b/PresentationLayer/UI Components/Base Components/Base Text Field/BaseTextFieldEnum.swift new file mode 100644 index 00000000..ad62ac9c --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Base Text Field/BaseTextFieldEnum.swift @@ -0,0 +1,91 @@ +// +// BaseTextFieldEnum.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 10/5/22. +// + +import SwiftUI + +enum BaseTextFieldEnum { + case user + case name + case surname + case password + case email + case mandatoryEmail + case qrCodesScan + case serialNumber + case currentWXMAddress + case newWXMAddress + case heliumDeviceDevEUI + case heliumDeviceKey + case locationSearch + case accountConfirmation + + var isPassword: Bool { + switch self { + case .password, .accountConfirmation: + return true + default: + return false + } + } + + var leftIcon: Image? { + switch self { + case .user, .name, .surname: + return Image(asset: .user) + case .password, .accountConfirmation: + return Image(asset: .lock) + case .email, .mandatoryEmail: + return Image(asset: .email) + default: + return nil + } + } + + var rightIcon: Image? { + switch self { + case .password, .accountConfirmation: + return Image(asset: .eye) + case .qrCodesScan: + return Image(asset: .qrCode) + case .locationSearch: + return Image(asset: .search) + default: + return nil + } + } + + var label: String { + switch self { + case .user, .email: + return LocalizableString.email.localized + case .mandatoryEmail: + return LocalizableString.mandatoryEmail.localized + case .name: + return LocalizableString.firstName.localized + case .surname: + return LocalizableString.lastName.localized + case .password: + return LocalizableString.password.localized + case .serialNumber: + return LocalizableString.ClaimDevice.enterSerialNumberTitle.localized + case .qrCodesScan: + return LocalizableString.Wallet.enterAddressTitle.localized + case .newWXMAddress: + return LocalizableString.Wallet.enterAddressTitle.localized + case .heliumDeviceDevEUI: + return LocalizableString.ClaimDevice.devEUIFieldHint.localized + case .heliumDeviceKey: + return LocalizableString.ClaimDevice.keyFieldHint.localized + case .locationSearch: + return LocalizableString.ClaimDevice.confirmLocationSearchHint.localized + case .accountConfirmation: + return LocalizableString.typeYourPassword.localized + default: + return "" + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/CTAView.swift b/PresentationLayer/UI Components/Base Components/CTAView.swift new file mode 100644 index 00000000..345f2e85 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/CTAView.swift @@ -0,0 +1,61 @@ +// +// CTAView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 3/8/23. +// + +import SwiftUI +import Toolkit + +struct CTAContainerView: View { + let ctaObject: CTAObject + + var body: some View { + HStack(spacing: CGFloat(.smallSpacing)) { + HStack { + Text(ctaObject.description) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + + Spacer(minLength: 0.0) + } + + Button { + ctaObject.buttonAction() + } label: { + HStack(spacing: CGFloat(.smallToMediumSpacing)) { + Text(ctaObject.buttonFontIcon.rawValue) + .font(.fontAwesome(font: .FAPro, size: CGFloat(.mediumFontSize))) + Text(ctaObject.buttonTitle) + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + } + .buttonStyle(WXMButtonStyle.filled()) + .fixedSize() + } + .padding(.trailing, CGFloat(.smallSidePadding)) + .padding(.leading, CGFloat(.mediumSidePadding)) + .padding(.vertical, CGFloat(.smallSidePadding)) + .WXMCardStyle(backgroundColor: Color(colorEnum: .top), + insideHorizontalPadding: 0.0, + insideVerticalPadding: 0.0) + } +} + +extension CTAContainerView { + struct CTAObject { + let description: String + let buttonTitle: String + let buttonFontIcon: FontIcon + let buttonAction: VoidCallback + } +} + +struct CTAContainerView_Previews: PreviewProvider { + static var previews: some View { + CTAContainerView(ctaObject: .init(description: "Add weather station to your favourites to see historical & forecast data.", + buttonTitle: "Follow", + buttonFontIcon: .heart) {}) + } +} diff --git a/PresentationLayer/UI Components/Base Components/CardWarningView.swift b/PresentationLayer/UI Components/Base Components/CardWarningView.swift new file mode 100644 index 00000000..4fe9d241 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/CardWarningView.swift @@ -0,0 +1,120 @@ +// +// CardWarningView.swift +// PresentationLayer +// +// Created by Pantelis Giazitsis on 31/1/23. +// + +import SwiftUI + +/// A card to show warning. Requires `title` `message`. If passed a `closeAction` will render an `x` button on the top right side. +/// `content` used to provide a custom view which will be rendered below the`message` text. If `showContentFullWidth` is true the `content` will cover the view edge to edge +struct CardWarningView: View { + var type: CardWarningType = .warning + var showIcon = true + var title: String? + let message: String + var showContentFullWidth: Bool = false + var showBorder: Bool = false + let closeAction: (() -> Void)? + var content: () -> Content + + var body: some View { + VStack(spacing: CGFloat(.smallSpacing)) { + HStack(spacing: CGFloat(.smallSpacing)) { + if showIcon { + Image(asset: type.icon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: type.iconColor)) + } + + VStack(alignment: .leading, spacing: CGFloat(.minimumSpacing)) { + HStack { + if let title { + Text(title) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + } + + Spacer() + + if let closeAction { + Button(action: closeAction) { + Image(asset: .toggleXMark) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + } + } + } + + Text(message.attributedMarkdown ?? "") + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + .fixedSize(horizontal: false, vertical: true) + + if !showContentFullWidth { + content() + } + } + } + + if showContentFullWidth { + content() + } + } + .WXMCardStyle(backgroundColor: Color(colorEnum: type.tintColor), + cornerRadius: CGFloat(.buttonCornerRadius)) + .if(showBorder) { view in + view.strokeBorder(color: Color(colorEnum: type.iconColor), lineWidth: 1.0, radius: CGFloat(.buttonCornerRadius)) + } + } +} + +enum CardWarningType { + case warning + case error + case info + + var icon: AssetEnum { + switch self { + case .warning: + return .warningIcon + case .error: + return .errorIcon + case .info: + return .infoIcon + } + } + + var iconColor: ColorEnum { + switch self { + case .warning: + return .warning + case .error: + return .error + case .info: + return .darkestBlue + } + } + + var tintColor: ColorEnum { + switch self { + case .warning: + return .warningTint + case .error: + return .errorTint + case .info: + return .blueTint + } + } +} + +struct Previews_CardWarningView_Previews: PreviewProvider { + static var previews: some View { + CardWarningView(type: .info, + title: "This is title", + message: "This is a warning text", closeAction: nil) { + EmptyView() + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/CircleRadius.swift b/PresentationLayer/UI Components/Base Components/CircleRadius.swift new file mode 100644 index 00000000..f30180ef --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/CircleRadius.swift @@ -0,0 +1,29 @@ +// +// CircleRadius.swift +// PresentationLayer +// +// Created by danaekikue on 20/6/22. +// + +import SwiftUI + +struct CircleRadius: View { + var isOptionActive: Bool + let outerCircleWidth: CGFloat = 18 + let innerCircleWidth: CGFloat = 9 + let borderWidth: CGFloat = 2 + + var body: some View { + ZStack { + Circle() + .inset(by: 1) + .stroke(Color(colorEnum: isOptionActive ? .primary : .text), lineWidth: borderWidth) + .frame(width: outerCircleWidth, height: outerCircleWidth) + if isOptionActive { + Circle() + .fill(Color(colorEnum: .primary)) + .frame(width: innerCircleWidth, height: innerCircleWidth) + } + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/Custom Sheet/BottomSheetModifier.swift b/PresentationLayer/UI Components/Base Components/Custom Sheet/BottomSheetModifier.swift new file mode 100644 index 00000000..16b31ee4 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Custom Sheet/BottomSheetModifier.swift @@ -0,0 +1,121 @@ +// +// BottomSheetModifier.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 29/5/23. +// + +import SwiftUI +import Toolkit + +struct BottomSheetModifier: ViewModifier { + @Binding var show: Bool + var fitContent: Bool = false + var initialDetentId: UISheetPresentationController.Detent.Identifier? + let content: () -> V + @State private var hostingWrapper: HostingWrapper = HostingWrapper() + @State private var contentSize: CGSize = .zero + + func body(content: Content) -> some View { + content + .if(fitContent) { view in + view.customSheet(isPresented: $show) { _ in + VStack { + Capsule() + .foregroundColor(Color(colorEnum: .layer2)) + .frame(width: 40.0, height: 5.0) + .padding(.top) + + self.content() + .fixedSize(horizontal: false, vertical: true) + } + .WXMCardStyle( + backgroundColor: Color(colorEnum: .layer1), + insideHorizontalPadding: 0, + insideVerticalPadding: 0 + ) + } + } + .if(!fitContent) { view in + view.onChange(of: show) { _ in + if show, hostingWrapper.hostingController == nil { + let controller = BottomSheetHostingController(rootView: self.content()) + + (controller.presentationController as? UISheetPresentationController)?.detents = [.medium(), .large()] + (controller.presentationController as? UISheetPresentationController)?.selectedDetentIdentifier = initialDetentId + + /* + For some crazy reason the following line causes a memory leak in versions < iOS 17. + So the easiest and cleaner solution is to omit the grabber. + https://developer.apple.com/forums/thread/729183 + */ + if #available(iOS 17.0, *) { + (controller.presentationController as? UISheetPresentationController)?.prefersGrabberVisible = true + } + + controller.willDismissCallback = { + show = false + } + UIApplication.shared.topViewController?.present(controller, animated: true) + hostingWrapper.hostingController = controller + } else if !show { + hostingWrapper.hostingController?.dismiss(animated: true) + } + } + } + } + + private class BottomSheetHostingController: UIHostingController { + + var willDismissCallback: VoidCallback? + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if isBeingDismissed { + willDismissCallback?() + } + } + } +} + +extension View { + + @ViewBuilder + func bottomSheet(show: Binding, + fitContent: Bool = false, + initialDetentId: UISheetPresentationController.Detent.Identifier? = nil, + content: @escaping () -> Content) -> some View { + modifier(BottomSheetModifier(show: show, fitContent: fitContent, initialDetentId: initialDetentId, content: content)) + } + + @ViewBuilder + func bottomInfoView(info: (title: String?, description: String)?) -> some View { + ZStack { + Color(colorEnum: .layer1) + .ignoresSafeArea() + + VStack(spacing: CGFloat(.mediumSpacing)) { + if let info = info { + HStack { + if let title = info.title { + Text(title) + .font(.system(size: CGFloat(.largeFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + } + + Spacer() + } + + HStack { + Text(info.description.attributedMarkdown ?? "") + .font(.system(size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + } + } + .padding(CGFloat(.largeSidePadding)) + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/Custom Sheet/CustomSheet.swift b/PresentationLayer/UI Components/Base Components/Custom Sheet/CustomSheet.swift new file mode 100644 index 00000000..a65441e1 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Custom Sheet/CustomSheet.swift @@ -0,0 +1,249 @@ +// +// CustomSheet.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 24/9/22. +// + +import SwiftUI + +public class SheetController { + let dismiss: () -> Void + init(dismiss: @escaping () -> Void) { + self.dismiss = dismiss + } +} + +public extension View { + /// Presents any content similarly to a + /// [SwiftUI sheet](https://developer.apple.com/documentation/swiftui/button/sheet(ispresented:ondismiss:content:)) + /// but enables 100% custom styling as well as dynamic height. + /// > Uses UIKit and UIHostingController under the hood to make sure the content is always presented on top of everything else. + /// The presented content is completely responsible for its own presentation. + /// No sheet background or styling is provided, besides a semi-transparent black overlay. + func customSheet( + isPresented: Binding, + allowSwipeAndTapToDismiss: Binding = .constant(true), + shouldBeCentered: Bool = false, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (SheetController) -> Content + ) -> some View { + return modifier( + CustomSheetModifier( + customSheet: CustomSheet( + isPresented: isPresented, + allowSwipeAndTapToDismiss: allowSwipeAndTapToDismiss, + shouldBeCentered: shouldBeCentered, + onDismiss: onDismiss, + content: content + ) + ) + ) + } +} + +private struct CustomSheet: View { + @Binding var isPresented: Bool + @Binding var allowSwipeAndTapToDismiss: Bool + var shouldBeCentered: Bool + + let onDismiss: (() -> Void)? + var content: (SheetController) -> Content + + @State private var dragState = DragState.inactive + + @State private var containerHeight: CGFloat = 0 + @State private var safeAreaInsets: EdgeInsets = .init() + @State private var contentHeight: CGFloat = 0 + @State private var isAdded = false + + private let animation = Animation.interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0) + + var body: some View { + let drag = DragGesture() + .onChanged { value in + dragState = .dragging(translation: value.translation) + } + .onEnded(onDragEnded) + + GeometryReader { containerGeometry in + let maxHeight = containerGeometry.size.height - containerGeometry.safeAreaInsets.top - containerGeometry.safeAreaInsets.bottom + + ZStack { + backgroundOverlay() + VStack { + if !shouldBeCentered { + Spacer() + } + + content( + SheetController(dismiss: { + dismiss() + }) + ) + .anchorPreference(key: ViewAnchorKey.self, value: .bounds) { + ViewAnchorData(size: containerGeometry[$0].size) + } + .frame(maxHeight: maxHeight, alignment: .bottom) + .offset(y: currentVerticalOffset()) + } + .onPreferenceChange(ViewAnchorKey.self) { value in + containerHeight = containerGeometry.size.height + safeAreaInsets = containerGeometry.safeAreaInsets + contentHeight = value.size.height + safeAreaInsets.bottom + } + .gesture(drag) + } + .onAppear { + withAnimation(animation) { + isAdded = true + } + } + } + } + + struct ViewAnchorKey: PreferenceKey { + static var defaultValue: CustomSheet.ViewAnchorData { + return ViewAnchorData(size: CGSize.zero) + } + + static func reduce(value: inout ViewAnchorData, nextValue: () -> ViewAnchorData) { + value.size = nextValue().size + } + } + + struct ViewAnchorData: Equatable { + var size: CGSize + static func == (_: ViewAnchorData, _: ViewAnchorData) -> Bool { + return false + } + } + + enum DragState: Equatable { + case inactive + case dragging(translation: CGSize) + + var translation: CGSize { + switch self { + case .inactive: + return .zero + case let .dragging(translation): + return translation + } + } + + var isDragging: Bool { + switch self { + case .inactive: + return false + case .dragging: + return true + } + } + } +} + +private extension CustomSheet { + func backgroundOverlay() -> some View { + Spacer() + .background(currentBackgroundColor()) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + if !allowSwipeAndTapToDismiss { + return + } + dismiss() + } + } + + func currentBackgroundColor() -> Color { + if !isAdded { + return Color.clear + } + + let progress = 1 - dragState.translation.height / min(contentHeight, containerHeight) + return Color.black.opacity( + 0.6 * min(max(0, progress), 1) + ) + } + + func currentVerticalOffset() -> CGFloat { + if !isAdded { + return contentHeight + } + + let offsetDueToOverflow = max(0, contentHeight - containerHeight) + return max(offsetDueToOverflow, dragState.translation.height + offsetDueToOverflow) + } + + func onDragEnded(drag: DragGesture.Value) { + let contentHeight = min(containerHeight, contentHeight) + let dragThreshold = abs(contentHeight * 0.5) + if + allowSwipeAndTapToDismiss, + drag.predictedEndTranslation.height > dragThreshold || drag.translation.height > dragThreshold + { + dismiss() + } else { + withAnimation(animation) { + dragState = .inactive + } + } + } + + func dismiss() { + withAnimation(animation) { + isAdded = false + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isPresented = false + onDismiss?() + } + } +} + +private struct CustomSheetModifier: ViewModifier { + let customSheet: CustomSheet + let manager = CustomSheetManager.default + + func body(content: Content) -> some View { + return content + .onChange(of: customSheet.isPresented) { isPresented in + var host = manager.customSheetHostController + + if isPresented { + + if host == nil { + let hc = UIHostingController(rootView: AnyView(customSheet)) + let animator = OverlayAnimator() + hc.modalPresentationStyle = .custom + hc.transitioningDelegate = animator + + if let rootVC = UIApplication.shared.currentKeyWindow?.rootViewController { + manager.customSheetHostController = hc + hc.view.backgroundColor = UIColor.clear + rootVC.present(hc, animated: true) + } + } + } else { + cleanUp() + } + } + } + + func cleanUp() { + let host = manager.customSheetHostController + host?.dismiss(animated: false) + manager.customSheetHostController = nil + } +} + +private class CustomSheetManager { + private init() {} + + var customSheetHostController: UIHostingController? + var animator: OverlayAnimator? + + static let `default` = CustomSheetManager() +} diff --git a/PresentationLayer/UI Components/Base Components/Custom Sheet/OverlayAnimator.swift b/PresentationLayer/UI Components/Base Components/Custom Sheet/OverlayAnimator.swift new file mode 100644 index 00000000..2b4bf21b --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Custom Sheet/OverlayAnimator.swift @@ -0,0 +1,63 @@ +// +// OverlayAnimator.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 15/3/23. +// + +import Foundation +import UIKit + +public class OverlayAnimator: NSObject, UIViewControllerAnimatedTransitioning, UIViewControllerTransitioningDelegate { + + private var incoming = true + private let duration = 0.15 + + public func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + duration + } + + public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + self + } + + public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + incoming = false + return self + } + + public func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + + guard + let fromVC = transitionContext.viewController(forKey: .from), + let toVC = transitionContext.viewController(forKey: .to) else { + return + } + + let containerView = transitionContext.containerView + + switch incoming { + case true: + + toVC.view.alpha = 0.0 + + toVC.view.frame = containerView.bounds + containerView.addSubview(toVC.view) + + UIView.animate(withDuration: duration, delay: 0.0, options: .curveEaseIn) { + toVC.view.alpha = 1.0 + } completion: { _ in + transitionContext.completeTransition(true) + } + + case false: + + UIView.animate(withDuration: duration, delay: 0.0, options: .curveEaseIn) { + fromVC.view.alpha = 0.0 + } completion: { _ in + fromVC.view.removeFromSuperview() + transitionContext.completeTransition(true) + } + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/CustomPicker.swift b/PresentationLayer/UI Components/Base Components/CustomPicker.swift new file mode 100644 index 00000000..edebe095 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/CustomPicker.swift @@ -0,0 +1,188 @@ +// +// CustomPicker.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 7/12/22. +// + +import SwiftUI + +struct CustomPicker: View { + let items: [Item] + @Binding var selectedItem: Item? + let textCallback: (Item?) -> String + + @State private var isOpen: Bool = false + + private let PICKER_HEIGHT: CGFloat = 50 + private let ROW_HEIGHT: CGFloat = 50 + + var body: some View { + content + } + + var content: some View { + return pickerField + .onChange(of: isOpen) { isOpen in + guard isOpen else { + CustomPickerManager.default.customPickerHostController?.view.removeFromSuperview() + CustomPickerManager.default.customPickerHostController = nil + return + } + + if let rootView = UIApplication.shared.topViewController?.view { + CustomPickerManager.default.containerBounds = rootView.bounds + let host = UIHostingController(rootView: AnyView(pickerListWithOverlay)) + CustomPickerManager.default.customPickerHostController = host + host.view.backgroundColor = UIColor.clear + host.view.frame = rootView.bounds + rootView.addSubview(host.view) + } + } + } + + @ViewBuilder + var pickerField: some View { + let item = selectedItem ?? items.first + GeometryReader { geometry in + Button { + self.isOpen.toggle() + } label: { + HStack { + Text(textCallback(item)) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + + Image(asset: .chevronDown) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + .frame(width: 30) + } + .frame(width: geometry.size.width - 32) + .padding() + } + .buttonStyle(CustomColorButtonStyle()) + .frame(height: PICKER_HEIGHT) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(Color(colorEnum: .midGrey), lineWidth: 1) + ) + .anchorPreference(key: ViewAnchorKey.self, value: .bounds) { + CustomPickerManager.default.pickerFieldFrame = geometry.frame(in: .global) + return ViewAnchorData(bounds: geometry[$0]) + } + } + .frame(height: PICKER_HEIGHT) + .padding(.horizontal, 1) + .onPreferenceChange(ViewAnchorKey.self) { _ in } + } + + var pickerListWithOverlay: some View { + ZStack(alignment: .topLeading) { + let offsetX = CustomPickerManager.default.pickerFieldFrame.origin.x + let offsetY = CustomPickerManager.default.pickerFieldFrame.origin.y + PICKER_HEIGHT + let width = CustomPickerManager.default.pickerFieldFrame.size.width + + let totalHeight = CustomPickerManager.default.containerBounds.size.height + let maxHeight = max(0, totalHeight - offsetY - totalHeight * 0.1) + let height = min(CGFloat(items.count) * ROW_HEIGHT, maxHeight) + + Color.black.opacity(0.01) + pickerList + .offset( + x: offsetX, + y: offsetY + ) + .frame( + maxWidth: width, + maxHeight: height, + alignment: .topLeading + ) + } + .onTapGesture { + isOpen = false + } + .edgesIgnoringSafeArea(.all) + } + + @ViewBuilder + var pickerList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(items, id: \.self) { item in + row(item) + } + } + } + .background(Color(colorEnum: .top)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(Color(.lightGray), lineWidth: 1) + ) + } +} + +private extension CustomPicker { + func row(_ item: Item) -> some View { + let row = Button { + selectedItem = item + isOpen = false + } label: { + VStack { + Spacer() + + HStack { + Text(textCallback(item)) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + .padding(.horizontal, 16) + + Spacer() + + WXMDivider() + } + } + .buttonStyle( + CustomColorButtonStyle( + backgroundColor: selectedItem == item ? Color(colorEnum: .blueTint) : Color(colorEnum: .top) + ) + ) + .frame(height: ROW_HEIGHT) + + return row + } + + struct ViewAnchorKey: PreferenceKey { + static var defaultValue: CustomPicker.ViewAnchorData { + return ViewAnchorData(bounds: CGRect.zero) + } + + static func reduce(value: inout ViewAnchorData, nextValue: () -> ViewAnchorData) { + value.bounds = nextValue().bounds + } + } + + struct ViewAnchorData: Equatable { + var bounds: CGRect + static func == (_: ViewAnchorData, _: ViewAnchorData) -> Bool { + return false + } + } +} + +private class CustomPickerManager { + private init() {} + + var customPickerHostController: UIHostingController? + + static let `default` = CustomPickerManager() + + var pickerFieldFrame: CGRect = .zero + var containerBounds: CGRect = .zero + var contentHeight: CGFloat = 0 +} diff --git a/PresentationLayer/UI Components/Base Components/CustomSegmentView.swift b/PresentationLayer/UI Components/Base Components/CustomSegmentView.swift new file mode 100644 index 00000000..d3713d51 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/CustomSegmentView.swift @@ -0,0 +1,213 @@ +// +// CustomSegmentView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 3/3/23. +// + +import SwiftUI + +struct CustomSegmentView: View { + private let segments: [String] + @Binding private var selectedIndex: Int + private let style: Style + private let selectorPadding: CGFloat = 20.0 + private let cornerRadius: CGFloat = 60.0 + @State private var sizes: [SizeWrapper] + @State private var containerSize: CGSize = .zero + + init(options: [String], selectedIndex: Binding, style: Style = .normal) { + self.style = style + self.segments = options + self._selectedIndex = selectedIndex + self.sizes = (0.. 0 else { + return 0.0 + } + + let elementsTotalWidth = sizes.reduce(0.0) { $0 + $1.size.width } + let width = containerSize.width - 2.0 * selectorPadding - elementsTotalWidth + + return width / CGFloat(spacersCount) + } + + func selectorSizeForIndex(_ index: Int) -> CGSize { + let segmentSize = sizes[index].size + let sidePadding = style == .normal ? 2.0 * selectorPadding : 0.0 + let width = segmentSize.width + sidePadding + let height = segmentSize.height + return CGSize(width: width, height: height) + } + + func selectorOffsetForIndex(_ index: Int) -> CGFloat { + let spacerWidth = spacerWidth + let floatIndex = CGFloat(index) + let elementsWidth = index > 0 ? (0.. Void)? + let cancelAction: (() -> Void)? + let retryAction: (() -> Void)? +} + +extension FailSuccessStateObject { + static var mockSuccessObj: FailSuccessStateObject { + FailSuccessStateObject(type: .changeFrequency, + title: "Station Updated!", + subtitle: "Your station is updated to the latest Firmware!", + cancelTitle: nil, + retryTitle: "View Station", + contactSupportAction: nil, + cancelAction: nil, + retryAction: nil) + } + + static var mockErrorObj: FailSuccessStateObject { + return FailSuccessStateObject(type: .changeFrequency, + title: "Update Failed", + subtitle: "Pairing failed, please try again! If the problem persists, please contact our support team at support.weatherxm.com \n \n Please make sure to mention that you’re facing an **Error 666** for faster resolution".attributedMarkdown, + cancelTitle: "Cancel", + retryTitle: "Retry Updating", + actionButtonsAtTheBottom: false, + contactSupportAction: nil, + cancelAction: nil, + retryAction: {}) + } +} diff --git a/PresentationLayer/UI Components/Base Components/Fail Success /FailSuccessViewModifier.swift b/PresentationLayer/UI Components/Base Components/Fail Success /FailSuccessViewModifier.swift new file mode 100644 index 00000000..99e541bb --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Fail Success /FailSuccessViewModifier.swift @@ -0,0 +1,78 @@ +// +// FailSuccessViewModifier.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 20/6/23. +// + +import SwiftUI + +private struct FailSuccessModifier: ViewModifier { + @Binding var show: Bool + let isSuccess: Bool + let obj: FailSuccessStateObject + + func body(content: Content) -> some View { + content + .if(show) { view in + view.hidden() + } + .overlay { + if show { + stateView + .padding() + } + } + } + + @ViewBuilder + private var stateView: some View { + if isSuccess { + SuccessView(obj: obj) + } else { + FailView(obj: obj) + } + } +} + +extension View { + @ViewBuilder + func fail(show: Binding, obj: FailSuccessStateObject?) -> some View { + if let obj { + modifier(FailSuccessModifier(show: show, isSuccess: false, obj: obj)) + } else { + self + } + } + + @ViewBuilder + func success(show: Binding, obj: FailSuccessStateObject?) -> some View { + if let obj { + modifier(FailSuccessModifier(show: show, isSuccess: true, obj: obj)) + } else { + self + } + } +} + +struct Previews_Fail_Previews: PreviewProvider { + static var previews: some View { + VStack { + Color.gray + .ignoresSafeArea() + } + .fail(show: .constant(true), obj: .mockErrorObj) + .padding() + } +} + +struct Previews_Success_Previews: PreviewProvider { + static var previews: some View { + VStack { + Color.gray + .ignoresSafeArea() + } + .success(show: .constant(true), obj: .mockSuccessObj) + .padding() + } +} diff --git a/PresentationLayer/UI Components/Base Components/Fail Success /FailView.swift b/PresentationLayer/UI Components/Base Components/Fail Success /FailView.swift new file mode 100644 index 00000000..7544bf59 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Fail Success /FailView.swift @@ -0,0 +1,116 @@ +// +// FailView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 3/2/23. +// + +import SwiftUI +import Toolkit + +struct FailView: View { + let obj: FailSuccessStateObject + + @State private var bottomButtonsSize: CGSize = .zero + private let iconDimensions: CGFloat = 150 + + var body: some View { + ZStack { + if obj.actionButtonsAtTheBottom { + VStack { + Spacer() + + actionButtons + .sizeObserver(size: $bottomButtonsSize) + } + } + + ScrollView(showsIndicators: false) { + VStack(spacing: CGFloat(.defaultSpacing)) { + Spacer() + lottieViewLoading + .background { + Circle().foregroundColor(Color(colorEnum: .errorTint)) + } + + VStack(spacing: CGFloat(.smallSpacing)) { + Text(obj.title) + .font(.system(size: CGFloat(.largeTitleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + .multilineTextAlignment(.center) + + if let subtitle = obj.subtitle { + Text(subtitle) + .font(.system(size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .multilineTextAlignment(.center) + } + } + + contactSupportButton + + if !obj.actionButtonsAtTheBottom { + actionButtons + } + + Spacer() + } + } + .padding(.bottom, bottomButtonsSize.height) + .environment(\.openURL, OpenURLAction { url in + if url.absoluteString == LocalizableString.ClaimDevice.failedTextLinkURL.localized { + return .handled + } + return .systemAction + }) + } + .onAppear { + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .failure, + .contentId: .failureContentId, + .itemId: .custom(obj.type.description)]) + } + } +} + +private extension FailView { + @ViewBuilder + var lottieViewLoading: some View { + LottieView(animationCase: AnimationsEnums.fail.animationString, loopMode: .playOnce) + .frame(width: iconDimensions, height: iconDimensions) + } + + var contactSupportButton: some View { + Button { + obj.contactSupportAction?() + } label: { + Text(LocalizableString.contactSupport.localized) + } + .buttonStyle(WXMButtonStyle()) + } + + var actionButtons: some View { + HStack(spacing: CGFloat(.mediumSpacing)) { + if let cancelAction = obj.cancelAction { + Button(action: cancelAction) { + Text(obj.cancelTitle ?? "") + } + .buttonStyle(WXMButtonStyle()) + } + + if let retryAction = obj.retryAction { + Button(action: retryAction) { + Text(obj.retryTitle ?? "") + } + .buttonStyle(WXMButtonStyle.filled()) + } + } + .sizeObserver(size: $bottomButtonsSize) + } +} + +struct FailView_Previews: PreviewProvider { + static var previews: some View { + FailView(obj: FailSuccessStateObject.mockErrorObj) + .padding() + } +} diff --git a/PresentationLayer/UI Components/Base Components/Fail Success /SuccessView.swift b/PresentationLayer/UI Components/Base Components/Fail Success /SuccessView.swift new file mode 100644 index 00000000..9537f7ff --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Fail Success /SuccessView.swift @@ -0,0 +1,82 @@ +// +// SuccessView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 3/2/23. +// + +import SwiftUI +import Toolkit + +struct SuccessView: View { + let title: String + let subtitle: AttributedString? + let buttonTitle: String + let buttonAction: VoidCallback? + + @State private var bottomButtonsSize: CGSize = .zero + private let iconDimensions: CGFloat = 150.0 + + var body: some View { + ZStack { + if let buttonAction { + VStack { + Spacer() + Button(action: buttonAction) { + Text(buttonTitle) + } + .buttonStyle(WXMButtonStyle.filled()) + .sizeObserver(size: $bottomButtonsSize) + } + } + + VStack(spacing: CGFloat(.defaultSpacing)) { + lottieViewLoading + .background { + Circle().foregroundColor(Color(colorEnum: .successTint)) + } + + VStack(spacing: CGFloat(.smallSpacing)) { + Text(title) + .font(.system(size: CGFloat(.largeTitleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + + if let subtitle { + Text(subtitle) + .font(.system(size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.bottom, bottomButtonsSize.height) + } + } +} + +extension SuccessView { + init(obj: FailSuccessStateObject) { + self.title = obj.title + self.subtitle = obj.subtitle + self.buttonTitle = obj.retryTitle ?? "" + self.buttonAction = obj.retryAction + } +} + +private extension SuccessView { + @ViewBuilder + var lottieViewLoading: some View { + LottieView(animationCase: AnimationsEnums.success.animationString, loopMode: .playOnce) + .frame(width: iconDimensions, height: iconDimensions) + } +} + +struct SuccessView_Previews: PreviewProvider { + static var previews: some View { + SuccessView(title: "Station Updated!", + subtitle: "Your station is updated to the latest Firmware!", + buttonTitle: "View Station") {} + .padding() + } +} diff --git a/PresentationLayer/UI Components/Base Components/Indication/Indication.swift b/PresentationLayer/UI Components/Base Components/Indication/Indication.swift new file mode 100644 index 00000000..fa1b6773 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Indication/Indication.swift @@ -0,0 +1,48 @@ +// +// Indication.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 1/11/23. +// + +import Foundation +import SwiftUI + +private struct IndicationModifier: ViewModifier { + + @Binding var show: Bool + let borderColor: Color + let bgColor: Color + let content: () -> V + + func body(content: Content) -> some View { + if show { + VStack(spacing: 0.0) { + content + self.content() + } + .WXMCardStyle(backgroundColor: bgColor, insideHorizontalPadding: 0.0, insideVerticalPadding: 0.0) + .strokeBorder(color: borderColor, lineWidth: 1.0, radius: CGFloat(.cardCornerRadius)) + } else { + content + } + } +} + +extension View { + @ViewBuilder + func indication(show: Binding, + borderColor: Color, + bgColor: Color, + content: @escaping () -> Content) -> some View { + modifier(IndicationModifier(show: show, borderColor: borderColor, bgColor: bgColor, content: content)) + } +} + +#Preview { + WeatherStationCard(device: .mockDevice, followState: nil) + .indication(show: .constant(true), borderColor: .red, bgColor: .red.opacity(0.5)) { + Text(verbatim: "Indication") + .padding() + } +} diff --git a/PresentationLayer/UI Components/Base Components/InfoView.swift b/PresentationLayer/UI Components/Base Components/InfoView.swift new file mode 100644 index 00000000..e71dd919 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/InfoView.swift @@ -0,0 +1,38 @@ +// +// InfoView.swift +// PresentationLayer +// +// Created by Pantelis Giazitsis on 16/2/23. +// + +import SwiftUI + +struct InfoView: View { + let text: AttributedString + + var body: some View { + HStack(spacing: CGFloat(.smallSpacing)) { + Image(asset: .infoIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .darkestBlue)) + + Text(text) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .blueTint), + insideHorizontalPadding: CGFloat(.mediumSidePadding)) + .strokeBorder(color: Color(colorEnum: .darkestBlue), + lineWidth: 1.0, + radius: CGFloat(.cardCornerRadius)) + } +} + +struct InfoView_Previews: PreviewProvider { + static var previews: some View { + InfoView(text: "There is a brand new firmware update for your station. **We highly recommend you update, as this will improve the experience you’ll have with your WeatherXM station!**".attributedMarkdown ?? "") + } +} diff --git a/PresentationLayer/UI Components/Base Components/Lazy Loading Pager/LazyLoadingPagerView.swift b/PresentationLayer/UI Components/Base Components/Lazy Loading Pager/LazyLoadingPagerView.swift new file mode 100644 index 00000000..0c730c79 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Lazy Loading Pager/LazyLoadingPagerView.swift @@ -0,0 +1,187 @@ +// +// LazyLoadingPagerView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 13/9/23. +// + +import SwiftUI + +struct LazyLoadingPagerView: View { + @Binding var data: Data + let initialContent: (Data) -> Content + let previous: (Data) -> Content? + let next: (Data) -> Content? + let previousData: (Data) -> Data? + let nextData: (Data) -> Data? + let scrollDirection: (Data, Data) -> UIPageViewController.NavigationDirection + + var body: some View { + PagerView(data: $data, + content: initialContent, + previous: previous, + next: next, + previousData: previousData, + nextData: nextData, + scrollDirection: scrollDirection) + .navigationBarHidden(true) + } +} + +#Preview { + ZStack { + Color.red.ignoresSafeArea() + + LazyLoadingPagerView(data: .constant(0)) { data in + Text("\(data)") + } previous: { data in + Text("\(data-1)") + } next: { data in + Text("\(data+1)") + } previousData: { data in + data - 1 + } nextData: { data in + data + 1 + } scrollDirection: { _, _ in + .forward + } + } +} + +private struct PagerView: UIViewControllerRepresentable { + + @Binding var data: Data + let content: (Data) -> Content + let previous: (Data) -> Content? + let next: (Data) -> Content? + let previousData: (Data) -> Data? + let nextData: (Data) -> Data? + let scrollDirection: (Data, Data) -> UIPageViewController.NavigationDirection + + func makeUIViewController(context: Context) -> UIPageViewController { + let vc = UIPageViewController(transitionStyle: .scroll, + navigationOrientation: .horizontal) + vc.view.backgroundColor = .clear + vc.delegate = context.coordinator + vc.dataSource = context.coordinator + + let initialController = PagerHostingController.getController(data: data, view: content(data)) + vc.setViewControllers([initialController], direction: .forward, animated: false) + + return vc + } + + func makeCoordinator() -> PagerCoordinator { + PagerCoordinator(dataSource: DataSource(data: $data, + content: content, + previous: previous, + next: next, + previousData: previousData, + nextData: nextData)) + } + + func updateUIViewController(_ uiViewController: UIPageViewController, + context: Context) { + guard let visibleController = uiViewController.viewControllers?.first as? PagerHostingController, + let visibleData = visibleController.data, + visibleData != data else { + return + } + + let controller = PagerHostingController.getController(data: data, view: content(data)) + let direction = scrollDirection(visibleData, data) + uiViewController.setViewControllers([controller], direction: direction, animated: true) + } +} + +extension PagerView { + class PagerCoordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { + + let dataSource: DataSource + + init(dataSource: DataSource) { + self.dataSource = dataSource + } + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let viewController = viewController as? PagerHostingController, + let currentData = viewController.data, + let view = dataSource.previous(currentData) else { + return nil + } + + let previousController = PagerHostingController.getController(data: dataSource.previousData(currentData), view: view) + return previousController + } + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let viewController = viewController as? PagerHostingController, + let currentData = viewController.data, + let view = dataSource.next(currentData) else { + return nil + } + + let nextController = PagerHostingController.getController(data: dataSource.nextData(currentData), view: view) + return nextController + } + + func pageViewController(_ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool) { + guard completed, + let visibleController = pageViewController.viewControllers?.first as? PagerHostingController, + let visibleData = visibleController.data else { + return + } + + dataSource.data = visibleData + } + } + + class DataSource { + @Binding var data: ContentData + let content: (ContentData) -> ContentView + let previous: (ContentData) -> ContentView? + let next: (ContentData) -> ContentView? + let previousData: (ContentData) -> ContentData? + let nextData: (ContentData) -> ContentData? + + init(data: Binding, + content: @escaping (ContentData) -> ContentView, + previous: @escaping (ContentData) -> ContentView?, + next: @escaping (ContentData) -> ContentView?, + previousData: @escaping (ContentData) -> ContentData?, + nextData: @escaping (ContentData) -> ContentData?) { + self._data = data + self.content = content + self.previous = previous + self.next = next + self.previousData = previousData + self.nextData = nextData + } + } + +} + +private class PagerHostingController: UIHostingController { + + static func getController(data: Data?, view: Content) -> PagerHostingController { + let controller = PagerHostingController(rootView: view) + controller.view.backgroundColor = .clear + controller.data = data + return controller + } + + var data: Data? + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard #available(iOS 16.0, *) else { + /// This fixes the issue with navigation bar while swipping in iOS 15 + navigationController?.setNavigationBarHidden(true, animated: false) + return + } + + } +} diff --git a/PresentationLayer/UI Components/Base Components/Lottie/LottieView.swift b/PresentationLayer/UI Components/Base Components/Lottie/LottieView.swift new file mode 100644 index 00000000..7ae54e66 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Lottie/LottieView.swift @@ -0,0 +1,38 @@ +// +// LottieView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 26/5/22. +// + +import Lottie +import SwiftUI + +struct LottieView: UIViewRepresentable { + typealias UIViewType = UIView + + var animationCase: String + var loopMode: LottieLoopMode + + func makeUIView(context _: UIViewRepresentableContext) -> UIView { + let view = UIView(frame: .zero) + let animationView = LottieAnimationView() + let animation = LottieAnimation.named(animationCase) + animationView.animation = animation + animationView.backgroundBehavior = .pauseAndRestore + animationView.loopMode = loopMode + animationView.contentMode = .scaleAspectFit + animationView.play() + + animationView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(animationView) + NSLayoutConstraint.activate([ + animationView.heightAnchor.constraint(equalTo: view.heightAnchor), + animationView.widthAnchor.constraint(equalTo: view.widthAnchor) + ]) + + return view + } + + func updateUIView(_: UIView, context _: UIViewRepresentableContext) {} +} diff --git a/PresentationLayer/UI Components/Base Components/PercentageGridLayoutView.swift b/PresentationLayer/UI Components/Base Components/PercentageGridLayoutView.swift new file mode 100644 index 00000000..7a338378 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/PercentageGridLayoutView.swift @@ -0,0 +1,43 @@ +// +// PercentageGridLayoutView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 22/3/23. +// + +import SwiftUI + +struct PercentageGridLayoutView: View { + var alignments: [Alignment] = [.leading, .leading] + var firstColumnPercentage: CGFloat = 0.6 + + let content: () -> Content + @State private var size: CGSize = .zero + + var body: some View { + #if MAIN_APP + LazyVGrid(columns: [GridItem(.fixed(size.width * firstColumnPercentage), spacing: 0.0, alignment: alignments[0]), + GridItem(.flexible(), spacing: 0.0, alignment: alignments[1])]) { + content() + }.sizeObserver(size: $size) + #else + VStack { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 0.0, alignment: alignments[0]), + GridItem(.flexible(), spacing: 0.0, alignment: alignments[1])]) { + content() + } + } + #endif + } +} + +struct PercentageGridLayoutView_Previews: PreviewProvider { + static var previews: some View { + PercentageGridLayoutView { + Group { + Text(verbatim: "Column 0") + Text(verbatim: "Column 1") + } + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/ProgressBarStyle.swift b/PresentationLayer/UI Components/Base Components/ProgressBarStyle.swift new file mode 100644 index 00000000..33faee19 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/ProgressBarStyle.swift @@ -0,0 +1,61 @@ +// +// ProgressBarStyle.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 3/2/23. +// + +import SwiftUI + +struct ProgressBarStyle: ProgressViewStyle { + var text: String? + var bgColor: Color = Color(colorEnum: .midGrey) + var progressColor: Color = Color(colorEnum: .primary) + + @State private var textFrame: CGRect = .zero + @State private var progressSize: CGSize = .zero + + func makeBody(configuration: Configuration) -> some View { + GeometryReader { geometry in + ZStack { + bgColor + HStack(spacing: 0.0) { + progressColor + .frame(width: CGFloat(configuration.fractionCompleted ?? 0) * geometry.size.width) + .clipShape(Capsule()) + .animation(.linear, value: configuration.fractionCompleted) + .sizeObserver(size: $progressSize) + + Spacer(minLength: 0.0) + } + + HStack(spacing: 0.0) { + Color(.white) + .frame(width: progressSize.width) + .clipShape(Capsule()) + .animation(.linear, value: configuration.fractionCompleted) + + progressColor + }.mask { + if let text { + Text(text) + .font(.system(size: CGFloat(.caption))) + .transaction { transaction in + transaction.animation = nil + } + } + } + } + .clipShape(Capsule()) + } + } +} + +struct ProgressBarStyle_Previews: PreviewProvider { + static var previews: some View { + ProgressView(value: 5, + total: 10) + .progressViewStyle(ProgressBarStyle(text: "\(4)")) + .previewLayout(.fixed(width: 256, height: 22)) + } +} diff --git a/PresentationLayer/UI Components/Base Components/QrScannerView.swift b/PresentationLayer/UI Components/Base Components/QrScannerView.swift new file mode 100644 index 00000000..a5b77f8b --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/QrScannerView.swift @@ -0,0 +1,52 @@ +// +// QrScannerView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 10/6/22. +// + +import SwiftUI + +struct QrScannerView: View { + let qrSquareDimensions: CGFloat = 300 + let backgroundOpacity: CGFloat = 0.5 + let lineOpacity: CGFloat = 0.4 + let lineHeight: CGFloat = 1 + let informationPaddingHorizontal: CGFloat = 1 + let informationPaddingBottom: CGFloat = 10 + + var body: some View { + ZStack { + Color.black.opacity(backgroundOpacity) + scanner + } + } + + var scanner: some View { + VStack { + Spacer() + qrBox + Spacer() + scanningGuide + } + } + + var qrBox: some View { + ZStack { + Rectangle() + .frame(width: qrSquareDimensions, height: qrSquareDimensions) + .blendMode(.destinationOut) + Rectangle() + .fill(Color.red.opacity(lineOpacity)) + .frame(width: qrSquareDimensions, height: lineHeight) + } + } + + var scanningGuide: some View { + Text(LocalizableString.scannerGuide.localized) + .foregroundColor(Color(.white)) + .font(.system(size: CGFloat(.normalFontSize))) + .padding(.horizontal, informationPaddingHorizontal) + .padding(.bottom, informationPaddingBottom) + } +} diff --git a/PresentationLayer/UI Components/Base Components/Scrolling Picker/ScrollingPagerView.swift b/PresentationLayer/UI Components/Base Components/Scrolling Picker/ScrollingPagerView.swift new file mode 100644 index 00000000..0478ddca --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Scrolling Picker/ScrollingPagerView.swift @@ -0,0 +1,229 @@ +// +// ScrollingPickerView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 7/9/23. +// + +import SwiftUI +import Toolkit + +struct ScrollingPickerView: View { + @Binding var selectedIndex: Int + let textCallback: GenericValueCallback + let countCallback: GenericValueWithNoArgumentCallback + + var body: some View { + VStack(spacing: 0.0) { + GeometryReader { proxy in + Pager(selectedIndex: $selectedIndex, + containerSize: proxy.size, + textCallback: textCallback, + countCallback: countCallback) + } + .frame(height: 40.0) + + Color(colorEnum: .text) + .frame(width: 100.0, height: 2.0) + } + .overlay { + LinearGradient(gradient: Gradient(colors: [Color(colorEnum: .top), + Color(colorEnum: .top).opacity(0.1), + Color.clear, + Color(colorEnum: .top).opacity(0.1), + Color(colorEnum: .top)]), + startPoint: .leading, + endPoint: .trailing) + .allowsHitTesting(false) + } + } +} + +private let cellId = "identifier" +private struct Pager: UIViewRepresentable { + + @Binding var selectedIndex: Int + let containerSize: CGSize + let textCallback: GenericValueCallback + let countCallback: GenericValueWithNoArgumentCallback + + func makeCoordinator() -> Coordinator { + Coordinator(valueForIndexCallback: textCallback, + didSelectCallback: { index in selectedIndex = index }, + elementsCountCallback: countCallback) + } + + func makeUIView(context: Self.Context) -> UICollectionView { + let flowlayout = PagingCollectionViewLayout() + flowlayout.scrollDirection = .horizontal + flowlayout.itemSize = CGSize(width: containerSize.width / 2.0, height: 40.0) + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowlayout) + collectionView.register(PagerCell.self, forCellWithReuseIdentifier: cellId) + collectionView.showsHorizontalScrollIndicator = false + collectionView.dataSource = context.coordinator + collectionView.delegate = context.coordinator + collectionView.decelerationRate = .fast + collectionView.backgroundColor = .clear + return collectionView + } + + func updateUIView(_ uiView: UICollectionView, context: Self.Context) { + context.coordinator.size = containerSize + uiView.reloadData() + print(selectedIndex) + context.coordinator.selectElement(selectedIndex, in: uiView) + } +} + +extension Pager { + class Coordinator: NSObject, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + let valueForIndexCallback: GenericValueCallback + let didSelectCallback: GenericCallback + let elementsCountCallback: GenericValueWithNoArgumentCallback + var size: CGSize = .zero + + init(valueForIndexCallback: @escaping GenericValueCallback, + didSelectCallback: @escaping GenericCallback, + elementsCountCallback: @escaping GenericValueWithNoArgumentCallback) { + self.valueForIndexCallback = valueForIndexCallback + self.didSelectCallback = didSelectCallback + self.elementsCountCallback = elementsCountCallback + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard section == 1 else { + return 1 + } + + return elementsCountCallback() + } + + func numberOfSections(in collectionView: UICollectionView) -> Int { + 3 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! PagerCell + + guard indexPath.section == 1 else { + cell.label.text = nil + return cell + } + + cell.label.text = valueForIndexCallback(indexPath.row) + cell.label.sizeToFit() + + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) + didSelectCallback(indexPath.row) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + guard indexPath.section == 1 else { + return CGSize(width: size.width / 4.0, height: 40.0) + } + return CGSize(width: size.width / 2.0, height: 40.0) + } + + func selectElement(_ index: Int, in collectionView: UICollectionView) { + /// Dispatch on main thread to make sure will be scrolled as expected + /// The main reason for this was an issue in scroll position on appear + DispatchQueue.main.async { [weak collectionView] in + guard let collectionView, + collectionView.numberOfSections > 2 else { + return + } + let indexPath = IndexPath(row: index, section: 1) + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + guard let collectionView = scrollView as? UICollectionView, + let center = collectionView.superview?.convert(collectionView.center, to: collectionView), + let indexPath = collectionView.indexPathForItem(at: center) else { + return + } + + didSelectCallback(indexPath.row) + } + } + + class PagingCollectionViewLayout: UICollectionViewFlowLayout { + + var velocityThresholdPerPage: CGFloat = 2 + var numberOfItemsPerPage: CGFloat = 1 + + override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { + guard let collectionView = collectionView else { return proposedContentOffset } + + let pageLength: CGFloat + let approxPage: CGFloat + let currentPage: CGFloat + let speed: CGFloat + + if scrollDirection == .horizontal { + pageLength = (self.itemSize.width + self.minimumLineSpacing) * numberOfItemsPerPage + approxPage = collectionView.contentOffset.x / pageLength + speed = velocity.x + } else { + pageLength = (self.itemSize.height + self.minimumLineSpacing) * numberOfItemsPerPage + approxPage = collectionView.contentOffset.y / pageLength + speed = velocity.y + } + + if speed < 0 { + currentPage = ceil(approxPage) + } else if speed > 0 { + currentPage = floor(approxPage) + } else { + currentPage = round(approxPage) + } + + guard speed != 0 else { + if scrollDirection == .horizontal { + return CGPoint(x: currentPage * pageLength, y: 0) + } else { + return CGPoint(x: 0, y: currentPage * pageLength) + } + } + + var nextPage: CGFloat = currentPage + (speed > 0 ? 1 : -1) + + let increment = speed / velocityThresholdPerPage + nextPage += (speed < 0) ? ceil(increment) : floor(increment) + + if scrollDirection == .horizontal { + return CGPoint(x: nextPage * pageLength, y: 0) + } else { + return CGPoint(x: 0, y: nextPage * pageLength) + } + } + } + + class PagerCell: UICollectionViewCell { + let label: UILabel + + override init(frame: CGRect) { + label = UILabel() + label.textColor = UIColor(colorEnum: .text) + label.font = UIFont.systemFont(ofSize: CGFloat(.mediumFontSize), weight: .bold) + label.textAlignment = .center + super.init(frame: frame) + label.translatesAutoresizingMaskIntoConstraints = false + self.contentView.addSubview(label) + label.topAnchor.constraint(equalTo: self.contentView.topAnchor, constant: 0).isActive = true + label.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: 0).isActive = true + label.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor, constant: 0).isActive = true + label.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: 0).isActive = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/Shimmer Effect Loader/ShimmerEffectLoader.swift b/PresentationLayer/UI Components/Base Components/Shimmer Effect Loader/ShimmerEffectLoader.swift new file mode 100644 index 00000000..b8c121e7 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Shimmer Effect Loader/ShimmerEffectLoader.swift @@ -0,0 +1,66 @@ +// +// ShimmerEffectLoader.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 19/6/23. +// + +import SwiftUI + +struct ShimmerEffectModifier: ViewModifier { + @Binding var show: Bool + let position: Position + let horizontalPadding: CGFloat + let bottomPadding: CGFloat + + func body(content: Content) -> some View { + content.overlay { + if show { + VStack { + if position == .bottom { + Spacer() + } + + ShimmerEffectLoaderView() + .frame(height: 4.0) + .padding(.horizontal, horizontalPadding) + .padding(.bottom, bottomPadding) + + if position == .top { + Spacer() + } + } + .transition(AnyTransition.opacity.animation(.easeIn(duration: 0.2))) + } + } + } +} + +extension ShimmerEffectModifier { + enum Position { + case top + case bottom + } +} + +extension View { + func shimmerLoader(show: Binding, + position: ShimmerEffectModifier.Position = .top, + horizontalPadding: CGFloat = 0.0, + bottomPadding: CGFloat = 0.0) -> some View { + modifier(ShimmerEffectModifier(show: show, + position: position, + horizontalPadding: horizontalPadding, + bottomPadding: bottomPadding)) + } +} + +struct Previews_ShimmerEffectLoader_Previews: PreviewProvider { + static var previews: some View { + VStack { + Color.gray + .ignoresSafeArea() + } + .shimmerLoader(show: .constant(true), horizontalPadding: 10.0) + } +} diff --git a/PresentationLayer/UI Components/Base Components/Shimmer Effect Loader/ShimmerEffectLoaderView.swift b/PresentationLayer/UI Components/Base Components/Shimmer Effect Loader/ShimmerEffectLoaderView.swift new file mode 100644 index 00000000..517dc88d --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Shimmer Effect Loader/ShimmerEffectLoaderView.swift @@ -0,0 +1,46 @@ +// +// ShimmerEffectLoaderView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 19/6/23. +// + +import SwiftUI + +struct ShimmerEffectLoaderView: View { + + let duration: Double = 2.0 + @State private var startPoint: UnitPoint = UnitPoint(x: -3.0, y: 0.5) + @State private var endPoint: UnitPoint = UnitPoint(x: 0.0, y: 0.5) + + var body: some View { + Capsule() + .fill( + LinearGradient(gradient: Gradient(colors: [Color(colorEnum: .netStatsFabColor), + Color(.clear), + Color(colorEnum: .netStatsFabColor)]), + startPoint: startPoint, + endPoint: endPoint) + ) + .frame(height: 4.0) + .padding(.horizontal) + .safeAreaInset(edge: .top, content: { + Color.clear + }) + .onAppear { + let baseAnimation = Animation.easeInOut(duration: duration) + let repeated = baseAnimation.repeatForever(autoreverses: false) + withAnimation(repeated) { + startPoint = UnitPoint(x: 1.0, y: 0.0) + endPoint = UnitPoint(x: 4.0, y: 0.0) + } + } + } +} + +struct ShimmerEffectLoader_Previews: PreviewProvider { + static var previews: some View { + ShimmerEffectLoaderView() + .frame(height: 10.0) + } +} diff --git a/PresentationLayer/UI Components/Base Components/Spinning Loader/SpinningLoader.swift b/PresentationLayer/UI Components/Base Components/Spinning Loader/SpinningLoader.swift new file mode 100644 index 00000000..e5a52603 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Spinning Loader/SpinningLoader.swift @@ -0,0 +1,44 @@ +// +// SpinningLoader.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 20/6/23. +// + +import SwiftUI + +struct SpinningLoaderModifier: ViewModifier { + @Binding var show: Bool + let hideContent: Bool + + func body(content: Content) -> some View { + content + .opacity((hideContent && show) ? 0.0 : 1.0) + .overlay { + if show { + VStack { + SpinningLoaderView() + .if(!hideContent) { view in + view.wxmShadow() + } + } + } + } + } +} + +extension View { + func spinningLoader(show: Binding, hideContent: Bool = false) -> some View { + modifier(SpinningLoaderModifier(show: show, hideContent: hideContent)) + } +} + +struct Previews_SpinningLoader_Previews: PreviewProvider { + static var previews: some View { + VStack { + Color.gray + .ignoresSafeArea() + } + .spinningLoader(show: .constant(true), hideContent: true) + } +} diff --git a/PresentationLayer/UI Components/Base Components/Spinning Loader/SpinningLoaderView.swift b/PresentationLayer/UI Components/Base Components/Spinning Loader/SpinningLoaderView.swift new file mode 100644 index 00000000..9823018a --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Spinning Loader/SpinningLoaderView.swift @@ -0,0 +1,25 @@ +// +// SpinningLoaderView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 20/3/23. +// + +import SwiftUI + +struct SpinningLoaderView: View { + private let dimensions: CGFloat = 150 + + var body: some View { + LottieView(animationCase: AnimationsEnums.loading.animationString, + loopMode: .loop) + .frame(width: dimensions, + height: dimensions) + } +} + +struct SpinningLoaderView_Previews: PreviewProvider { + static var previews: some View { + SpinningLoaderView() + } +} diff --git a/PresentationLayer/UI Components/Base Components/StaticButtonStyle.swift b/PresentationLayer/UI Components/Base Components/StaticButtonStyle.swift new file mode 100644 index 00000000..bf721505 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/StaticButtonStyle.swift @@ -0,0 +1,14 @@ +// +// StaticButtonStyle.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 19/11/22. +// + +import SwiftUI + +struct StaticButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + } +} diff --git a/PresentationLayer/UI Components/Base Components/StationLastActiveView.swift b/PresentationLayer/UI Components/Base Components/StationLastActiveView.swift new file mode 100644 index 00000000..f83783c0 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/StationLastActiveView.swift @@ -0,0 +1,58 @@ +// +// StationLastActiveView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 3/3/23. +// + +import SwiftUI +import DomainLayer + +struct StationLastActiveView: View { + + let configuration: Configuration + + var body: some View { + HStack(spacing: 0.0) { + Image(asset: configuration.icon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: configuration.stateColor)) + + Text(configuration.lastActiveAt?.lastActiveTime() ?? "-") + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: configuration.stateColor)) + .padding(.trailing, CGFloat(.smallSidePadding)) + } + .WXMCardStyle(backgroundColor: Color(colorEnum: configuration.tintColor), + insideHorizontalPadding: 0.0, + insideVerticalPadding: 0.0, + cornerRadius: CGFloat(.buttonCornerRadius)) + } +} + +extension StationLastActiveView { + struct Configuration { + let lastActiveAt: String? + let icon: AssetEnum + let stateColor: ColorEnum + let tintColor: ColorEnum + } + + init(device: DeviceDetails) { + let conf = Configuration(lastActiveAt: device.lastActiveAt, + icon: device.icon, + stateColor: device.isActiveStateColor, + tintColor: device.isActiveStateTintColor) + self.configuration = conf + } +} + +struct StationLastActiveView_Previews: PreviewProvider { + static var previews: some View { + var device = DeviceDetails.emptyDeviceDetails + device.profile = .helium + device.isActive = false + device.lastActiveAt = Date().ISO8601Format() + return StationLastActiveView(device: device) + } +} diff --git a/PresentationLayer/UI Components/Base Components/StepsNavView.swift b/PresentationLayer/UI Components/Base Components/StepsNavView.swift new file mode 100644 index 00000000..db5470d4 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/StepsNavView.swift @@ -0,0 +1,250 @@ +// +// StepsNavView.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 1/10/22. +// + +import SwiftUI + +struct StepsNavView: View { + @State var isMovingForward = false + @State var currentStep = 0 + + @Environment(\.presentationMode) var presentationMode: Binding + + @State private var id = UUID() + + var title: String + var steps: [Step] + + /** + Provided to children (step) views, so that they move to the next or previous step. + */ + final class Transport { + let nextStep: () -> Void + let previousStep: () -> Void + let firstStep: () -> Void + let isLastStep: () -> Bool + let isFirstStep: () -> Bool + init( + nextStep: @escaping () -> Void, + previousStep: @escaping () -> Void, + firstStep: @escaping () -> Void, + isLastStep: @escaping () -> Bool, + isFirstStep: @escaping () -> Bool + ) { + self.nextStep = nextStep + self.previousStep = previousStep + self.firstStep = firstStep + self.isLastStep = isLastStep + self.isFirstStep = isFirstStep + } + } + + struct Step { + var title: String + var view: (Transport) -> AnyView + } + + var body: some View { + GeometryReader { _ in + VStack(alignment: .leading, spacing: CGFloat(.minimumSpacing)) { + customNavbar + + StepsHeaderView(steps: steps, + currentStep: currentStep) + .animation(.easeInOut(duration: 0.2), value: currentStep) + + currentView + .transition(AnyTransition.asymmetric( + insertion: .move(edge: isMovingForward ? .trailing : .leading), + removal: .move(edge: isMovingForward ? .leading : .trailing) + )) + .animation(.easeInOut(duration: 0.2), value: currentStep) + } + } + .navigationBarTitle("") + .navigationBarHidden(true) + .background( + Color(colorEnum: .bg).edgesIgnoringSafeArea(.all) + ) + .ignoresSafeArea(.keyboard) + } + + var customNavbar: some View { + HStack { + Button { + let transport = getTransportObject() + if transport.isFirstStep() { + self.presentationMode.wrappedValue.dismiss() + } else { + transport.previousStep() + } + } label: { + Image(asset: .backArrow) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .primary)) + .padding(.leading, 14) + .padding(.top, 2.5) + .padding(.trailing, 10) + } + titleView + Spacer() + } + .padding(.top, 12) + .padding(.bottom, 12) + } + + var titleView: some View { + Text(title) + .foregroundColor(Color(colorEnum: .text)) + .padding(.top, 2) + .font(.system(size: CGFloat(.largeTitleFontSize))) + } + + @State var viewCache: [Int: AnyView] = [:] + + @ViewBuilder + var currentView: some View { + steps[currentStep].view(getTransportObject()) + .id("\(id)step\(currentStep)") + } + + private struct StepsHeaderView: View { + let steps: [StepsNavView.Step] + let currentStep: Int + + private static let CIRCLE_RADIUS: CGFloat = 22 + private static let CIRCLE_BORDER_WIDTH: CGFloat = 3 + private static let HEADER_PADDING: CGFloat = 12 + private static let STEP_LAST_PADDING: CGFloat = 10 + + var body: some View { + HStack(spacing: -(Self.CIRCLE_RADIUS + Self.CIRCLE_BORDER_WIDTH)) { + ForEach(0 ..< steps.count, id: \.self) { index in + stepView(step: steps[index], index: index) + } + } + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, CGFloat(.defaultSidePadding)) + } + + @ViewBuilder + func stepView(step: Step, index: Int) -> some View { + let isSelected = index == currentStep + let isCompleted = index < currentStep + HStack(spacing: CGFloat(.minimumSpacing)) { + + if isCompleted { + Image(asset: .circleCheckmark) + .resizable() + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + .scaledToFit() + .frame(width: Self.CIRCLE_RADIUS, height: Self.CIRCLE_RADIUS) + } else { + Text("\(index + 1)") + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(isSelected ? Color(colorEnum: .text) : Color(colorEnum: .top)) + .frame(width: Self.CIRCLE_RADIUS, height: Self.CIRCLE_RADIUS) + .background( + Circle() + .foregroundColor(isSelected ? Color(colorEnum: .top) : Color(colorEnum: .text)) + ) + .strokeBorder(color: Color(colorEnum: .text), lineWidth: Self.CIRCLE_BORDER_WIDTH, radius: Self.CIRCLE_RADIUS) + } + + Text(step.title) + .foregroundColor(Color(colorEnum: .text)) + .font( + .system( + size: CGFloat(.caption), + weight: isSelected ? .bold : .regular + ) + ) + .multilineTextAlignment(.leading) + + Spacer(minLength: 0.0) + } + .frame(maxHeight: .infinity) + .padding(CGFloat(.smallSidePadding)) + .background(Color(colorEnum: isCompleted ? .successTint : .top)) + .cornerRadius(Self.CIRCLE_RADIUS) + .strokeBorder(color: Color(colorEnum: .bg), lineWidth: Self.CIRCLE_BORDER_WIDTH, radius: Self.CIRCLE_RADIUS) + } + } +} + +private extension StepsNavView { + func getTransportObject() -> Transport { + Transport( + nextStep: { + if currentStep >= steps.count - 1 { + return + } + + isMovingForward = true + withAnimation { + currentStep += 1 + } + }, + previousStep: { + if currentStep <= 0 { + return + } + + isMovingForward = false + withAnimation { + currentStep -= 1 + } + }, + firstStep: { + if currentStep == 0 { + return + } + + isMovingForward = false + withAnimation { + currentStep = 0 + } + }, + isLastStep: { + currentStep == steps.count - 1 + }, + isFirstStep: { + currentStep == 0 + } + ) + } +} + +// Enable swipe-to-go-back gesture after the back button has been customized. +extension UINavigationController: UIGestureRecognizerDelegate { + override open func viewDidLoad() { + super.viewDidLoad() + interactivePopGestureRecognizer?.delegate = self + } + + public func gestureRecognizerShouldBegin(_: UIGestureRecognizer) -> Bool { + return viewControllers.count > 1 + } +} + +struct Previews_StepsNavView_Previews: PreviewProvider { + static var previews: some View { + let step = StepsNavView.Step(title: LocalizableString.ClaimDevice.connectionStepTitle.localized) { _ in + AnyView(Text(LocalizableString.ClaimDevice.connectionStepTitle.localized)) + } + let step1 = StepsNavView.Step(title: LocalizableString.ClaimDevice.verifyStepTitle.localized) { _ in + AnyView(Text(LocalizableString.ClaimDevice.resetStationTitle.localized)) + } + + let step2 = StepsNavView.Step(title: LocalizableString.ClaimDevice.locationStepTitle.localized) { _ in + AnyView(Text(LocalizableString.ClaimDevice.verifyTitle.localized)) + } + + StepsNavView(title: LocalizableString.ClaimDevice.ws1000DeviceTitle.localized, + steps: [step, step1, step2]) + } +} diff --git a/PresentationLayer/UI Components/Base Components/StepsView.swift b/PresentationLayer/UI Components/Base Components/StepsView.swift new file mode 100644 index 00000000..4453e4de --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/StepsView.swift @@ -0,0 +1,76 @@ +// +// StepsView.swift +// PresentationLayer +// +// Created by Pantelis Giazitsis on 16/2/23. +// + +import SwiftUI + +struct StepsView: View { + var steps: [Step] = [] + @Binding var currentStepIndex: Int? + @State private var stepsViewSize: CGSize = .zero + + var body: some View { + stepsView + } +} + +extension StepsView { + @ViewBuilder + var stepsView: some View { + VStack(spacing: CGFloat(.smallSpacing)) { + VStack(alignment: .leading, spacing: CGFloat(.smallSpacing)) { + ForEach(Array(steps.enumerated()), id: \.element) { index, step in + let isCurrent = currentStepIndex == index + + HStack(spacing: CGFloat(.minimumSpacing)) { + if step.isCompleted { + Image(asset: .circleCheckmark) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .darkGrey)) + } else { + Text("\(index + 1)") + .foregroundColor(Color(colorEnum: .top)) + .font(.system(size: CGFloat(.caption), weight: .bold)) + .frame(width: 20.0, height: 20.0) + .background { + Circle().foregroundColor(Color(colorEnum: isCurrent ? .text : .darkGrey)) + } + } + + let weight: Font.Weight = isCurrent ? .bold : .regular + Text(step.text) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.mediumFontSize), weight: weight)) + } + } + } + .fixedSize(horizontal: true, vertical: false) + .sizeObserver(size: $stepsViewSize) + } + } +} + +extension StepsView { + /// Step structure to render in steps list + struct Step: Hashable { + var id: String { + text + } + + let text: String + var isCompleted: Bool + + mutating func setCompleted(_ completed: Bool) { + isCompleted = completed + } + } +} + +struct StepsView_Previews: PreviewProvider { + static var previews: some View { + StepsView(currentStepIndex: .constant(nil)) + } +} diff --git a/PresentationLayer/UI Components/Base Components/TabViewWrapper.swift b/PresentationLayer/UI Components/Base Components/TabViewWrapper.swift new file mode 100644 index 00000000..3eb8a8f9 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/TabViewWrapper.swift @@ -0,0 +1,20 @@ +// +// TabViewWrapper.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 22/8/23. +// + +/// Implementation on bugs... +/// The following wrapper solves the memory leaks in SwiftUI's TabView +/// https://stackoverflow.com/questions/62857076/memory-leak-in-tabview-with-pagetabviewstyle +import SwiftUI + +struct TabViewWrapper: View { + @Binding var selection: Selection + @ViewBuilder let content: () -> Content + + var body: some View { + TabView(selection: $selection, content: content) + } +} diff --git a/PresentationLayer/UI Components/Base Components/Toast/ToastView.swift b/PresentationLayer/UI Components/Base Components/Toast/ToastView.swift new file mode 100644 index 00000000..b8500934 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Toast/ToastView.swift @@ -0,0 +1,114 @@ +// +// ToastView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 14/3/23. +// + +import SwiftUI +import Toolkit + +struct ToastView: View { + let text: AttributedString + var type: ToastType = .error + var dismissInterval = 3.0 + var dismissCompletion: VoidCallback? + let retryAction: VoidCallback? + + @State private var offset: CGFloat = 150.0 + @State private var dismissItem: DispatchWorkItem? + private let animationDuration = 0.2 + + var body: some View { + HStack(spacing: CGFloat(.smallSpacing)) { + Text(text) + .foregroundColor(Color(colorEnum: type.textColor)) + .font(.system(size: CGFloat(.normalFontSize))) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + + if let retryAction { + Button { + dismissItem?.cancel() + dismiss { + retryAction() + } + } label: { + Text(LocalizableString.retry.localized) + .foregroundColor(Color(colorEnum: type.textColor)) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + } + } + } + .WXMCardStyle(backgroundColor: Color(colorEnum: type.color), + insideHorizontalPadding: CGFloat(.mediumSidePadding)) + .wxmShadow() + .padding() + .offset(x: 0.0, y: offset) + .onAppear { + + withAnimation(.easeOut(duration: animationDuration)) { + offset = 0.0 + } + + let workItem = DispatchWorkItem { + dismiss() + } + dismissItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + dismissInterval, + execute: workItem) + } + + } +} + +private extension ToastView { + func dismiss(completion: VoidCallback? = nil) { + withAnimation(.easeIn(duration: animationDuration)) { + offset = 150.0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) { + dismissCompletion?() + completion?() + } + } +} + +extension ToastView { + enum ToastType { + case error + case info + + var textColor: ColorEnum { + switch self { + case .error: + return .toastErrorText + case .info: + return .text + } + } + + var color: ColorEnum { + switch self { + case .error: + return .toastErrorBg + case .info: + return .toastInfoBg + } + } + } +} + +struct ToastView_Previews: PreviewProvider { + static var previews: some View { + ToastView(text: "Toast text here") {} + } +} + +struct ToastInfoView_Previews: PreviewProvider { + static var previews: some View { + ToastView(text: "Toast text here", type: .info) {} + } +} diff --git a/PresentationLayer/UI Components/Base Components/TrackableScrollView/TabBarVisibilityHandler.swift b/PresentationLayer/UI Components/Base Components/TrackableScrollView/TabBarVisibilityHandler.swift new file mode 100644 index 00000000..ed62f252 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/TrackableScrollView/TabBarVisibilityHandler.swift @@ -0,0 +1,56 @@ +// +// TabBarVisibilityHandler.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 30/11/23. +// + +import Foundation +import SwiftUI +import Combine + +class TabBarVisibilityHandler { + + let scrollOffsetObject: TrackableScrollOffsetObject + @Published var isTabBarShowing: Bool = true + private var cancellableSet: Set = [] + + init(scrollOffsetObject: TrackableScrollOffsetObject) { + self.scrollOffsetObject = scrollOffsetObject + observeContentOffset() + } +} + +private extension TabBarVisibilityHandler { + func observeContentOffset() { + scrollOffsetObject.$contentOffset.sink { [weak self] value in + guard let self = self, + case let isTabBarShowing = self.isTabBarVisible(newContentOffset: value), + /// The following check is required to prevent unnecessary renders from SwiftUI. + /// For some reason this happens on every assignment regardless the value is the same 🤷‍♂️ + self.isTabBarShowing != isTabBarShowing + else { + return + } + + self.isTabBarShowing = isTabBarShowing + } + .store(in: &cancellableSet) + } + + /// Calculates the condition to show or hide tab bar + /// - Parameter newContentOffset: The new scrolling offset + /// - Returns: If should show or hide tab bar + func isTabBarVisible(newContentOffset: CGFloat) -> Bool { + if scrollOffsetObject.hasReachedBottom(with: newContentOffset) { + return false + } + + if scrollOffsetObject.contentOffset < 40 { + return true + } + + let isScrollingUpwards = newContentOffset > scrollOffsetObject.contentOffset + return !isScrollingUpwards + } +} diff --git a/PresentationLayer/UI Components/Base Components/TrackableScrollView/TrackableScrollView.swift b/PresentationLayer/UI Components/Base Components/TrackableScrollView/TrackableScrollView.swift new file mode 100644 index 00000000..abdc1d83 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/TrackableScrollView/TrackableScrollView.swift @@ -0,0 +1,159 @@ +// +// TrackableScrollView.swift +// PresentationLayer +// +// Created by Hristos Condrea on 31/5/22. +// + +import PullableScrollView +import SwiftUI +import Toolkit + +struct TrackableScrollView: View where Content: View { + private let showIndicators: Bool + private let refreshAction: PullableScrollView.RefreshCallback? + private let content: () -> Content + private var offsetObject: TrackableScrollOffsetObject + + /// A refreshable scroll view which tracks the offset and content size + /// - Parameters: + /// - showIndicators: Show or hide scrolling indicators + /// - offsetObject: Object which keeps the `contentOffset` and the `contentSize`. + /// User of this component should keep a reference to this object in order to get the content offset and size changes + /// - refreshAction: Callback where the refresh actions should be performed. Once every request is finished call the passed callback to hide refresh control + /// - content: The content to be presented in scroll view + init(showIndicators: Bool = true, + offsetObject: TrackableScrollOffsetObject = .init(), + refreshAction: PullableScrollView.RefreshCallback? = nil, + @ViewBuilder content: @escaping () -> Content) { + self.showIndicators = showIndicators + self.refreshAction = refreshAction + self.content = content + self.offsetObject = offsetObject + } + + var body: some View { + GeometryReader { outsideProxy in + container { + ZStack { + GeometryReader { insideProxy in + Color.clear + .preference(key: ScrollOffsetPreferenceKey.self, value: [self.calculateContentOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy)]) + } + + self.content() + .sizeObserver(size: Binding(get: { offsetObject.contentSize }, set: { offsetObject.contentSize = $0 })) + } + } + .sizeObserver(size: Binding(get: { offsetObject.scrollerSize }, set: { offsetObject.scrollerSize = $0 })) + .onTapGesture {} + .simultaneousGesture(LongPressGesture(minimumDuration: 0.0).onEnded { _ in + offsetObject.didStartDragging() + }) + } + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in + DispatchQueue.main.async { + self.offsetObject.contentOffset = value[0] + } + } + } + + private func calculateContentOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat { + outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY + } + + /// Embeds the content in `PullableScrollView` if `refreshAction` is provided or a regular `ScrollView` if not + /// - Parameter content: The conent to show + /// - Returns: The updated view + @ViewBuilder private func container(content: () -> Content) -> some View { + if #available(iOS 16.0, *) { + ScrollView(showsIndicators: false) { + content() + } + .modify { view in + if refreshAction != nil { + view.refreshable { + await refresh() + } + } else { + view + } + } + .onAppear { + UIRefreshControl.appearance().tintColor = UIColor(Color(colorEnum: .primary)) + } + } else if let refreshAction = refreshAction { + PullableScrollView(tintColor: Color(colorEnum: .primary)) { completion in + refreshAction(completion) + } content: { + content() + } + .modify { view in + if #available(iOS 16.0, *) { + view.scrollContentBackground(.hidden) + } else { + view.background(Color(colorEnum: .bg)) + } + } + } else { + ScrollView(showsIndicators: false) { + content() + } + } + } + + private func refresh() async { + return await withCheckedContinuation { continuation in + DispatchQueue.main.async { + self.refreshAction? { + continuation.resume() + } + } + } + } +} + +struct ScrollOffsetPreferenceKey: PreferenceKey { + typealias Value = [CGFloat] + + static var defaultValue: [CGFloat] = [0] + + static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) { + value.append(contentsOf: nextValue()) + } +} + +class TrackableScrollOffsetObject: ObservableObject { + @Published var contentOffset: CGFloat = 0.0 { + didSet { + updateDiffOffset() + } + } + @Published private(set) var diffOffset: CGFloat = 0.0 + var contentSize: CGSize = .zero + var scrollerSize: CGSize = .zero + var willStartDraggingAction: VoidCallback? + + private var initialOffset: CGFloat? + + func hasReachedBottom(with offset: CGFloat) -> Bool { + guard contentSize.height >= scrollerSize.height else { + return false + } + + return (contentSize.height - offset) <= scrollerSize.height + } + + fileprivate func didStartDragging() { + willStartDraggingAction?() + initialOffset = contentOffset + } + + private func updateDiffOffset() { + guard let initialOffset else { + return + } + + diffOffset = contentOffset - initialOffset + } +} diff --git a/PresentationLayer/UI Components/Base Components/UberTextField.swift b/PresentationLayer/UI Components/Base Components/UberTextField.swift new file mode 100644 index 00000000..cb1fda23 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/UberTextField.swift @@ -0,0 +1,139 @@ +// +// UberTextField.swift +// +// Created by Manolis Katsifarakis on 30/10/22. +// + +import SwiftUI + +/** + A supercharged TextField that: + • Allows focusing / unfocusing via the `isFirstResponder` binding. It can be used instead of the `@FocusState` property wrapper which is only available on iOS > 15. + • Takes up full vertical and horizontal space of its container view, so that the user can focus it by tapping anywhere on it. + • Is based on a `UITextField` which is provided and can be configured in the `configuration` + callback (to set for example auto-correction / capitalization properties, enable secure text entry, etc.). + */ +struct UberTextField: UIViewRepresentable { + @Binding public var isFirstResponder: Bool? + @Binding public var text: String + @Binding public var hint: String + + private let onEditingChanged: (UITextField, Bool) -> Void + private let onSubmit: (UITextField) -> Bool + private let shouldChangeCharactersIn: (UITextField, NSRange, String) -> Bool + + public var configuration = { (_: UITextFieldWithPadding) in } + + public init( + text: Binding, + hint: Binding = .constant(""), + isFirstResponder: Binding = .constant(nil), + onEditingChanged: @escaping (UITextField, Bool) -> Void = { _, _ in }, + shouldChangeCharactersIn: @escaping (UITextField, NSRange, String) -> Bool = { _, _, _ in true }, + onSubmit: @escaping (UITextField) -> Bool = { _ in true }, + configuration: @escaping (UITextFieldWithPadding) -> Void = { _ in } + ) { + self.configuration = configuration + self.onEditingChanged = onEditingChanged + self.shouldChangeCharactersIn = shouldChangeCharactersIn + self.onSubmit = onSubmit + _text = text + _hint = hint + _isFirstResponder = isFirstResponder + } + + public func makeUIView(context: Context) -> UITextFieldWithPadding { + let view = UITextFieldWithPadding() + view.horizontalPadding = 20 + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + view.addTarget(context.coordinator, action: #selector(Coordinator.textFieldDidChange), for: .editingChanged) + view.delegate = context.coordinator + return view + } + + public func updateUIView(_ uiView: UITextFieldWithPadding, context _: Context) { + uiView.placeholder = hint + uiView.text = text + configuration(uiView) + + guard let isFirstResponder = isFirstResponder else { + return + } + + DispatchQueue.main.async { + switch isFirstResponder { + case true: uiView.becomeFirstResponder() + case false: uiView.resignFirstResponder() + } + } + } + + public func makeCoordinator() -> Coordinator { + Coordinator( + $text, + isFirstResponder: $isFirstResponder, + onEditingChanged: onEditingChanged, + shouldChangeCharactersIn: shouldChangeCharactersIn, + onSubmit: onSubmit + ) + } + + open class Coordinator: NSObject, UITextFieldDelegate { + var text: Binding + var isFirstResponder: Binding + let onEditingChanged: (UITextField, Bool) -> Void + let onSubmit: (UITextField) -> Bool + let shouldChangeCharactersIn: (UITextField, NSRange, String) -> Bool + + init( + _ text: Binding, + isFirstResponder: Binding, + onEditingChanged: @escaping (UITextField, Bool) -> Void, + shouldChangeCharactersIn: @escaping (UITextField, NSRange, String) -> Bool, + onSubmit: @escaping (UITextField) -> Bool + ) { + self.text = text + self.isFirstResponder = isFirstResponder + self.onEditingChanged = onEditingChanged + self.shouldChangeCharactersIn = shouldChangeCharactersIn + self.onSubmit = onSubmit + } + + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + return onSubmit(textField) + } + + @objc public func textFieldDidChange(_ textField: UITextField) { + text.wrappedValue = textField.text ?? "" + } + + public func textFieldDidBeginEditing(_ tf: UITextField) { + isFirstResponder.wrappedValue = true + onEditingChanged(tf, true) + } + + public func textFieldDidEndEditing(_ tf: UITextField) { + isFirstResponder.wrappedValue = false + onEditingChanged(tf, false) + } + + public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + let result = shouldChangeCharactersIn(textField, range, string) + text.wrappedValue = textField.text ?? "" + return result + } + } + + class UITextFieldWithPadding: UITextField { + var horizontalPadding: CGFloat = 0 + var verticalPadding: CGFloat = 0 + + override func textRect(forBounds bounds: CGRect) -> CGRect { + return CGRectInset(bounds, horizontalPadding, verticalPadding) + } + + override func editingRect(forBounds bounds: CGRect) -> CGRect { + return CGRectInset(bounds, horizontalPadding, verticalPadding) + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/WXMAlert/WXMAlertModifier.swift b/PresentationLayer/UI Components/Base Components/WXMAlert/WXMAlertModifier.swift new file mode 100644 index 00000000..40101278 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/WXMAlert/WXMAlertModifier.swift @@ -0,0 +1,145 @@ +// +// WXMAlertModifier.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 23/8/23. +// + +import SwiftUI +import Toolkit + +private struct WXMAlertModifier: ViewModifier { + + @Binding var show: Bool + let content: () -> V + + @State private var opacity = 0.0 + @State private var hostingWrapper: HostingWrapper = .init() + @State private var animator: OverlayAnimator? + + func body(content: Content) -> some View { + content + .onChange(of: show) { newValue in + if newValue, hostingWrapper.hostingController == nil { + present() + } else if !show { + dismiss() + } + } + } +} + +private extension WXMAlertModifier { + + struct AlertWrapper: View { + let content: () -> V + + private let animationDuration = 0.2 + @State private var opacity = 0.0 + @State private var scale: CGSize = .zero + + var body: some View { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + .opacity(opacity) + + content() + .scaleEffect(scale) + .onAppear { + withAnimation(.easeIn(duration: animationDuration)) { + opacity = 1.0 + scale = CGSize(width: 1.0, height: 1.0) + } + } + } + } + } + + class HostingController: UIHostingController { + + var willDismissCallback: VoidCallback? + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if isBeingDismissed { + willDismissCallback?() + } + } + + deinit { + print("deinit \(Self.self)") + } + } + + func present() { + + let controller = HostingController(rootView: AlertWrapper(content: content)) + controller.view.backgroundColor = .clear + controller.modalPresentationStyle = .custom + animator = OverlayAnimator() + controller.transitioningDelegate = animator + UIApplication.shared.topViewController?.present(controller, animated: true) + controller.willDismissCallback = { + show = false + } + hostingWrapper.hostingController = controller + } + + func dismiss() { + hostingWrapper.hostingController?.dismiss(animated: true) + } +} + +extension View { + @ViewBuilder + func wxmAlert(show: Binding, + content: @escaping () -> Content) -> some View { + modifier(WXMAlertModifier(show: show, content: content)) + } +} + +struct Previews_WXMAlert_Previews: PreviewProvider { + struct TestView: View { + @State private var show: Bool = false + + var body: some View { + Button { + show.toggle() + } label: { + Text(verbatim: "Hello") + } + .wxmAlert(show: $show) { + Text(verbatim: "Hellozz") + } + } + } + + static var previews: some View { + TestView() + } +} + +struct Previews_WXMAlertWrapper_Previews: PreviewProvider { + struct TestView: View { + + var body: some View { + WXMAlertModifier.AlertWrapper { + WXMAlertView(show: .constant(true), + configuration: .init( + title: "Add to favorites", + text: "Login to add **Perky Magenta Clothes** to your favorites and see historical & forecast data.".attributedMarkdown!, + secondaryButtons: [.init(title: "Cancel", action: {})], + primaryButtons: [.init(title: "Sign In", action: {})])) { + HStack { + Text(verbatim: "Don’t have an account? SIGN UP") + } + } + } + } + } + + static var previews: some View { + TestView() + } +} diff --git a/PresentationLayer/UI Components/Base Components/WXMAlert/WXMAlertView.swift b/PresentationLayer/UI Components/Base Components/WXMAlert/WXMAlertView.swift new file mode 100644 index 00000000..4cf18eed --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/WXMAlert/WXMAlertView.swift @@ -0,0 +1,172 @@ +// +// WXMAlertView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 23/8/23. +// + +import SwiftUI +import Toolkit + +struct WXMAlertConfiguration { + let title: String + let text: AttributedString + var buttonsLayout: Layout = .vertical + var secondaryButtons: [ActionButton] = [] + let primaryButtons: [ActionButton] + + enum Layout { + case horizontal + case vertical + } + + struct ActionButton: Identifiable { + var id: String { + title + } + + let title: String + let action: VoidCallback + } +} + +struct WXMAlertView: View { + @Binding var show: Bool + let configuration: WXMAlertConfiguration + let bottomView: () -> V + + var body: some View { + GeometryReader { proxy in + VStack { + Spacer() + + HStack { + Spacer() + + alertView + .frame(width: 0.8 * proxy.size.width) + + Spacer() + } + + Spacer() + } + } + } +} + +private extension WXMAlertView { + + @ViewBuilder + var alertView: some View { + VStack(spacing: CGFloat(.smallSpacing)) { + HStack { + Text(configuration.title) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + + Spacer() + + Button { + show = false + } label: { + Image(asset: .closeIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + } + + } + + HStack { + Text(configuration.text) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + Spacer() + } + + buttonsView + + bottomView() + } + .WXMCardStyle() + } + + @ViewBuilder + var buttonsView: some View { + buttonsContainer { + Group { + ForEach(configuration.secondaryButtons) { button in + Button { + show = false + button.action() + } label: { + Text(button.title) + } + .buttonStyle(WXMButtonStyle()) + } + + ForEach(configuration.primaryButtons) { button in + Button { + show = false + button.action() + } label: { + Text(button.title) + } + .buttonStyle(WXMButtonStyle.filled()) + } + } + } + } + + @ViewBuilder + func buttonsContainer(content: () -> V) -> some View { + switch configuration.buttonsLayout { + case .horizontal: + HStack { + content() + } + case .vertical: + VStack { + content() + } + } + + } +} + +struct WXMAlertView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.red + + WXMAlertView(show: .constant(true), + configuration: .init( + title: "Add to favorites", + text: "Login to add **Perky Magenta Clothes** to your favorites and see historical & forecast data.".attributedMarkdown!, + secondaryButtons: [.init(title: "Cancel", action: {})], + primaryButtons: [.init(title: "Sign In", action: {})])) { + HStack { + Text(verbatim: "Don’t have an account? SIGN UP") + } + } + } + } +} + +struct WXMAlertView_Horizontal_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.red + + WXMAlertView(show: .constant(true), + configuration: .init( + title: "Add to favorites", + text: "Login to add **Perky Magenta Clothes** to your favorites and see historical & forecast data.".attributedMarkdown!, + buttonsLayout: .horizontal, + secondaryButtons: [.init(title: "Cancel", action: {})], + primaryButtons: [.init(title: "Sign In", action: {})])) { + EmptyView() + } + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/WXMDivider.swift b/PresentationLayer/UI Components/Base Components/WXMDivider.swift new file mode 100644 index 00000000..ac43ef6b --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/WXMDivider.swift @@ -0,0 +1,21 @@ +// +// WXMDivider.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 25/4/23. +// + +import SwiftUI + +struct WXMDivider: View { + var body: some View { + Divider() + .foregroundColor(Color(colorEnum: .midGrey)) + } +} + +struct WXMDivider_Previews: PreviewProvider { + static var previews: some View { + WXMDivider() + } +} diff --git a/PresentationLayer/UI Components/Base Components/WXMEmptyView.swift b/PresentationLayer/UI Components/Base Components/WXMEmptyView.swift new file mode 100644 index 00000000..3a7391b7 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/WXMEmptyView.swift @@ -0,0 +1,137 @@ +// +// WXMEmptyView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 13/6/23. +// + +import SwiftUI +import Toolkit + +struct WXMEmptyView: View { + let configuration: Configuration + let iconDimensions: CGFloat = 150 + + var body: some View { + ZStack { + Color(colorEnum: .bg) + .ignoresSafeArea() + + VStack(spacing: CGFloat(.mediumSpacing)) { + #if MAIN_APP + if let animationEnum = configuration.animationEnum { + LottieView(animationCase: animationEnum.animationString, loopMode: .playOnce) + .frame(width: iconDimensions, height: iconDimensions) + } else if let image = configuration.image { + Image(asset: image.icon) + .if(image.tintColor != nil) { view in + view.renderingMode(.template).foregroundColor(Color(colorEnum: image.tintColor!)) + } + } + + #else + if let image = configuration.image { + Image(asset: image.icon) + .if(image.tintColor != nil) { view in + view.renderingMode(.template).foregroundColor(Color(colorEnum: image.tintColor!)) + } + } + #endif + + VStack(spacing: CGFloat(.defaultSpacing)) { + VStack(spacing: CGFloat(.smallSpacing)) { + if let title = configuration.title { + Text(title) + .font(.system(size: CGFloat(.smallTitleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.7) + } + + if let description = configuration.description { + Text(description) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.7) + } + } + + if let action = configuration.action, + let buttonTitle = configuration.buttonTitle { + Button(action: action) { + HStack(spacing: CGFloat(.smallToMediumSpacing)) { + if let fontIcon = configuration.buttonFontIcon { + Text(fontIcon.rawValue) + .font(.fontAwesome(font: .FAPro, size: CGFloat(.mediumFontSize))) + } + Text(buttonTitle) + } + } + .buttonStyle(WXMButtonStyle.filled()) + } + } + } + .if(configuration.enableSidePadding) { view in + view + .padding(CGFloat(.XLSidePadding)) + } + } + } +} + +extension WXMEmptyView { + struct Configuration { + var image: (icon: AssetEnum, tintColor: ColorEnum?)? = (.noWifiIcon, nil) + #if MAIN_APP + var animationEnum: AnimationsEnums? + #endif + var enableSidePadding: Bool = true + let title: String? + let description: AttributedString? + var buttonFontIcon: FontIcon? + var buttonTitle: String? + var action: VoidCallback? + } +} + +struct WXMEmptyView_Previews: PreviewProvider { + static var previews: some View { + let conf = WXMEmptyView.Configuration(image: (.lockedIcon, .primary), + title: "SERVICE UNAVAILABLE", + description: "Server busy, site may have moved or you lost your dial-up Internet connection", + buttonFontIcon: .gear, + buttonTitle: "Reload") { } + WXMEmptyView(configuration: conf) + } +} + +private struct WXMEmptyViewModifier: ViewModifier { + @Binding var show: Bool + let conf: WXMEmptyView.Configuration + + func body(content: Content) -> some View { + content + .if(show) { view in + view.hidden() + } + .overlay { + if show { + WXMEmptyView(configuration: conf) + .padding() + } + } + } +} + +extension View { + @ViewBuilder + func wxmEmptyView(show: Binding, configuration: WXMEmptyView.Configuration?) -> some View { + if let configuration { + modifier(WXMEmptyViewModifier(show: show, + conf: configuration)) + } else { + self + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/Weather Overview/WeatherOverviewView+Content.swift b/PresentationLayer/UI Components/Base Components/Weather Overview/WeatherOverviewView+Content.swift new file mode 100644 index 00000000..1280e527 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Weather Overview/WeatherOverviewView+Content.swift @@ -0,0 +1,324 @@ +// +// WeatherOverviewView+Content.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 6/3/23. +// + +import SwiftUI +import DomainLayer + +private extension WeatherField { + func attributedString(from weather: CurrentWeather?, + unitsManager: WeatherUnitsManager, + fontSize: CGFloat = CGFloat(.mediumFontSize)) -> AttributedString { + guard let weather else { + return "" + } + + let literals = weatherLiterals(from: weather, unitsManager: unitsManager) + let value = literals?.value ?? "" + let unit = literals?.unit ?? "" + + let font = UIFont.systemFont(ofSize: fontSize, weight: .bold) + var attributedString = AttributedString("\(value)\(shouldHaveSpaceWithUnit ? " " : "")\(unit)") + attributedString.font = font + attributedString.foregroundColor = Color(colorEnum: .text) + + if let unitRange = attributedString.range(of: unit) { + let unitFont = UIFont.systemFont(ofSize: CGFloat(.caption)) + attributedString[unitRange].foregroundColor = Color(colorEnum: .darkGrey) + attributedString[unitRange].font = unitFont + } + + return attributedString + } +} + +extension WeatherOverviewView { + + @ViewBuilder + var weatherDataView: some View { + HStack(spacing: 0.0) { + switch mode { + case .minimal: + VStack(spacing: 0.0) { + weatherImage + + Text(attributedTemperatureString) + .lineLimit(1) + .fixedSize() + .minimumScaleFactor(0.8) + + Text(attributedFeelsLikeString) + .fixedSize() + } + case .default, .medium, .large: + PercentageGridLayoutView(alignments: [.center, .leading], firstColumnPercentage: 0.5) { + Group { + VStack(spacing: 0.0) { + weatherImage + + Text(attributedTemperatureString) + .lineLimit(1) + .minimumScaleFactor(0.7) + + Text(attributedFeelsLikeString) + .minimumScaleFactor(0.7) + } + + VStack(alignment: .leading, spacing: weatherFieldsSpacing) { + ForEach(WeatherField.mainFields, id: \.description) { field in + weatherFieldView(for: field) + } + } + } + } + } + } + } + + @ViewBuilder + var noDataView: some View { + HStack(spacing: CGFloat(.defaultSpacing)) { + switch mode { + case .minimal: + Image(asset: .noDataIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + case .default, .medium, .large: + Image(asset: .noDataIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + + VStack(alignment: .leading, spacing: CGFloat(.minimumSpacing)) { + Text(LocalizableString.stationNoDataTitle.localized) + .font(.system(size: noDataTitleFontSize, weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + .minimumScaleFactor(0.6) + + Text(noDataText.localized) + .font(.system(size: CGFloat(.normalMediumFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .minimumScaleFactor(0.5) + } + } + } + } + + @ViewBuilder + var secondaryFieldsView: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + PercentageGridLayoutView(firstColumnPercentage: 0.5) { + ForEach(WeatherField.secondaryFields, id: \.description) { field in + weatherFieldView(for: field) + } + } + + if let lastUpdatedText { + HStack { + Spacer() + Text(lastUpdatedText) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .darkGrey)) + } + } + + if let buttonTitle, let buttonAction { + Button { + buttonAction() + } label: { + Text(buttonTitle) + } + .buttonStyle(WXMButtonStyle.solid) + .buttonStyle(.plain) + .disabled(!isButtonEnabled) + } + } + } + + @ViewBuilder + func weatherFieldView(for field: WeatherField) -> some View { + HStack(spacing: 0.0) { + Image(asset: field.icon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .aspectRatio(contentMode: .fill) + + VStack(alignment: .leading, spacing: 0.0) { + Text(field.description) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: weatherFieldsTitleFontSize, weight: .bold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + + Text(field.attributedString(from: weather, + unitsManager: unitsManager, + fontSize: weatherFieldsValueFontSize)) + } + } + } + + @ViewBuilder + var weatherImage: some View { +#if MAIN_APP + Group { + if let weather { + LottieView(animationCase: weather.icon?.getAnimationString() ?? "".getAnimationString(), loopMode: .loop) + } else { + LottieView(animationCase: "anim_not_available", loopMode: .loop) + } + } + .frame(width: weatherIconDimensions, height: weatherIconDimensions) +#else + Image(weather?.icon ?? "") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: weatherIconDimensions, height: weatherIconDimensions) +#endif + } + + var attributedTemperatureString: AttributedString { + let font = UIFont.systemFont(ofSize: temperatureFontSize) + let temperatureLiterals: WeatherValueLiterals = WeatherField.temperature.weatherLiterals(from: weather, unitsManager: unitsManager) ?? ("", "") + + var attributedString = AttributedString("\(temperatureLiterals.value)\(temperatureLiterals.unit)") + attributedString.font = font + attributedString.foregroundColor = Color(colorEnum: .text) + + if let unitRange = attributedString.range(of: temperatureLiterals.unit) { + let superScriptFont = UIFont.systemFont(ofSize: temperatureUnitFontSize) + attributedString[unitRange].foregroundColor = Color(colorEnum: .darkGrey) + attributedString[unitRange].font = superScriptFont + } + + return attributedString + } + + var attributedFeelsLikeString: AttributedString { + let feelsLikeLiteral = LocalizableString.feelsLike.localized + let temperatureLiterals: WeatherValueLiterals = WeatherField.feelsLike.weatherLiterals(from: weather, unitsManager: unitsManager) ?? ("", "") + + var attributedString = AttributedString("\(feelsLikeLiteral) \(temperatureLiterals.value)\(temperatureLiterals.unit)") + attributedString.font = .system(size: CGFloat(.littleCaption)) + attributedString.foregroundColor = Color(colorEnum: .darkestBlue) + + if let temperatureRange = attributedString.range(of: temperatureLiterals.value) { + attributedString[temperatureRange].font = .system(size: feelsLikeFontSize, weight: .bold) + attributedString[temperatureRange].foregroundColor = Color(colorEnum: .text) + } + + if let unitRange = attributedString.range(of: temperatureLiterals.unit) { + attributedString[unitRange].font = .system(size: feelsLikeUnitFontSize) + attributedString[unitRange].foregroundColor = Color(colorEnum: .darkGrey) + } + + return attributedString + } +} + +private extension WeatherOverviewView { + var weatherFieldsSpacing: CGFloat { + switch mode { + case .minimal: + return 0.0 + case .medium: + return 0.0 + case .large: + return CGFloat(.minimumSpacing) + case .default: + return CGFloat(.smallSpacing) + } + } + + var weatherFieldsTitleFontSize: CGFloat { + switch mode { + case .minimal: + return 0.0 + case .medium: + return CGFloat(.littleCaption) + case .large: + return CGFloat(.caption) + case .default: + return CGFloat(.caption) + } + } + + var weatherFieldsValueFontSize: CGFloat { + switch mode { + case .minimal: + 0.0 + case .medium: + CGFloat(.caption) + case .large: + CGFloat(.normalFontSize) + case .default: + CGFloat(.mediumFontSize) + } + } + + var temperatureFontSize: CGFloat { + switch mode { + case .minimal: + CGFloat(.largeTitleFontSize) + case .medium: + CGFloat(.largeTitleFontSize) + case .large: + CGFloat(.largeTitleFontSize) + case .default: + CGFloat(.XXLTitleFontSize) + } + } + + var temperatureUnitFontSize: CGFloat { + switch mode { + case .minimal: + CGFloat(.largeTitleFontSize) + case .medium: + CGFloat(.largeTitleFontSize) + case .large: + CGFloat(.largeTitleFontSize) + case .default: + CGFloat(.XLTitleFontSize) + } + } + + var feelsLikeFontSize: CGFloat { + switch mode { + case .minimal: + CGFloat(.smallFontSize) + case .medium: + CGFloat(.smallFontSize) + case .large: + CGFloat(.normalFontSize) + case .default: + CGFloat(.mediumFontSize) + } + } + + var feelsLikeUnitFontSize: CGFloat { + switch mode { + case .minimal: + CGFloat(.littleCaption) + case .medium: + CGFloat(.littleCaption) + case .large: + CGFloat(.smallFontSize) + case .default: + CGFloat(.caption) + } + } + + var noDataTitleFontSize: CGFloat { + switch mode { + case .minimal: + return CGFloat(.mediumFontSize) + case .medium: + return CGFloat(.mediumFontSize) + case .large: + return CGFloat(.mediumFontSize) + case .default: + return CGFloat(.smallTitleFontSize) + } + } +} diff --git a/PresentationLayer/UI Components/Base Components/Weather Overview/WeatherOverviewView.swift b/PresentationLayer/UI Components/Base Components/Weather Overview/WeatherOverviewView.swift new file mode 100644 index 00000000..9269cc20 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/Weather Overview/WeatherOverviewView.swift @@ -0,0 +1,112 @@ +// +// WeatherOverviewView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 6/3/23. +// + +import SwiftUI +import DomainLayer + +struct WeatherOverviewView: View { + var mode: Mode = .default + let weather: CurrentWeather? + var showSecondaryFields: Bool = false + var noDataText: LocalizableString = .stationNoDataText + var lastUpdatedText: String? + var buttonTitle: String? + var isButtonEnabled: Bool = true + var buttonAction: (() -> Void)? + + let unitsManager: WeatherUnitsManager = .default + var weatherIconDimensions: CGFloat { + switch mode { + case .minimal, .medium: + CGFloat(.weatherIconMinDimension) + case .large: + CGFloat(.weatherIconLargeDimension) + case .default: + CGFloat(.weatherIconDefaultDimension) + } + } + + var body: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + Group { + if weather != nil { + weatherDataView + } else { + noDataView + } + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .top), + insideHorizontalPadding: CGFloat(.defaultSidePadding), + insideVerticalPadding: mainViewVerticalPadding, + cornerRadius: CGFloat(.cardCornerRadius)) + + if showSecondaryFields { + secondaryFieldsView + .WXMCardStyle(backgroundColor: Color(colorEnum: .layer1), + insideHorizontalPadding: CGFloat(.defaultSidePadding), + insideVerticalPadding: 0.0, + cornerRadius: CGFloat(.cardCornerRadius)) + .if(mode == .default) { view in + view.padding(.bottom) + } + } + } + .if(showSecondaryFields) { view in + view + .background(Color(colorEnum: .layer1)) + } + .if(mode == .default) { view in + view + .cornerRadius(CGFloat(.cardCornerRadius)) + } + } +} + +extension WeatherOverviewView { + enum Mode { + case minimal + case medium + case large + case `default` + } + + private var mainViewVerticalPadding: CGFloat { + switch mode { + case .minimal: + CGFloat(.minimumPadding) + case .medium: + CGFloat(.minimumPadding) + case .large: + CGFloat(.smallSidePadding) + case .default: + CGFloat(.defaultSidePadding) + } + } +} + +struct WeatherOverviewView_Previews: PreviewProvider { + static var previews: some View { + return ZStack { + Color(.red) + WeatherOverviewView(weather: CurrentWeather.mockInstance, showSecondaryFields: true, buttonTitle: "Button text", isButtonEnabled: false) {} + } + } +} + +#Preview { + return ZStack { + Color(.red) + WeatherOverviewView(mode: .minimal, weather: CurrentWeather.mockInstance, showSecondaryFields: false) {} + } +} + +#Preview { + return ZStack { + Color(.red) + WeatherOverviewView(mode: .minimal, weather: nil, showSecondaryFields: false) {} + } +} diff --git a/PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCard+Content.swift b/PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCard+Content.swift new file mode 100644 index 00000000..f5b77516 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCard+Content.swift @@ -0,0 +1,125 @@ +// +// WeatherStationCard+Content.swift +// PresentationLayer +// +// Created by Pantelis Giazitsis on 30/1/23. +// + +import DomainLayer +import SwiftUI +import Toolkit + +/// Main ViewBuilders to be used from body callback +extension WeatherStationCard { + @ViewBuilder + var titleView: some View { + StationAddressTitleView(device: device, + followState: followState, + showSubtitle: false, + showStateIcon: true, + tapStateIconAction: followAction, + tapAddressAction: nil) + } + + @ViewBuilder + var weatherView: some View { + WeatherOverviewView(weather: device.weather) + } + + @ViewBuilder + var statusView: some View { + let alertsCount = device.alertsCount(mainVM: mainScreenViewModel, followState: followState) + if alertsCount > 1 { + multipleAlertsView(alertsCount: alertsCount) + } else if device.isActive { + warningView + } else { + HStack(spacing: CGFloat(.smallSpacing)) { + Image(asset: .offlineIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .error)) + + Text(LocalizableString.offlineStation.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + + Spacer() + } + .padding(CGFloat(.smallSidePadding)) + } + } + + @ViewBuilder + var statusContainerBackground: some View { + if !device.isActive { + Color(colorEnum: .errorTint) + } else if device.needsUpdate(mainVM: mainScreenViewModel, followState: followState) { + Color(colorEnum: .warningTint) + } else { + EmptyView() + } + } +} + +/// Viewbuilders and stuff used internally from the main Viewbuilders +private extension WeatherStationCard { + @ViewBuilder + var lastActiveView: some View { + StationLastActiveView(configuration: device.stationLastActiveConf) + } + + @ViewBuilder + var warningView: some View { + if device.needsUpdate(mainVM: mainScreenViewModel, followState: followState) { + CardWarningView(title: LocalizableString.stationWarningUpdateTitle.localized, + message: LocalizableString.stationWarningUpdateDescription.localized, + showContentFullWidth: true, + closeAction: nil) { + Button { + updateFirmwareAction() + Logger.shared.trackEvent(.prompt, parameters: [.promptName: .OTAAvailable, + .promptType: .warnPromptType, + .action: .action]) + } label: { + Text(LocalizableString.stationWarningUpdateButtonTitle.localized) + } + .buttonStyle(WXMButtonStyle()) + .buttonStyle(.plain) + } + .padding(.vertical, CGFloat(.smallSidePadding)) + .onAppear { + Logger.shared.trackEvent(.prompt, parameters: [.promptName: .OTAAvailable, + .promptType: .warnPromptType, + .action: .viewAction]) + } + } else { + EmptyView() + } + } + + @ViewBuilder + func multipleAlertsView(alertsCount: Int) -> some View { + HStack(spacing: CGFloat(.smallSpacing)) { + Image(asset: .offlineIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .error)) + + Text(LocalizableString.issues(alertsCount).localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + + Spacer() + + Button { + viewMoreAction() + } label: { + Text(LocalizableString.viewMore.localized) + .foregroundColor(Color(colorEnum: .primary)) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + .padding(.horizontal, CGFloat(.smallSidePadding)) + } + + } + .padding(CGFloat(.smallSidePadding)) + } +} diff --git a/PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCard.swift b/PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCard.swift new file mode 100644 index 00000000..75030728 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCard.swift @@ -0,0 +1,54 @@ +// +// WeatherStationCard.swift +// PresentationLayer +// +// Created by Pantelis Giazitsis on 26/1/23. +// + +import DomainLayer +import SwiftUI +import Toolkit + +struct WeatherStationCard: View { + let device: DeviceDetails + let followState: UserDeviceFollowState? + var updateFirmwareAction: VoidCallback = {} + var viewMoreAction: VoidCallback = {} + var followAction: VoidCallback? + let mainScreenViewModel: MainScreenViewModel = .shared + + var body: some View { + VStack(spacing: 0.0) { + WeatherStationCardView(device: device, followState: followState, followAction: followAction) + .background { + Color(colorEnum: .top) + .cornerRadius(CGFloat(.cardCornerRadius)) + } + + statusView + } + .background { + statusContainerBackground + .cornerRadius(CGFloat(.cardCornerRadius)) + } + .if(!device.isActive) { view in + view.strokeBorder(color: Color(colorEnum: .error), lineWidth: 1.0, radius: CGFloat(.cardCornerRadius)) + } + .if(device.isActive && device.needsUpdate(mainVM: mainScreenViewModel, followState: followState)) { view in + view.strokeBorder(color: Color(colorEnum: .warning), lineWidth: 1.0, radius: CGFloat(.cardCornerRadius)) + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .top), + insideHorizontalPadding: .zero, + insideVerticalPadding: .zero, + cornerRadius: CGFloat(.cardCornerRadius)) + .wxmShadow() + } +} + +struct WeatherStationCard_Previews: PreviewProvider { + static var previews: some View { + let device = DeviceDetails.mockDevice + return WeatherStationCard(device: device, + followState: UserDeviceFollowState(deviceId: device.id!, relation: .owned)) + } +} diff --git a/PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCardView.swift b/PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCardView.swift new file mode 100644 index 00000000..fe94d1a3 --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/WeatherStationCard/WeatherStationCardView.swift @@ -0,0 +1,55 @@ +// +// WeatherStationCardView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 29/9/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct WeatherStationCardView: View { + let device: DeviceDetails + let followState: UserDeviceFollowState? + var followAction: VoidCallback? + + var body: some View { + VStack(spacing: 0.0) { + titleView + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.top, CGFloat(.defaultSidePadding)) + + weatherView + } + } +} + +private extension WeatherStationCardView { + @ViewBuilder + var titleView: some View { + StationAddressTitleView(device: device, + followState: followState, + showSubtitle: false, + showStateIcon: true, + tapStateIconAction: followAction, + tapAddressAction: nil) + } + + @ViewBuilder + var weatherView: some View { + WeatherOverviewView(weather: device.weather) + } +} + +#Preview { + let device = DeviceDetails.mockDevice + return ZStack { + Color.red + WeatherStationCardView(device: device, + followState: UserDeviceFollowState(deviceId: device.id!, + relation: .owned)) + .frame(height: 600) + .background(Color.pink) + } +} diff --git a/PresentationLayer/UI Components/Base Components/WebView/WebContainerView.swift b/PresentationLayer/UI Components/Base Components/WebView/WebContainerView.swift new file mode 100644 index 00000000..1fc8c77d --- /dev/null +++ b/PresentationLayer/UI Components/Base Components/WebView/WebContainerView.swift @@ -0,0 +1,137 @@ +// +// WebContainerView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 4/12/23. +// + +import SwiftUI +import WebKit + +private let disableZoomScript = "var meta = document.createElement('meta');" + +"meta.name = 'viewport';" + +"meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no';" + +"var head = document.getElementsByTagName('head')[0];" + +"head.appendChild(meta);" + +struct WebContainerView: View { + let title: String + let url: String + var params: [DisplayLinkParams: String]? + var redirectParamsCallback: DeepLinkHandler.QueryParamsCallBack? + @State private var isLoading: Bool = false + + var body: some View { + NavigationContainerView { + WXMWebView(isLoading: $isLoading, + title: title, + url: url, + params: params, + redirectParamsCallback: redirectParamsCallback) + .spinningLoader(show: $isLoading, hideContent: false) + } + } +} + +private struct WXMWebView: UIViewRepresentable { + @Binding var isLoading: Bool + let title: String + let url: String + var params: [DisplayLinkParams: String]? + var redirectParamsCallback: DeepLinkHandler.QueryParamsCallBack? + + @EnvironmentObject var navigationObject: NavigationObject + + func makeCoordinator() -> Coordinator { + let coordinator = Coordinator(isLoading: $isLoading) + coordinator.redirectParamsCallback = redirectParamsCallback + + return coordinator + } + + func makeUIView(context: Context) -> WKWebView { + var webUrl = URL(string: url) + params?.forEach { key, value in + webUrl?.appendQueryItem(name: key.rawValue, value: value) + } + + let configuration = WKWebViewConfiguration() + // Disable zoom + let script: WKUserScript = WKUserScript(source: disableZoomScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true) + configuration.userContentController.addUserScript(script) + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.scrollView.bounces = false + webView.scrollView.zoomScale = 1.0 + webView.scrollView.minimumZoomScale = 1.0 + webView.scrollView.maximumZoomScale = 1.0 + + webView.navigationDelegate = context.coordinator + if let webUrl { + print("web url: \(webUrl)") + webView.load(URLRequest(url: webUrl)) + } + + DispatchQueue.main.async { + navigationObject.title = title + } + + return webView + } + + func updateUIView(_ uiView: WKWebView, context: Context) { + } + + class Coordinator: NSObject, WKNavigationDelegate, WKURLSchemeHandler { + @Binding var isLoading: Bool + var redirectParamsCallback: DeepLinkHandler.QueryParamsCallBack? + + init(isLoading: Binding) { + _isLoading = isLoading + } + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + print(urlSchemeTask) + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + print(urlSchemeTask) + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + DispatchQueue.main.async { [weak self] in + guard let requestUrl = navigationAction.request.url, + MainScreenViewModel.shared.deepLinkHandler.handleUrl(requestUrl, + queryParamsCallback: self?.redirectParamsCallback) else { + decisionHandler(.allow) + + return + } + + decisionHandler(.cancel) + } + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + isLoading = true + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + isLoading = false + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + isLoading = false + } + + func webView(_ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error) { + isLoading = false + } + } +} + +#Preview { + WebContainerView(title: "Web View",url: "https://google.com", params: [.theme: "dark"]) +} diff --git a/PresentationLayer/UI Components/MapBox/MapBoxClaimDevice.swift b/PresentationLayer/UI Components/MapBox/MapBoxClaimDevice.swift new file mode 100644 index 00000000..2c44186c --- /dev/null +++ b/PresentationLayer/UI Components/MapBox/MapBoxClaimDevice.swift @@ -0,0 +1,215 @@ +// +// MapBoxClaimDevice.swift +// PresentationLayer +// +// Created by Hristos Condrea on 1/6/22. +// + +import CoreLocation +import DomainLayer +import Foundation +import MapboxMaps +import SwiftUI +import UIKit + +struct MapBoxClaimDeviceView: View { + @Binding var location: CLLocationCoordinate2D + @Binding var annotationTitle: String? + let areLocationServicesAvailable: Bool + let geometryProxyForFrameOfMapView: CGRect + + private let markerSize: CGSize = CGSize(width: 44.0, height: 44.0) + @State private var locationPoint: CGPoint = .zero + @State private var markerViewSize: CGSize = .zero + + init(location: Binding, + annotationTitle: Binding, + areLocationServicesAvailable: Bool, geometryProxyForFrameOfMapView: CGRect) { + _location = location + _annotationTitle = annotationTitle + self.areLocationServicesAvailable = areLocationServicesAvailable + self.geometryProxyForFrameOfMapView = geometryProxyForFrameOfMapView + } + + var body: some View { + ZStack { + MapBoxClaimDevice(location: $location, + locationPoint: $locationPoint, + areLocationServicesAvailable: areLocationServicesAvailable, + geometryProxyForFrameOfMapView: geometryProxyForFrameOfMapView) + + markerAnnotation + .offset(x: 0.0, y: markerAnnotationOffset) + .position(locationPoint) + .animation(.easeIn, value: annotationTitle) + + Image(asset: .markerDefault) + .resizable() + .foregroundColor(Color(colorEnum: .mapPin)) + .frame(width: markerSize.width, height: markerSize.height) + .offset(x: 0.0, y: -markerSize.height/2.0) + .position(locationPoint) + } + } + + @ViewBuilder + private var markerAnnotation: some View { + if let annotationTitle { + HStack(spacing: CGFloat(.mediumSpacing)) { + Image(asset: .globe) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + + Text(annotationTitle) + .font(.system(size: CGFloat(.normalFontSize))) + } + .disabled(true) + .WXMCardStyle() + .wxmShadow() + } else { + EmptyView() + } + } + + private var markerAnnotationOffset: CGFloat { + if geometryProxyForFrameOfMapView.height < 320.0 { + return markerSize.height/2.0 + CGFloat(.smallSpacing) + } + + return -(markerSize.height * 3.0 / 2.0 + CGFloat(.smallSpacing)) + } +} + +struct MapBoxClaimDevice: UIViewControllerRepresentable { + @Binding var location: CLLocationCoordinate2D + @Binding var locationPoint: CGPoint + + let areLocationServicesAvailable: Bool + let geometryProxyForFrameOfMapView: CGRect + + init(location: Binding, locationPoint: Binding = .constant(.zero), areLocationServicesAvailable: Bool, geometryProxyForFrameOfMapView: CGRect) { + _location = location + _locationPoint = locationPoint + self.areLocationServicesAvailable = areLocationServicesAvailable + self.geometryProxyForFrameOfMapView = geometryProxyForFrameOfMapView + } + + func makeUIViewController(context _: Context) -> MapViewLocationController { + return MapViewLocationController( + frame: geometryProxyForFrameOfMapView, + location: $location, + locationPoint: $locationPoint, + locationServicesAvailable: areLocationServicesAvailable + ) + } + + func updateUIViewController(_ controller: MapViewLocationController, context _: Context) { + controller.setCenter(location) + } +} + +class MapViewLocationController: UIViewController { + private static let ZOOM_LEVEL: CGFloat = 15 + + let frame: CGRect + @Binding var location: CLLocationCoordinate2D + @Binding var locationPoint: CGPoint + let locationServicesAvailable: Bool + internal var mapView: MapView! + + init(frame: CGRect, location: Binding, locationPoint: Binding, locationServicesAvailable: Bool) { + self.frame = frame + _location = location + _locationPoint = locationPoint + self.locationServicesAvailable = locationServicesAvailable + super.init(nibName: nil, bundle: nil) + } + + init?(frame: CGRect, location: Binding, locationPoint: Binding, locationServicesAvailable: Bool, coder aDecoder: NSCoder) { + self.frame = frame + _location = location + _locationPoint = locationPoint + self.locationServicesAvailable = locationServicesAvailable + super.init(coder: aDecoder) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("fatal Error") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + guard let accessToken: String = Bundle.main.getConfiguration(for: .mapBoxAccessToken) else { + return + } + + let myResourceOptions = ResourceOptions(accessToken: accessToken) + let cameraOptions = cameraSetup() + let myMapInitOptions = MapInitOptions( + resourceOptions: myResourceOptions, + cameraOptions: cameraOptions + ) + + mapView = MapView(frame: CGRect(x: 0, y: 0, width: frame.width, height: frame.height), mapInitOptions: myMapInitOptions) + mapView.ornaments.options.logo.margins.y = 30 + mapView.ornaments.options.attributionButton.margins.y = 30 + mapView.ornaments.options.scaleBar.visibility = .hidden + mapView.gestures.options.rotateEnabled = false + mapView.gestures.options.pitchEnabled = false + view.addSubview(mapView) + + switch locationServicesAvailable { + case true: + mapView.location.delegate = self + mapView.location.locationProvider.startUpdatingLocation() + case false: break + } + + mapView.mapboxMap.onNext(event: .mapLoaded) { [weak self] _ in + guard let self else { + return + } + + var pointAnnotation = PointAnnotation(coordinate: self.mapView.cameraState.center) + switch self.locationServicesAvailable { + case true: + self.locationUpdate(newLocation: self.mapView.location.latestLocation!) + pointAnnotation.point.coordinates = self.mapView.cameraState.center + case false: + break + } + self.locationPoint = self.mapView.mapboxMap.point(for: self.location) + self.mapView.mapboxMap.onEvery(event: .cameraChanged, handler: { [weak self] _ in + guard let self = self else { return } + pointAnnotation.point.coordinates = self.mapView.cameraState.center + DispatchQueue.main.async { [weak self] in + if let self = self { + self.location = pointAnnotation.point.coordinates + self.locationPoint = mapView.mapboxMap.point(for: self.location) + } + } + }) + } + } + + func setCenter(_ center: CLLocationCoordinate2D) { + if mapView.mapboxMap.cameraState.center == center { + return + } + + mapView?.mapboxMap.setCamera(to: CameraOptions(center: center, zoom: Self.ZOOM_LEVEL)) + } + + internal func cameraSetup() -> CameraOptions { + return CameraOptions(center: CLLocationCoordinate2D()) + } +} + +extension MapViewLocationController: LocationPermissionsDelegate, LocationConsumer { + func locationUpdate(newLocation: Location) { + mapView.mapboxMap.setCamera(to: CameraOptions(center: newLocation.coordinate, zoom: 13)) + location = newLocation.coordinate + } +} diff --git a/PresentationLayer/UI Components/MapBox/MapBoxMapView.swift b/PresentationLayer/UI Components/MapBox/MapBoxMapView.swift new file mode 100644 index 00000000..2f8d25ad --- /dev/null +++ b/PresentationLayer/UI Components/MapBox/MapBoxMapView.swift @@ -0,0 +1,256 @@ +// +// MapBoxMapView.swift +// PresentationLayer +// +// Created by Hristos Condrea on 12/5/22. +// + +import CoreLocation +import DomainLayer +import Foundation +import MapboxMaps +import Network +import SwiftUI +import UIKit +import Toolkit + +struct MapBoxMapView: UIViewControllerRepresentable { + @EnvironmentObject var explorerViewModel: ExplorerViewModel + + func makeUIViewController(context: Context) -> MapViewController { + let mapViewController = explorerViewModel.mapController ?? MapViewController() + mapViewController.delegate = context.coordinator + explorerViewModel.mapController = mapViewController + return mapViewController + } + + func updateUIViewController(_ mapViewController: MapViewController, context _: Context) { + if let location = explorerViewModel.locationToSnap { + mapViewController.snapToLocationCoordinates(location) { + // Reset `locationToSnap` to avoid snaps on every re-render + explorerViewModel.locationToSnap = nil + } + } + + if explorerViewModel.showUserLocation { + mapViewController.showUserLocation() + } + } +} + +extension MapBoxMapView { + struct SnapLocation { + static let DEFAULT_SNAP_ZOOM_LEVEL: CGFloat = 11 + + let coordinates: CLLocationCoordinate2D + var zoomLevel: CGFloat? = DEFAULT_SNAP_ZOOM_LEVEL + } +} + +extension MapBoxMapView { + func makeCoordinator() -> Coordinator { + Coordinator(self, viewModel: explorerViewModel) + } + + class Coordinator: NSObject, MapViewControllerDelegate { + func didTapAnnotation(_: MapViewController, _ annotations: [PolygonAnnotation]) { + guard let firstValidAnnotation = annotations.first else { return } + guard let hexIndex = firstValidAnnotation.userInfo?.keys.first else { return } + viewModel.routeToDeviceListFor(hexIndex, firstValidAnnotation.polygon.center) + } + + func didTapMapArea(_: MapViewController) { + viewModel.showTopOfMapItems.toggle() + } + + func configureMap(_ mapViewController: MapViewController) { + viewModel.fetchExplorerData { explorerData in + guard let explorerData else { + return + } + + DispatchQueue.main.async { + mapViewController.configureHeatMapLayer(source: explorerData.geoJsonSource) + mapViewController.configurePolygonLayer(polygonAnnotations: explorerData.polygonPoints) + self.viewModel.snapToInitialLocation() + } + } + } + + let parent: MapBoxMapView + let viewModel: ExplorerViewModel + + init(_ mapBoxMapView: MapBoxMapView, viewModel: ExplorerViewModel) { + parent = mapBoxMapView + self.viewModel = viewModel + } + } +} + +class MapViewController: UIViewController { + private static let SNAP_ANIMATION_DURATION: CGFloat = 1.4 + private static let wxm_lat = 37.98075475244475 + private static let wxm_lon = 23.710478235562956 + + internal var mapView: MapView! + internal var layer = HeatmapLayer(id: "wtxm-heatmap-layer") + internal weak var polygonManager: PolygonAnnotationManager? + + weak var delegate: MapViewControllerDelegate? + @objc func didTapMap(_ tap: UITapGestureRecognizer) { + handleMapTap(tap) + } + + override public func viewDidLoad() { + super.viewDidLoad() + guard let accessToken: String = Bundle.main.getConfiguration(for: .mapBoxAccessToken) else { + return + } + + let myResourceOptions = ResourceOptions(accessToken: accessToken) + let myMapInitOptions = MapInitOptions(resourceOptions: myResourceOptions, styleURI: StyleURI(rawValue: MapBoxConstants.mapBoxStyle)) + + mapView = MapView(frame: view.bounds, mapInitOptions: myMapInitOptions) + mapView.ornaments.options.scaleBar.visibility = .hidden + mapView.gestures.options.rotateEnabled = false + mapView.gestures.options.pitchEnabled = false + mapView.gestures.singleTapGestureRecognizer.addTarget(self, action: #selector(didTapMap(_:))) + + view.addSubview(mapView) + mapView.mapboxMap.onNext(.mapLoaded) { [weak self] _ in + guard let self = self else { return } + self.cameraSetup() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + delegate?.configureMap(self) + } + + internal func configureHeatMapLayer(source: GeoJSONSource) { + layer.source = "wtxm-source" + layer.maxZoom = 10 + layer.heatmapColor = .expression( + Exp(.interpolate) { + Exp(.linear) + Exp(.heatmapDensity) + 0 + UIColor(red: 33.0 / 255.0, green: 102.0 / 255.0, blue: 172.0 / 255.0, alpha: 0.0) + 0.2 + UIColor(red: 103.0 / 255.0, green: 169.0 / 255.0, blue: 207.0 / 255.0, alpha: 1.0) + 0.4 + UIColor(red: 162.0 / 255.0, green: 187.0 / 255.0, blue: 201.0 / 255.0, alpha: 1.0) + 0.6 + UIColor(red: 149.0 / 255.0, green: 153.0 / 255.0, blue: 189.0 / 255.0, alpha: 1.0) + 0.8 + UIColor(red: 103.0 / 255.0, green: 118.0 / 255.0, blue: 247.0 / 255.0, alpha: 1.0) + 1 + UIColor(red: 0.0 / 255.0, green: 255.0 / 255.0, blue: 206.0 / 255.0, alpha: 1.0) + } + ) + layer.heatmapWeight = .expression( + Exp(.interpolate) { + Exp(.linear) + Exp(.get) { + "device_count" + } + 0 + 0 + 100 + 100 + } + ) + layer.heatmapRadius = .expression( + Exp(.interpolate) { + Exp(.linear) + Exp(.zoom) + 0.0 + 2 + 9.0 + 20 + } + ) + layer.heatmapOpacity = .expression( + Exp(.interpolate) { + Exp(.exponential) { + 0.5 + } + Exp(.zoom) + 0.0 + 1.0 + 8.0 + 0.9 + 9.0 + 0.5 + 9.5 + 0.1 + 10.0 + 0.0 + } + ) + do { + try mapView.mapboxMap.style.addSource(source, id: "wtxm-source") + try mapView.mapboxMap.style.addLayer(layer) + } catch { + print(error) + } + } + + internal func configurePolygonLayer(polygonAnnotations: [PolygonAnnotation]) { + let polygonAnnotationManager = self.polygonManager ?? mapView.annotations.makePolygonAnnotationManager(id: "wtxm-polygon-annotation-manager") + polygonAnnotationManager.annotations = polygonAnnotations + polygonManager = polygonAnnotationManager + } + + internal func cameraSetup() { + let centerCoordinate = CLLocationCoordinate2D(latitude: Self.wxm_lat, longitude: Self.wxm_lon) + let camera = CameraOptions(center: centerCoordinate, zoom: 1) + mapView.mapboxMap.setCamera(to: camera) + } + + func snapToLocationCoordinates(_ snapLocation: MapBoxMapView.SnapLocation, completion: @escaping VoidCallback) { + mapView.camera.fly(to: CameraOptions(center: snapLocation.coordinates, zoom: snapLocation.zoomLevel), duration: Self.SNAP_ANIMATION_DURATION) { _ in + completion() + } + } + + func showUserLocation() { + mapView?.location.options.puckType = .puck2D() + } + + private func handleMapTap(_ tap: UITapGestureRecognizer) { + guard let polygonManager = polygonManager else { return } + let layerIds = [polygonManager.layerId] + let annotations = polygonManager.annotations + let options = RenderedQueryOptions(layerIds: layerIds, filter: nil) + let point = tap.location(in: tap.view) + mapView.mapboxMap.queryRenderedFeatures(in: CGRect(origin: point, size: CGSize.zero).insetBy(dx: -20.0, dy: -20.0), options: options) { result in + switch result { + case let .success(queriedFeatures): + + // Get the identifiers of all the queried features + let queriedFeatureIds: [String] = queriedFeatures.compactMap { + guard case let .string(featureId) = $0.feature.identifier else { + return nil + } + return featureId + } + let tappedAnnotations = annotations.filter { queriedFeatureIds.contains($0.id) } + if tappedAnnotations.isEmpty { + self.delegate?.didTapMapArea(self) + return + } + self.delegate?.didTapAnnotation(self, tappedAnnotations) + case .failure: + self.delegate?.didTapMapArea(self) + } + } + } +} + +protocol MapViewControllerDelegate: AnyObject { + func configureMap(_ mapViewController: MapViewController) + func didTapAnnotation(_ mapViewController: MapViewController, _ annotations: [PolygonAnnotation]) + func didTapMapArea(_ mapViewController: MapViewController) +} diff --git a/PresentationLayer/UI Components/Modifiers/ChartModifier.swift b/PresentationLayer/UI Components/Modifiers/ChartModifier.swift new file mode 100644 index 00000000..e41b638a --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/ChartModifier.swift @@ -0,0 +1,34 @@ +// +// ChartModifier.swift +// PresentationLayer +// +// Created by Lampros Zouloumis on 6/9/22. +// + +import SwiftUI + +struct ChartModifier: ViewModifier { + let height: Double + let cornerRadius: Double + let paddingOffset: Double + + func body(content: Content) -> some View { + content + .frame(height: height) + .padding(.vertical, paddingOffset) + .background(Color(colorEnum: .top)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .padding(.horizontal, paddingOffset) + } +} + +extension View { + func chartModifier( + height: Double = 180, + cornerRadius: Double = 12, + paddingOffset: Double = 20 + ) -> some View { modifier( + ChartModifier(height: height, cornerRadius: cornerRadius, paddingOffset: paddingOffset) + ) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/ConditionalModifier.swift b/PresentationLayer/UI Components/Modifiers/ConditionalModifier.swift new file mode 100644 index 00000000..00ea1b9b --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/ConditionalModifier.swift @@ -0,0 +1,42 @@ +// +// ConditionalModifier.swift +// PresentationLayer +// +// Created by Pantelis Giazitsis on 31/1/23. +// + +import SwiftUI + +struct ConditionalModifier: ViewModifier { + let condition: Bool + + @ViewBuilder + let transform: (Content) -> V + + func body(content: Content) -> some View { + if condition { + transform(content) + } + } +} + +extension View { + @ViewBuilder + func `if`(_ condition: Bool, + @ViewBuilder transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } +} + +extension View { + /// A way to apply conditional modifiers in view + /// - Parameter transform: Apply the needed modifiers + /// - Returns: The modified view + @ViewBuilder func modify(@ViewBuilder modify: (Self) -> Content) -> some View { + modify(self) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/CornerRadius.swift b/PresentationLayer/UI Components/Modifiers/CornerRadius.swift new file mode 100644 index 00000000..e1d057a5 --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/CornerRadius.swift @@ -0,0 +1,26 @@ +// +// CornerRadius.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 3/3/23. +// + +import Foundation +import SwiftUI + +private struct CornerRadius: Shape { + + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape( CornerRadius(radius: radius, corners: corners) ) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/CustomColorButtonStyle.swift b/PresentationLayer/UI Components/Modifiers/CustomColorButtonStyle.swift new file mode 100644 index 00000000..1cded94c --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/CustomColorButtonStyle.swift @@ -0,0 +1,21 @@ +// +// CustomColorButtonStyle.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 6/12/22. +// + +import SwiftUI + +struct CustomColorButtonStyle: ButtonStyle { + var textColor: Color = .init(colorEnum: .text) + var pressedTextColor: Color = .init(colorEnum: .darkGrey) + var backgroundColor: Color = .init(colorEnum: .top) + var pressedBackgroundColor: Color = .init(colorEnum: .midGrey) + public func makeBody(configuration: Self.Configuration) -> some View { + configuration.label + .opacity(1) + .foregroundColor(configuration.isPressed ? pressedTextColor : textColor) + .background(configuration.isPressed ? pressedBackgroundColor : backgroundColor) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/GeneralButtonStyle.swift b/PresentationLayer/UI Components/Modifiers/GeneralButtonStyle.swift new file mode 100644 index 00000000..95fa932a --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/GeneralButtonStyle.swift @@ -0,0 +1,15 @@ +// +// GeneralButtonStyle.swift +// PresentationLayer +// +// Created by Hristos Condrea on 23/5/22. +// + +import SwiftUI + +public struct GeneralButtonStyle: ButtonStyle { + public func makeBody(configuration: Self.Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.7 : 1) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/OnAnimationCompletedModifier.swift b/PresentationLayer/UI Components/Modifiers/OnAnimationCompletedModifier.swift new file mode 100644 index 00000000..7f66a75a --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/OnAnimationCompletedModifier.swift @@ -0,0 +1,49 @@ +// +// OnAnimationCompletedModifier.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 21/11/22. +// + +import SwiftUI + +struct AnimationCompletionObserverModifier: AnimatableModifier where Value: VectorArithmetic { + var animatableData: Value { + didSet { + notifyCompletionIfFinished() + } + } + + private var targetValue: Value + + private var completion: () -> Void + + init(observedValue: Value, completion: @escaping () -> Void) { + self.completion = completion + animatableData = observedValue + targetValue = observedValue + } + + private func notifyCompletionIfFinished() { + guard animatableData == targetValue else { return } + + DispatchQueue.main.async { + self.completion() + } + } + + func body(content: Content) -> some View { + return content + } +} + +extension View { + /// Calls the completion handler whenever an animation on the given value completes. + /// - Parameters: + /// - value: The value to observe for animations. + /// - completion: The completion callback to call once the animation completes. + /// - Returns: A modified `View` instance with the observer attached. + func onAnimationCompleted(for value: Value, completion: @escaping () -> Void) -> ModifiedContent> { + return modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion)) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/SizeObserver.swift b/PresentationLayer/UI Components/Modifiers/SizeObserver.swift new file mode 100644 index 00000000..04ee6f3a --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/SizeObserver.swift @@ -0,0 +1,59 @@ +// +// SizeObserver.swift +// PresentationLayer +// +// Created by Pantelis Giazitsis on 24/1/23. +// + +import SwiftUI + +/// A modifier to observe the size of the view +struct SizeObserver: ViewModifier { + @Binding var size: CGSize + @Binding var frame: CGRect + + public init(size: Binding, frame: Binding = .constant(.zero)) { + _size = size + _frame = frame + } + + public func body(content: Content) -> some View { + content + .background(GeometryReader { geometry in + ZStack { + updateFrame(frame: geometry.frame(in: .global)) + } + }) + } + + @ViewBuilder + func updateFrame(frame: CGRect) -> some View { + Color.clear.preference(key: FramePreferenceKey.self, value: frame) + .onPreferenceChange(FramePreferenceKey.self) { preferences in + DispatchQueue.main.async { + if self.frame != preferences { + self.frame = preferences + } + + if self.size != preferences.size { + self.size = preferences.size + } + } + } + } + + private struct FramePreferenceKey: PreferenceKey { + typealias Value = CGRect + static var defaultValue: Value = .zero + + static func reduce(value _: inout Value, nextValue: () -> Value) { + _ = nextValue() + } + } +} + +public extension View { + func sizeObserver(size: Binding, frame: Binding = .constant(.zero)) -> some View { + modifier(SizeObserver(size: size, frame: frame)) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/StrokeBorderModifier.swift b/PresentationLayer/UI Components/Modifiers/StrokeBorderModifier.swift new file mode 100644 index 00000000..7465b8a1 --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/StrokeBorderModifier.swift @@ -0,0 +1,29 @@ +// +// StrokeBorderModifier.swift +// PresentationLayer +// +// Created by Pantelis Giazitsis on 30/1/23. +// + +import SwiftUI + +private struct StrokeBorderModifier: ViewModifier { + let color: Color + let lineWidth: CGFloat + let radius: CGFloat + + func body(content: Content) -> some View { + content + .overlay { + RoundedRectangle(cornerRadius: radius) + .strokeBorder(color, lineWidth: lineWidth) + } + } +} + +extension View { + @ViewBuilder + func strokeBorder(color: Color, lineWidth: CGFloat, radius: CGFloat) -> some View { + modifier(StrokeBorderModifier(color: color, lineWidth: lineWidth, radius: radius)) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/TabBarModifier.swift b/PresentationLayer/UI Components/Modifiers/TabBarModifier.swift new file mode 100644 index 00000000..b1eb46d3 --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/TabBarModifier.swift @@ -0,0 +1,45 @@ +// +// TabBarModifier.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 12/5/22. +// + +import SwiftUI + +struct TabBarModifier: ViewModifier { + let insideHorizontalTabBarPadding: CGFloat + let insideVerticalTabBarPadding: CGFloat + let bottomPaddingTabBarPadding: CGFloat + let tabBarContainerRadius: CGFloat + let backgroundColor: Color + + func body(content: Content) -> some View { + content + .padding(.horizontal, insideHorizontalTabBarPadding) + .padding(.vertical, insideVerticalTabBarPadding) + .background(backgroundColor) + .cornerRadius(tabBarContainerRadius) + .padding(.bottom, bottomPaddingTabBarPadding) + .shadow(radius: ShadowEnum.tabBar.radius, x: ShadowEnum.tabBar.xVal, y: ShadowEnum.tabBar.yVal) + } +} + +extension View { + func tabBarStyle( + insideHorizontalTabBarPadding: CGFloat = CGFloat(.largeSidePadding), + insideVerticalTabBarPadding: CGFloat = CGFloat(.mediumSidePadding), + bottomPaddingTabBarPadding: CGFloat = CGFloat(.largeSidePadding), + tabBarContainerRadius: CGFloat = CGFloat(.tabBarCornerRadius), + backgroundColor: Color = Color(colorEnum: .top) + ) -> some View { modifier( + TabBarModifier( + insideHorizontalTabBarPadding: insideHorizontalTabBarPadding, + insideVerticalTabBarPadding: insideVerticalTabBarPadding, + bottomPaddingTabBarPadding: bottomPaddingTabBarPadding, + tabBarContainerRadius: tabBarContainerRadius, + backgroundColor: backgroundColor + ) + ) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/TextfieldClearButton.swift b/PresentationLayer/UI Components/Modifiers/TextfieldClearButton.swift new file mode 100644 index 00000000..4ff076b6 --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/TextfieldClearButton.swift @@ -0,0 +1,68 @@ +// +// TextfieldClearButton.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 21/6/23. +// + +import SwiftUI + +private struct TextfieldClearButtonModifier: ViewModifier { + @Binding var text: String + @Binding var isLoading: Bool + let icon: AssetEnum + + @State private var clearIconSize: CGSize = .zero + + func body(content: Content) -> some View { + HStack { + content + .padding(.trailing, text.isEmpty ? 0.0 : clearIconSize.width) + .overlay { + HStack { + Spacer() + + if !text.isEmpty { + ZStack { + if isLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(Color(colorEnum: .text)) + .frame(width: clearIconSize.width) + } else { + Button { + text.removeAll() + } label: { + Image(asset: icon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + } + .transition(AnyTransition.opacity.animation(.easeIn(duration: 0.1))) + .sizeObserver(size: $clearIconSize) + } + } + .multilineTextAlignment(.center) + } + } + } + + } + } +} + +extension TextField { + @ViewBuilder + func textFieldClearButton(text: Binding, + isLoading: Binding, + icon: AssetEnum) -> some View { + modifier(TextfieldClearButtonModifier(text: text, isLoading: isLoading, icon: icon)) + } +} + +struct Previews_TextfieldClearButton_Previews: PreviewProvider { + static var previews: some View { + TextField("Search here", text: .constant("")) + .textFieldClearButton(text: .constant("fewf"), isLoading: .constant(true), icon: .clearIcon) + + } +} diff --git a/PresentationLayer/UI Components/Modifiers/WXMButtonStyle.swift b/PresentationLayer/UI Components/Modifiers/WXMButtonStyle.swift new file mode 100644 index 00000000..c7adfa6e --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/WXMButtonStyle.swift @@ -0,0 +1,104 @@ +// +// WXMButtonStyle.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 1/10/22. +// + +import SwiftUI + +struct WXMButtonStyle: ButtonStyle { + private let textColor: Color + private let textColorDisabled: Color + private let fillColor: Color + private let fillColorDisabled: Color + private let strokeColor: Color + private let strokeColorDisabled: Color + private let fixedSize: Bool + + @Environment(\.isEnabled) private var isEnabled: Bool + + init( + textColor: ColorEnum = .primary, + textColorDisabled: ColorEnum = .darkGrey, + fillColor: ColorEnum = .clear, + fillColorDisabled: ColorEnum = .midGrey, + strokeColor: ColorEnum = .primary, + strokeColorDisabled: ColorEnum = .midGrey, + fixedSize: Bool = false + ) { + self.fillColor = Color(colorEnum: fillColor) + self.fillColorDisabled = Color(colorEnum: fillColorDisabled) + self.textColor = Color(colorEnum: textColor) + self.textColorDisabled = Color(colorEnum: textColorDisabled) + self.strokeColor = Color(colorEnum: strokeColor) + self.strokeColorDisabled = Color(colorEnum: strokeColorDisabled) + self.fixedSize = fixedSize + } + + private init(textColor: Color, + textColorDisabled: Color, + fillColor: Color, + fillColorDisabled: Color, + strokeColor: Color, + strokeColorDisabled: Color, + fixedSize: Bool = false) { + self.fillColor = fillColor + self.fillColorDisabled = fillColorDisabled + self.textColor = textColor + self.textColorDisabled = textColorDisabled + self.strokeColor = strokeColor + self.strokeColorDisabled = strokeColorDisabled + self.fixedSize = fixedSize + } + + func makeBody(configuration: Self.Configuration) -> some View { + let stroke = isEnabled ? strokeColor : strokeColorDisabled + let fill = isEnabled ? fillColor : fillColorDisabled + configuration.label + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + .foregroundColor(isEnabled ? textColor : textColorDisabled) + .if(!fixedSize) { view in + view.frame(maxWidth: .infinity) + .frame(height: 50) + } + .background { + fill + } + .strokeBorder(color: stroke, lineWidth: 2.0, radius: CGFloat(.buttonCornerRadius)) + .opacity(configuration.isPressed ? 0.7 : 1) + .contentShape(Rectangle()) + .cornerRadius(CGFloat(.buttonCornerRadius)) + } +} + +extension WXMButtonStyle { + static func filled(fixedSize: Bool = false) -> Self { + Self.init(textColor: .top, + fillColor: .primary, + fixedSize: fixedSize) + } + + static func plain(fixedSize: Bool = false) -> Self { + Self.init(strokeColor: .clear, + fixedSize: fixedSize) + } + + static var solid: Self { + Self.init(textColor: Color(colorEnum: .primary), + textColorDisabled: Color(colorEnum: .midGrey), + fillColor: Color(colorEnum: .top), + fillColorDisabled: Color(colorEnum: .midGrey).opacity(0.15), + strokeColor: Color(colorEnum: .primary), + strokeColorDisabled: Color(colorEnum: .midGrey)) + } + + static var transparent: Self { + Self.init(textColor: Color(colorEnum: .primary), + textColorDisabled: Color(colorEnum: .midGrey), + fillColor: Color(colorEnum: .top).opacity(0.15), + fillColorDisabled: Color(colorEnum: .midGrey).opacity(0.15), + strokeColor: Color.clear, + strokeColorDisabled: Color.clear) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/WXMCardStyle.swift b/PresentationLayer/UI Components/Modifiers/WXMCardStyle.swift new file mode 100644 index 00000000..2053fce3 --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/WXMCardStyle.swift @@ -0,0 +1,44 @@ +// +// WXMCardStyle.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 10/5/22. +// + +import SwiftUI + +struct WXMCardModifier: ViewModifier { + let backgroundColor: Color + let foregroundColor: Color + let insideHorizontalPadding: CGFloat + let insideVerticalPadding: CGFloat + let cornerRadius: CGFloat + + func body(content: Content) -> some View { + content + .padding(.horizontal, insideHorizontalPadding) + .padding(.vertical, insideVerticalPadding) + .background(backgroundColor) + .foregroundColor(foregroundColor) + .cornerRadius(cornerRadius) + } +} + +extension View { + func WXMCardStyle( + backgroundColor: Color = Color(colorEnum: .top), + foregroundColor: Color = Color(colorEnum: .text), + insideHorizontalPadding: CGFloat = CGFloat(.defaultSidePadding), + insideVerticalPadding: CGFloat = CGFloat(.defaultSidePadding), + cornerRadius: CGFloat = CGFloat(.cardCornerRadius) + + ) -> some View { modifier( + WXMCardModifier( + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + insideHorizontalPadding: insideHorizontalPadding, + insideVerticalPadding: insideVerticalPadding, + cornerRadius: cornerRadius + )) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/WXMPopover.swift b/PresentationLayer/UI Components/Modifiers/WXMPopover.swift new file mode 100644 index 00000000..71c08e01 --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/WXMPopover.swift @@ -0,0 +1,114 @@ +// +// WXMPopover.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 5/7/23. +// + +import SwiftUI +import Toolkit + +private struct PopOverModifier: ViewModifier { + @Binding var show: Bool + let content: () -> V + @State private var hostingWrapper: HostingWrapper = HostingWrapper() + @State private var store = Store() + + func body(content: Content) -> some View { + if #available(iOS 16.4, *) { + content + .popover(isPresented: $show) { + self.content() + .fixedSize() + .presentationCompactAdaptation(.popover) + } + .onChange(of: show) { value in + /// Add an overlay on top of `NavigationStack` container to fix a SwiftUI issue with gestures + /// More info https://stackoverflow.com/questions/71714592/sheet-dismiss-gesture-with-swipe-back-gesture-causes-app-to-freeze + Router.shared.showDummyOverlay = value + } + } else { + content + .background(InternalAnchorView(uiView: store.anchorView)) + .onChange(of: show) { newValue in + if newValue { + presentPopover() + } else { + dismissPopover() + } + } + } + } + + private func presentPopover() { + let contentController = PopoverHostingController(rootView: content()) + contentController.modalPresentationStyle = .popover + contentController.willDismissCallback = { + show = false + } + + let view = store.anchorView + guard let popover = contentController.popoverPresentationController else { return } + popover.sourceView = view + popover.sourceRect = view.bounds + popover.permittedArrowDirections = .up + popover.delegate = contentController + + hostingWrapper.hostingController = contentController + UIApplication.shared.topViewController?.present(contentController, animated: true) + } + + private func dismissPopover() { + hostingWrapper.hostingController?.dismiss(animated: true) + } +} + +private extension PopOverModifier { + struct Store { + var anchorView = UIView() + } + + class PopoverHostingController: UIHostingController, UIPopoverPresentationControllerDelegate { + var willDismissCallback: VoidCallback? + + override func viewDidLoad() { + super.viewDidLoad() + let size = sizeThatFits(in: UIView.layoutFittingExpandedSize) + preferredContentSize = size + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if isBeingDismissed { + willDismissCallback?() + } + } + + func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + .none + } + + deinit { + print("deinit \(Self.self)") + } + } + + struct InternalAnchorView: UIViewRepresentable { + typealias UIViewType = UIView + let uiView: UIView + + func makeUIView(context: Self.Context) -> Self.UIViewType { + uiView + } + + func updateUIView(_ uiView: Self.UIViewType, context: Self.Context) { } + } +} + +extension View { + + @ViewBuilder + func wxmPopOver(show: Binding, content: @escaping () -> Content) -> some View { + modifier(PopOverModifier(show: show, content: content)) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/WXMShadow.swift b/PresentationLayer/UI Components/Modifiers/WXMShadow.swift new file mode 100644 index 00000000..effe714a --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/WXMShadow.swift @@ -0,0 +1,24 @@ +// +// WXMShadow.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 5/4/23. +// + +import SwiftUI + +struct WXMShadow: ViewModifier { + func body(content: Content) -> some View { + content + .shadow(color: Color(.black).opacity(0.25), + radius: ShadowEnum.stationCard.radius, + x: ShadowEnum.stationCard.xVal, + y: ShadowEnum.stationCard.yVal) + } +} + +extension View { + func wxmShadow() -> some View { + modifier(WXMShadow()) + } +} diff --git a/PresentationLayer/UI Components/Modifiers/WXMToggleStyle.swift b/PresentationLayer/UI Components/Modifiers/WXMToggleStyle.swift new file mode 100644 index 00000000..3014d77b --- /dev/null +++ b/PresentationLayer/UI Components/Modifiers/WXMToggleStyle.swift @@ -0,0 +1,66 @@ +// +// ColoredToggleStyle.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 15/10/22. +// + +import SwiftUI + +struct WXMToggleStyle: ToggleStyle { + static var Default: WXMToggleStyle = .init( + onColor: Color(colorEnum: .primary), + onIcon: Image(asset: .toggleCheckmark), + offIcon: Image(asset: .toggleXMark), + thumbColorOff: Color(colorEnum: .darkGrey), + strokeColorOff: Color(colorEnum: .darkGrey) + ) + + var label = "" + var onColor = Color(UIColor.green) + var offColor = Color(UIColor.systemGray5) + var onIcon: Image? + var offIcon: Image? + var thumbPadding: CGFloat = 2 + var thumbColorOn = Color.white + var thumbColorOff = Color.white + var strokeColorOn = Color.clear + var strokeColorOff = Color.clear + var strokeWidth: CGFloat = 1 + + func makeBody(configuration: Self.Configuration) -> some View { + HStack { + let overlayIcon = configuration.isOn && onIcon != nil || !configuration.isOn && offIcon != nil + ? configuration.isOn ? onIcon! : offIcon! + : nil + + let circle = Circle() + .fill(configuration.isOn ? thumbColorOn : thumbColorOff) + .overlay(overlayIcon) + .shadow(radius: 1, x: 0, y: 1) + .padding(thumbPadding) + .offset(x: configuration.isOn ? 10 : -10) + + if !label.isEmpty { + Text(label) + Spacer() + } + + Button { + configuration.isOn.toggle() + } label: { + RoundedRectangle(cornerRadius: 16, style: .circular) + .style( + withStroke: configuration.isOn ? strokeColorOn : strokeColorOff, + lineWidth: strokeWidth, + fill: configuration.isOn ? onColor : offColor + ) + .frame(width: 50, height: 29) + .overlay(circle) + .animation(Animation.easeInOut(duration: 0.1), value: configuration.isOn) + } + .buttonStyle(StaticButtonStyle()) + } + .font(.title) + } +} diff --git a/PresentationLayer/UI Components/Navigation/CustomNavigationLinkView.swift b/PresentationLayer/UI Components/Navigation/CustomNavigationLinkView.swift new file mode 100644 index 00000000..3a59f68d --- /dev/null +++ b/PresentationLayer/UI Components/Navigation/CustomNavigationLinkView.swift @@ -0,0 +1,27 @@ +// +// CustomNavigationLinkView.swift +// PresentationLayer +// +// Created by Hristos Condrea on 20/5/22. +// + +import SwiftUI + +struct CustomNavigationLinkView: View { + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + GeometryReader { _ in + ZStack { + Color(colorEnum: .bg).edgesIgnoringSafeArea(.vertical) + content + .buttonStyle(GeneralButtonStyle()) + } + + }.ignoresSafeArea(.keyboard, edges: .all) + } +} diff --git a/PresentationLayer/UI Components/Navigation/NavigationContainerView.swift b/PresentationLayer/UI Components/Navigation/NavigationContainerView.swift new file mode 100644 index 00000000..6f094186 --- /dev/null +++ b/PresentationLayer/UI Components/Navigation/NavigationContainerView.swift @@ -0,0 +1,129 @@ +// +// NavigatationContainerView.swift +// PresentationLayer +// +// Created by Pantelis Giazitsis on 14/2/23. +// + +import SwiftUI +import Toolkit + +class NavigationObject: ObservableObject { + @Published var title = "" + @Published var subtitle: String? + @Published var titleColor = Color(colorEnum: .text) + @Published var navigationBarColor: Color? = Color(colorEnum: .top) + var willDismissAction: (() -> Void)? +} + +struct NavigationContainerView: View { + @Environment(\.dismiss) private var dismiss + private let content: Content + private let rightView: RightView + @StateObject private var navigationObject = NavigationObject() + + private let showBackButton: Bool + + init(showBackButton: Bool = true, + @ViewBuilder rightView: () -> RightView = { EmptyView() }, + @ViewBuilder content: () -> Content) { + self.showBackButton = showBackButton + self.content = content() + self.rightView = rightView() + } + + var body: some View { + VStack(spacing: 0.0) { + navbar + .zIndex(100) + + content + .environmentObject(navigationObject) + } + .navigationBarHidden(true) + } +} + +private extension NavigationContainerView { + @ViewBuilder + var navbar: some View { + ZStack { + HStack(spacing: CGFloat(.mediumSpacing)) { + if showBackButton { + Button { + navigationObject.willDismissAction?() + dismiss() + } label: { + Image(asset: .backArrow) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .primary)) + } + } + + HStack(spacing: CGFloat(.minimumSpacing)) { + + VStack { + HStack { + Text(navigationObject.title) + .font(.system(size: CGFloat(.largeTitleFontSize))) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(navigationObject.titleColor) + + Spacer() + } + + if let subtitle = navigationObject.subtitle { + HStack { + Text(subtitle) + .font(.system(size: CGFloat(.largeFontSize))) + .foregroundColor(navigationObject.titleColor) + + Spacer() + } + } + } + + Spacer() + + rightView + } + } + .padding(CGFloat(.mediumSidePadding)) + } + .background { + if let color = navigationObject.navigationBarColor { + color + .ignoresSafeArea() + } + } + } +} + +struct NavigationContainerView_Previews: PreviewProvider { + static var previews: some View { + NavigationContainerView { + Button { + } label: { + Text(FontIcon.calendar.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: .primary)) + .frame(width: 30.0, height: 30.0) + } + + } content: { + TestView() + } + } + + struct TestView: View { + @EnvironmentObject var navigationObject: NavigationObject + + var body: some View { + Text(verbatim: "hellozzz") + .onAppear { + navigationObject.title = "Test title here! Long long text" + } + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Analytics/AnalyticsView.swift b/PresentationLayer/UI Components/Screens/Analytics/AnalyticsView.swift new file mode 100644 index 00000000..478f3e6a --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Analytics/AnalyticsView.swift @@ -0,0 +1,189 @@ +// +// AnalyticsView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 19/5/23. +// + +import SwiftUI + +struct AnalyticsView: View { + @Binding var show: Bool + @StateObject var viewModel: AnalyticsViewModel + @State private var bottomButtonsSize: CGSize = .zero + + var body: some View { + ZStack { + Color(colorEnum: .top) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: CGFloat(.largeSpacing)) { + Spacer() + + VStack(spacing: CGFloat(.defaultSpacing)) { + Image(asset: .analyticsIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .darkGrey)) + .padding(CGFloat(.XLSidePadding)) + .background { + Circle() + .foregroundColor(Color(colorEnum: .layer1)) + } + + VStack(spacing: CGFloat(.smallSpacing)) { + Text(LocalizableString.Analytics.title.localized) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.largeTitleFontSize), weight: .bold)) + + Text(LocalizableString.Analytics.description.localized.attributedMarkdown ?? "") + .multilineTextAlignment(.center) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + + } + + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + + VStack(spacing: CGFloat(.XLSpacing)) { + collectView + + captionView + } + .padding(.horizontal, CGFloat(.XLSidePadding)) + } + } + .padding(.bottom, bottomButtonsSize.height) + + VStack { + Spacer() + + bottomButtons + .padding(CGFloat(.defaultSidePadding)) + .sizeObserver(size: $bottomButtonsSize) + } + } + } +} + +private extension AnalyticsView { + @ViewBuilder + var collectView: some View { + LazyVGrid(columns: [GridItem(), GridItem()]) { + VStack(alignment: .leading, spacing: CGFloat(.smallSpacing)) { + HStack { + Text(LocalizableString.Analytics.whatWeCollect.localized) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .fixedSize(horizontal: true, vertical: false) + + Spacer() + } + + let fields: [LocalizableString.Analytics] = [.appUsage, .systemVerion] + ForEach(fields, id: \.self) { field in + HStack(spacing: CGFloat(.minimumSpacing)) { + Image(asset: .toggleCheckmark) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .top)) + .frame(width: 20.0, height: 20.0) + .background { + Circle().foregroundColor(Color(colorEnum: .text)) + } + + Text(field.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + .fixedSize(horizontal: true, vertical: false) + + Spacer() + } + } + } + + VStack(alignment: .leading, spacing: CGFloat(.smallSpacing)) { + HStack { + Text(LocalizableString.Analytics.whatWeDontCollect.localized) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .fixedSize(horizontal: true, vertical: false) + Spacer() + } + + let fields: [LocalizableString.Analytics] = [.personalData, .identifyingInfo] + ForEach(fields, id: \.self) { field in + HStack(spacing: CGFloat(.minimumSpacing)) { + Image(asset: .toggleXMark) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .top)) + .frame(width: 20.0, height: 20.0) + .background { + Circle().foregroundColor(Color(colorEnum: .text)) + } + + Text(field.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + .fixedSize(horizontal: true, vertical: false) + + Spacer() + } + } + } + } + } + + @ViewBuilder + var captionView: some View { + HStack(spacing: CGFloat(.smallSpacing)) { + Image(asset: .accountFilledIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .darkGrey)) + .padding(CGFloat(.minimumPadding)) + .background { + Circle() + .foregroundColor(Color(colorEnum: .bg)) + } + + Text(LocalizableString.Analytics.caption.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + + Spacer() + } + .padding(CGFloat(.smallSidePadding)) + .background { + Capsule() + .foregroundColor(Color(colorEnum: .layer1)) + } + } + + @ViewBuilder + var bottomButtons: some View { + HStack(spacing: CGFloat(.defaultSpacing)) { + Button { + viewModel.denyButtonTapped() + show.toggle() + } label: { + Text(LocalizableString.deny.localized) + } + .buttonStyle(WXMButtonStyle()) + + Button { + viewModel.soundsGoodButtonTapped() + show.toggle() + } label: { + Text(LocalizableString.soundsGood.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + } + } +} + +struct AnalyticsView_Previews: PreviewProvider { + static var previews: some View { + AnalyticsView(show: .constant(true), + viewModel: ViewModelsFactory.getAnalyticsViewModel()) + } +} diff --git a/PresentationLayer/UI Components/Screens/Analytics/AnalyticsViewModel.swift b/PresentationLayer/UI Components/Screens/Analytics/AnalyticsViewModel.swift new file mode 100644 index 00000000..21bd940f --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Analytics/AnalyticsViewModel.swift @@ -0,0 +1,26 @@ +// +// AnalyticsViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 19/5/23. +// + +import Foundation +import DomainLayer + +class AnalyticsViewModel: ObservableObject { + + private let useCase: SettingsUseCase + + init(useCase: SettingsUseCase) { + self.useCase = useCase + } + + func denyButtonTapped() { + useCase.optInOutAnalytics(false) + } + + func soundsGoodButtonTapped() { + useCase.optInOutAnalytics(true) + } +} diff --git a/PresentationLayer/UI Components/Screens/App Update/AppUpdateView.swift b/PresentationLayer/UI Components/Screens/App Update/AppUpdateView.swift new file mode 100644 index 00000000..5181d448 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/App Update/AppUpdateView.swift @@ -0,0 +1,91 @@ +// +// AppUpdateView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 6/12/23. +// + +import SwiftUI + +struct AppUpdateView: View { + @Binding var show: Bool + @StateObject var viewModel: AppUpdateViewModel + + var body: some View { + NavigationContainerView(showBackButton: false) { + ContentView(show: $show, viewModel: viewModel) + } + } +} + +private struct ContentView: View { + @Binding var show: Bool + @ObservedObject var viewModel: AppUpdateViewModel + @EnvironmentObject var navigationObject: NavigationObject + + var body: some View { + ZStack { + Color(colorEnum: .bg) + .ignoresSafeArea() + + VStack(spacing: CGFloat(.defaultSpacing)) { + TrackableScrollView { + VStack(spacing: CGFloat(.defaultSpacing)) { + HStack { + Text(LocalizableString.AppUpdate.description.localized) + .font(.system(size: CGFloat(.mediumFontSize))) + + Spacer() + } + + HStack { + Text(LocalizableString.AppUpdate.whatsNewTitle.localized) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + + Spacer() + } + + HStack { + Text(viewModel.whatsNewText ?? "-") + .font(.system(size: CGFloat(.mediumFontSize))) + + Spacer() + } + } + } + + Spacer(minLength: 0.0) + + VStack(spacing: CGFloat(.smallSpacing)) { + Button { + viewModel.handleUpdateButtonTap() + } label: { + Text(LocalizableString.AppUpdate.updateButtonTitle.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + + if !viewModel.forceUpdate { + Button { + viewModel.handleNoUpdateButtonTap() + show = false + } label: { + Text(LocalizableString.AppUpdate.noUpdateButtonTitle.localized) + } + .buttonStyle(WXMButtonStyle.plain()) + } + } + } + .WXMCardStyle() + .padding(CGFloat(.defaultSidePadding)) + } + .onAppear { + navigationObject.title = LocalizableString.AppUpdate.title.localized + navigationObject.navigationBarColor = Color(colorEnum: .bg) + } + } +} + +#Preview { + AppUpdateView(show: .constant(true), + viewModel: ViewModelsFactory.getAppUpdateViewModel()) +} diff --git a/PresentationLayer/UI Components/Screens/App Update/AppUpdateViewModel.swift b/PresentationLayer/UI Components/Screens/App Update/AppUpdateViewModel.swift new file mode 100644 index 00000000..f13c25c7 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/App Update/AppUpdateViewModel.swift @@ -0,0 +1,50 @@ +// +// AppUpdateViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 6/12/23. +// + +import Foundation +import UIKit +import Toolkit +import DomainLayer +import Combine + +class AppUpdateViewModel: ObservableObject { + @Published var whatsNewText: String? + @Published var forceUpdate: Bool = false + + private var cancellableSet: Set = .init() + private let useCase: MainUseCase + + init(useCase: MainUseCase) { + self.useCase = useCase + + RemoteConfigManager.shared.$iosAppChangelog.assign(to: &$whatsNewText) + + RemoteConfigManager.shared.$iosAppMinimumVersion.sink { [weak self] minVersion in + guard let self, let minVersion else { + return + } + + self.forceUpdate = self.useCase.shouldForceUpdate(minimumVersion: minVersion) + }.store(in: &cancellableSet) + } + + func handleUpdateButtonTap() { + if let url = URL(string: DisplayedLinks.appstore.linkURL) { + UIApplication.shared.open(url) + } + + if let version = RemoteConfigManager.shared.iosAppLatestVersion { + useCase.updateLastAppVersionPrompt(with: version) + } + } + + func handleNoUpdateButtonTap() { + if let version = RemoteConfigManager.shared.iosAppLatestVersion { + useCase.updateLastAppVersionPrompt(with: version) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Change Frequency/ChangeFrequencyView.swift b/PresentationLayer/UI Components/Screens/Change Frequency/ChangeFrequencyView.swift new file mode 100644 index 00000000..abe5e677 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Change Frequency/ChangeFrequencyView.swift @@ -0,0 +1,115 @@ +// +// ChangeFrequencyView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 13/3/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct ChangeFrequencyView: View { + @StateObject var viewModel: ChangeFrequencyViewModel + @EnvironmentObject var navigationObject: NavigationObject + @Environment(\.dismiss) private var dismiss + + var body: some View { + + ZStack { + Color(colorEnum: .top) + .ignoresSafeArea() + + VStack { + if viewModel.state == .setFrequency { + VStack(spacing: 0.0) { + SelectFrequencyView(selectedFrequency: $viewModel.selectedFrequency, + isFrequencyAcknowledged: $viewModel.isFrequencyAcknowledged) + + HStack(spacing: CGFloat(.mediumSpacing)) { + Button { + viewModel.cancelButtonTapped() + } label: { + Text(LocalizableString.cancel.localized) + } + .buttonStyle(WXMButtonStyle()) + + Button { + viewModel.changeButtonTapped() + } label: { + Text(LocalizableString.change.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + .disabled(!viewModel.isFrequencyAcknowledged) + } + } + } else { + DeviceUpdatesLoadingView(title: LocalizableString.changingFrequency.localized, + subtitle: nil, + steps: viewModel.steps, + currentStepIndex: $viewModel.currentStepIndex, + progress: .constant(nil)) + } + } + .fail(show: Binding(get: { viewModel.state.isFailed }, set: { _ in }), obj: viewModel.state.stateObject) + .success(show: Binding(get: { viewModel.state.isSuccess }, set: { _ in }), obj: viewModel.state.stateObject) + .animation(.easeIn, value: viewModel.state) + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .onAppear { + navigationObject.title = LocalizableString.deviceInfoButtonChangeFrequency.localized + Logger.shared.trackScreen(.changeFrequency, + parameters: [.itemId: .custom(viewModel.device.id ?? "")]) + } + .onChange(of: viewModel.dismissToggle) { _ in + dismiss() + } + } + } +} + +struct ChangeFrequencyView_Set_Previews: PreviewProvider { + static var previews: some View { + var device = DeviceDetails.emptyDeviceDetails + device.profile = .helium + + return NavigationContainerView { + ChangeFrequencyView(viewModel: ChangeFrequencyViewModel(device: device, useCase: nil)) + } + } +} + +struct ChangeFrequencyView_Change_Previews: PreviewProvider { + static var previews: some View { + var device = DeviceDetails.emptyDeviceDetails + device.profile = .helium + let vm = ChangeFrequencyViewModel(device: device, useCase: nil) + vm.state = .changeFrequency + return NavigationContainerView { + ChangeFrequencyView(viewModel: vm) + } + } +} + +struct ChangeFrequencyView_Fail_Previews: PreviewProvider { + static var previews: some View { + var device = DeviceDetails.emptyDeviceDetails + device.profile = .helium + let vm = ChangeFrequencyViewModel(device: device, useCase: nil) + vm.state = .failed(.mockErrorObj) + return NavigationContainerView { + ChangeFrequencyView(viewModel: vm) + } + } +} + +struct ChangeFrequencyView_Success_Previews: PreviewProvider { + static var previews: some View { + var device = DeviceDetails.emptyDeviceDetails + device.profile = .helium + let vm = ChangeFrequencyViewModel(device: device, useCase: nil) + vm.state = .success(.mockSuccessObj) + return NavigationContainerView { + ChangeFrequencyView(viewModel: vm) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Change Frequency/ChangeFrequencyViewModel.swift b/PresentationLayer/UI Components/Screens/Change Frequency/ChangeFrequencyViewModel.swift new file mode 100644 index 00000000..1eaca0d3 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Change Frequency/ChangeFrequencyViewModel.swift @@ -0,0 +1,225 @@ +// +// ChangeFrequencyViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 13/3/23. +// + +import Foundation +import Combine +import DomainLayer +import Toolkit + +class ChangeFrequencyViewModel: ObservableObject { + @Published var state: State = .setFrequency + @Published var selectedFrequency: Frequency? + @Published var isFrequencyAcknowledged: Bool = false + @Published private(set) var steps: [StepsView.Step] = Step.allCases.map { StepsView.Step(text: $0.description, isCompleted: false) } + @Published var currentStepIndex: Int? + @Published private(set) var dismissToggle: Bool = false + + private let mainVM: MainScreenViewModel = .shared + + private let useCase: DeviceInfoUseCase? + let device: DeviceDetails + private var cancellables: Set = [] + + init(device: DeviceDetails, useCase: DeviceInfoUseCase?, frequency: Frequency? = Frequency.allCases.first) { + self.device = device + self.useCase = useCase + self.selectedFrequency = frequency + } + + func changeButtonTapped() { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .changeFrequencyResult, + .contentType: .changeStationFrequency, + .action: .change]) + setFrequency() + } + + func cancelButtonTapped() { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .changeFrequencyResult, + .contentType: .changeStationFrequency, + .action: .cancel]) + + dismissToggle.toggle() + } +} + +extension ChangeFrequencyViewModel { + enum State: Equatable { + static func == (lhs: ChangeFrequencyViewModel.State, rhs: ChangeFrequencyViewModel.State) -> Bool { + switch (lhs, rhs) { + case (.setFrequency, .setFrequency): + return true + case (.changeFrequency, .changeFrequency): + return true + case (.failed, .failed): + return true + case (.success, .success): + return true + default: return false + } + } + + case setFrequency + case changeFrequency + case failed(FailSuccessStateObject) + case success(FailSuccessStateObject) + + var isFailed: Bool { + if case .failed = self { + return true + } + + return false + } + + var isSuccess: Bool { + if case .success = self { + return true + } + + return false + } + + var stateObject: FailSuccessStateObject? { + switch self { + case .setFrequency, .changeFrequency: + return nil + case .failed(let obj): + return obj + case .success(let obj): + return obj + } + } + } +} + +private extension ChangeFrequencyViewModel { + enum Step: CaseIterable, CustomStringConvertible { + case connect + case settingFrequncy + + var description: String { + switch self { + case .connect: + return LocalizableString.connectToStation.localized + case .settingFrequncy: + return LocalizableString.changingFrequency.localized + } + } + } + + func setFrequency() { + guard let selectedFrequency else { + return + } + useCase?.changeFrequency(device: device, frequency: selectedFrequency).sink { [weak self] state in + DispatchQueue.main.async { + switch state { + case .connect: + self?.state = .changeFrequency + self?.currentStepIndex = 0 + case .changing: + self?.state = .changeFrequency + self?.currentStepIndex = 1 + case .failed(let error): + self?.handleFrequencyError(error) + case .finished: + let subtitle = LocalizableString.deviceInfoStationFrequencyChangedDescription(selectedFrequency.rawValue).localized + let obj = FailSuccessStateObject(type: .changeFrequency, + title: LocalizableString.deviceInfoStationFrequencyChanged.localized, + subtitle: subtitle.attributedMarkdown, + cancelTitle: nil, + retryTitle: LocalizableString.deviceInfoStationBackToSettings.localized, + contactSupportAction: nil, + cancelAction: nil, + retryAction: { [weak self] in self?.dismissToggle.toggle() }) + self?.state = .success(obj) + + } + self?.updateSteps() + } + }.store(in: &cancellables) + } + + func updateSteps() { + guard let currentStepIndex else { + (0 ..< steps.count).forEach { index in + steps[index].setCompleted(false) + } + return + } + + (0 ..< currentStepIndex).forEach { index in + steps[index].setCompleted(index < currentStepIndex) + } + } + + func handleFrequencyError(_ error: ChangeFrequencyError) { + let title: String = LocalizableString.deviceInfoStationFrequencyChangeFailed.localized + let subtitle: String + let cancelTitle = LocalizableString.cancel.localized + let retryTitle = LocalizableString.retry.localized + let contactSupportAction: () -> Void + let cancelAction: () -> Void = { [weak self] in self?.dismissToggle.toggle()} + let retryAction: () -> Void = { [weak self] in self?.setFrequency() } + + switch error { + case .bluetooth(let bluetoothState): + subtitle = bluetoothState.errorDescription ?? "" + contactSupportAction = { [weak self] in + HelperFunctions().openContactSupport(successFailureEnum: .changeFrequency, + email: self?.mainVM.userInfo?.email, + serialNumber: self?.device.label, + errorString: bluetoothState.errorDescription ?? "-") + } + + case .notInRange: + subtitle = LocalizableString.FirmwareUpdate.stationNotInRangeDescription.localized + contactSupportAction = { [weak self] in + HelperFunctions().openContactSupport(successFailureEnum: .changeFrequency, + email: self?.mainVM.userInfo?.email, + serialNumber: self?.device.label, + errorString: LocalizableString.FirmwareUpdate.stationNotInRangeTitle.localized) + } + case .connect: + let linkString = "[\(LocalizableString.ClaimDevice.failedTroubleshootingTextLinkTitle.localized)](\(DisplayedLinks.heliumTroubleshooting.linkURL))" + subtitle = LocalizableString.FirmwareUpdate.failedStationConnectionDescription(linkString, LocalizableString.ClaimDevice.failedTextLinkTitle.localized).localized + contactSupportAction = { [weak self] in + HelperFunctions().openContactSupport(successFailureEnum: .changeFrequency, + email: self?.mainVM.userInfo?.email, + serialNumber: self?.device.label, + errorString: LocalizableString.FirmwareUpdate.failedToConnectError.localized) + } + case .settingFrequency(let errorString): + subtitle = LocalizableString.deviceInfoStationFrequencyChangeFailureDescription(errorString ?? "-").localized + contactSupportAction = { [weak self] in + HelperFunctions().openContactSupport(successFailureEnum: .changeFrequency, + email: self?.mainVM.userInfo?.email, + serialNumber: self?.device.label, + errorString: errorString ?? "-") + } + case .unknown: + return + + } + + let obj = FailSuccessStateObject(type: .changeFrequency, + title: title, + subtitle: subtitle.attributedMarkdown, + cancelTitle: cancelTitle, + retryTitle: retryTitle, + contactSupportAction: contactSupportAction, + cancelAction: cancelAction, + retryAction: retryAction) + self.state = .failed(obj) + } + + func trackViewContentEvent(success: Bool) { + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .changeFrequencyResult, + .contentId: .changeFrequencyResultContentId, + .success: .custom(success ? "1" : "0")]) + } +} diff --git a/PresentationLayer/UI Components/Screens/Change Frequency/SelectFrequencyView.swift b/PresentationLayer/UI Components/Screens/Change Frequency/SelectFrequencyView.swift new file mode 100644 index 00000000..b80080b8 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Change Frequency/SelectFrequencyView.swift @@ -0,0 +1,166 @@ +// +// SelectFrequencyView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 13/3/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct SelectFrequencyView: View { + @Binding var selectedFrequency: Frequency? + @Binding var isFrequencyAcknowledged: Bool + var country: String? + var didSelectFrequencyFromLocation: Bool = false + var preSelectedFrequency: Frequency? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + title + + ScrollView { + ZStack { + VStack(alignment: .leading, spacing: 0) { + text + textLink + .simultaneousGesture(TapGesture().onEnded { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .frequencyDocumentation]) + }) + + frequencyTitle + frequencyPicker + + frequencyAutoSelectionText + } + } + } + + Spacer() + + acknowledgementContainer + } + } +} + +private extension SelectFrequencyView { + @ViewBuilder + var title: some View { + HStack { + Text(LocalizableString.SelectFrequency.title.localized) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + .padding(.bottom) + + Spacer() + } + } + + @ViewBuilder + var text: some View { + Text(LocalizableString.SelectFrequency.text.localized) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .padding(.bottom) + } + + @ViewBuilder + var textLink: some View { + let linkText = LocalizableString.SelectFrequency.listLinkText.localized + let url = DisplayedLinks.heliumRegionFrequencies.linkURL + let link = "**[\(linkText)](\(url))**" + + var actualText = LocalizableString.SelectFrequency.listLink(link).localized.attributedMarkdown ?? "" + let container = AttributeContainer([.foregroundColor: UIColor(colorEnum: .primary)]) + let range = actualText.range(of: linkText)! + actualText[range].mergeAttributes(container) + + return Text(actualText) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + } + + @ViewBuilder + var frequencyTitle: some View { + Text(LocalizableString.SelectFrequency.subtitle.localized) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + .padding(.top) + .padding(.bottom, 4) + } + + @ViewBuilder + var frequencyPicker: some View { + CustomPicker( + items: Frequency.allCases, + selectedItem: $selectedFrequency, + textCallback: { frequencyTitle($0) } + ) + } + + @ViewBuilder + var frequencyAutoSelectionText: some View { + if let currentCountry = country { + Text(LocalizableString.SelectFrequency.selectedFromLocationDescription(currentCountry.localizedUppercase).localized) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + .lineSpacing(4) + .padding(.top, 10) + } else { + EmptyView() + } + } + + var acknowledgeToggle: some View { + return Toggle( + LocalizableString.ClaimDevice.locationAcknowledgeText.localized, + isOn: $isFrequencyAcknowledged + ) + .labelsHidden() + .toggleStyle(WXMToggleStyle.Default) + } + + var acknowledgeText: some View { + Text(LocalizableString.SelectFrequency.acknowledgeText.localized) + .font(.system(size: CGFloat(.normalFontSize))) + .lineSpacing(4) + .padding(.bottom) + } + + var acknowledgementContainer: some View { + HStack(alignment: .top, spacing: CGFloat(.mediumSpacing)) { + acknowledgeToggle + acknowledgeText + } + } + + func frequencyTitle(_ frequency: Frequency?) -> String { + guard let frequency = frequency else { + return "-" + } + + guard didSelectFrequencyFromLocation, + frequency == preSelectedFrequency else { + return frequency.rawValue + } + + let autoSelectedText: String + if let currentCountry = country { + autoSelectedText = LocalizableString.SelectFrequency.selectedFromCountry(currentCountry.localizedUppercase).localized + } else { + autoSelectedText = LocalizableString.SelectFrequency.selectedFromLocation.localized + } + + return "\(frequency.rawValue)\(autoSelectedText)" + } +} + +struct SelectFrequencyView_Previews: PreviewProvider { + static var previews: some View { + SelectFrequencyView(selectedFrequency: .constant(.AU915), + isFrequencyAcknowledged: .constant(false), + country: nil, + didSelectFrequencyFromLocation: false, + preSelectedFrequency: nil) + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/ClaimDeviceViewModel.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/ClaimDeviceViewModel.swift new file mode 100644 index 00000000..0ee79da8 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/ClaimDeviceViewModel.swift @@ -0,0 +1,588 @@ +// +// ClaimDeviceViewModel.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 1/10/22. +// + +import AVFoundation +import Combine +import CoreLocation +import DataLayer +import DomainLayer +import SwiftUI + +public final class ClaimDeviceViewModel: NSObject, ObservableObject { + private static let LOCATION_UPDATE_DEBOUNCE_TIME_SECONDS = 0.2 + private static let CLAIMING_RETRIES_MAX = 25 // For 2 minutes timeout + private static let CLAIMING_RETRIES_DELAY_SECONDS: TimeInterval = 5 + + private let devicesUseCase: DevicesUseCase + private let deviceLocationUseCase: DeviceLocationUseCase + private let meUseCase: MeUseCase + + private var cancellableSet: Set = [] + private var claimCancellable: AnyCancellable? + private var locationUpdateCancellable: AnyCancellable? + + var isM5: Bool = false + + @Published var device = HeliumDevice(devEUI: "", deviceKey: "") + @Published var serialNumber = "" + + // Claiming + enum ClaimState: Equatable { + typealias ClaimErrorTuple = (message: String, backendId: String?) + + static func == (lhs: ClaimDeviceViewModel.ClaimState, rhs: ClaimDeviceViewModel.ClaimState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle): + return true + case (.claiming, .claiming): + return true + case let (.success(lhsResponse, _), .success(rhsResponse, _)): + return lhsResponse?.id == rhsResponse?.id + case (.failed, .failed): + return true + case (.connectionError, .connectionError): + return true + case (.rebooting, .rebooting): + return true + case (.settingFrequency, .settingFrequency): + return true + default: + return false + } + } + + case idle + case settingFrequency + case rebooting + case claiming + case connectionError + case success(DeviceDetails?, UserDeviceFollowState?) + case failed(ClaimErrorTuple) + } + + @Published var claimState: ClaimState = .idle { + didSet { + print(claimState) + } + } + + @Published var shouldExitClaimFlow = false + + private var claimWorkItem: DispatchWorkItem? + + // Camera + @Published var hasRetrievedCameraPermission = false + @Published var hasCameraPermission = false + + // Bluetooth + /// Toggle to show the claim sheet + var toggleShowClaimSheet = false + @Published var isBluetoothReady: Bool = false + @Published var isScanning: Bool = false + + @Published var bluetoothState: BluetoothState = .unknown + @Published var devices: [BTWXMDevice] = [] + + @Published var heliumDeviceInformation: HeliumDevice? + @Published var selectedBluetoothDevice: BTWXMDevice? + + @Published var pendingConnectionDevice: BTWXMDevice? + + // Location + + @Published var selectedCoordinates = CLLocationCoordinate2D() { + didSet { + currentLocationCoordinatesUpdated() + } + } + + @Published var locationSearchQuery = "" { + didSet { + deviceLocationUseCase.searchFor(locationSearchQuery) + } + } + + @Published var locationSearchResults: [DeviceLocationSearchResult] = [] + @Published var selectedLocation: DeviceLocation? { + didSet { + didSelectFrequencyFromLocation = false + } + } + + @Published var isLocationAcknowledged = false + + // Frequency + @Published var isSelectingFrequency = false + @Published var didSelectFrequency = false + @Published var errorSettingFrequency = false + + @Published var selectedFrequency: Frequency? + @Published var preSelectedFrequency: Frequency = .US915 + @Published var didSelectFrequencyFromLocation = false + @Published var isFrequencyAcknowledged = false + + public init(devicesUseCase: DevicesUseCase, + deviceLocationUseCase: DeviceLocationUseCase, + meUseCase: MeUseCase) { + self.devicesUseCase = devicesUseCase + self.deviceLocationUseCase = deviceLocationUseCase + self.meUseCase = meUseCase + + super.init() + + deviceLocationUseCase.searchResults.sink { [weak self] results in + guard let self = self else { return } + self.locationSearchResults = results + }.store(in: &cancellableSet) + + deviceLocationUseCase.error.sink { error in + print(error) + }.store(in: &cancellableSet) + + devicesUseCase.bluetoothState.sink { [weak self] state in + guard let self = self else { return } + self.bluetoothState = state + switch state { + case .unsupported, .unauthorized, .poweredOff, .unknown, .resetting: + self.isBluetoothReady = false + case .poweredOn: + self.isBluetoothReady = true + self.startScanning() + } + }.store(in: &cancellableSet) + + devicesUseCase.bluetoothDevices.sink { [weak self] devices in + guard let self = self else { return } + self.devices = devices + }.store(in: &cancellableSet) + + devicesUseCase.bluetoothDeviceState.sink { [weak self] helperState in + guard let self = self else { return } + switch helperState { + case .idle: + self.isSelectingFrequency = false + self.didSelectFrequency = false + self.errorSettingFrequency = false + case .connected: + self.isSelectingFrequency = false + self.didSelectFrequency = false + self.errorSettingFrequency = false + self.selectedBluetoothDevice = self.pendingConnectionDevice + self.pendingConnectionDevice = nil + case .settingFrequency: + self.isSelectingFrequency = true + self.didSelectFrequency = false + self.errorSettingFrequency = false + self.claimState = .settingFrequency + case .frequencySetSuccess: + self.isSelectingFrequency = false + self.didSelectFrequency = true + self.errorSettingFrequency = false + self.rebootStation() + case .frequencySetError: + self.isSelectingFrequency = false + self.didSelectFrequency = false + self.errorSettingFrequency = true + case .communicatingWithDevice: + break + case .connectionError: + self.isSelectingFrequency = false + self.errorSettingFrequency = false + self.claimState = .connectionError + self.toggleShowClaimSheet.toggle() + self.pendingConnectionDevice = nil + case let .success(heliumDevice): + self.isSelectingFrequency = false + self.didSelectFrequency = false + self.errorSettingFrequency = false + self.heliumDeviceInformation = heliumDevice + self.claimDevice() + case .rebooting: + self.claimState = .rebooting + case .rebootingError: + self.isSelectingFrequency = false + self.errorSettingFrequency = false + self.claimState = .connectionError + case .rebootingSuccess: + self.fetchInfo() + case let .error(error): + self.pendingConnectionDevice = nil + self.isSelectingFrequency = false + self.errorSettingFrequency = false + let validStates: [ClaimState] = [.claiming, .rebooting, .settingFrequency] + if validStates.contains(self.claimState) { + self.claimState = .failed((error.code, nil)) + } + print(error) + } + print(helperState) + }.store(in: &cancellableSet) + } + + func reset() { + claimState = .idle + shouldExitClaimFlow = false + + pendingConnectionDevice = nil + selectedBluetoothDevice = nil + heliumDeviceInformation = nil + + locationSearchQuery = "" + locationSearchResults = [] + selectedLocation = nil + + isLocationAcknowledged = false + + isSelectingFrequency = false + didSelectFrequency = false + errorSettingFrequency = false + + selectedFrequency = nil + + preSelectedFrequency = .US915 + isFrequencyAcknowledged = false + didSelectFrequencyFromLocation = false + } + + func handleContactSupportTap(userEmail: String?) { + var errorString = "" + switch claimState { + case .idle, .settingFrequency, .rebooting, .claiming, .success: + break + case .connectionError: + errorString = LocalizableString.ClaimDevice.connectionFailedTitle.localized + case let .failed(error): + errorString = "\(error.message)(\(error.backendId ?? "-"))" + } + + HelperFunctions().openContactSupport(successFailureEnum: .claimDeviceFlow, + email: userEmail, + serialNumber: heliumDeviceInformation?.devEUI, + errorString: errorString) + } + + // MARK: - Validation + + func isHeliumDeviceDevEUIValid(_ devEUI: String) -> Bool { + return devicesUseCase.isHeliumDeviceDevEUIValid(devEUI) + } + + func isHeliumDeviceKeyValid(_ key: String) -> Bool { + return devicesUseCase.isHeliumDeviceKeyValid(key) + } + + func isSelectedLocationValid() -> Bool { + deviceLocationUseCase.areLocationCoordinatesValid(LocationCoordinates.fromCLLocationCoordinate2D(selectedCoordinates)) + } + + // MARK: - Bluetooth + + func enableBluetooth() { + devicesUseCase.enableBluetooth() + } + + func startScanning() { + isScanning = true + devicesUseCase.startBluetoothScanning() + } + + func stopScanning() { + isScanning = false + devicesUseCase.stopBluetoothScanning() + } + + private func fetchInfo() { + guard let selectedBluetoothDevice else { + claimState = .failed((LocalizableString.ClaimDevice.errorGeneric.localized, nil)) + return + } + devicesUseCase.fetchDeviceInfo(selectedBluetoothDevice) + } + + func selectDevice(_ device: BTWXMDevice) { + pendingConnectionDevice = device + devicesUseCase.connect(device: device) + } + + // MARK: - Frequency + + func setFrequencyAndClaimSelectedDevice() { + #if targetEnvironment(simulator) + var response = DeviceDetails.emptyDeviceDetails + response.id = "debug-station-id" + response.name = "Debug station" + response.label = "Debug station" + claimState = .success(response, nil) + #else + setFrequency() + #endif + } + + private func setFrequency() { + guard let selectedBluetoothDevice, + let selectedFrequency = selectedFrequency + else { + claimState = .failed((LocalizableString.ClaimDevice.errorGeneric.localized, nil)) + return + } + + devicesUseCase.setHeliumFrequencyViaBluetooth(selectedBluetoothDevice, frequency: selectedFrequency) + } + + // MARK: - Device reboot + + func rebootStation() { + guard let selectedBluetoothDevice else { + claimState = .failed((LocalizableString.ClaimDevice.errorGeneric.localized, nil)) + return + } + devicesUseCase.reboot(device: selectedBluetoothDevice) + } + + // MARK: - Device claiming + + func claimDevice() { + startClaimingFlow() + performPersistentClaimDeviceCall(retries: 0) + } + + func cancelClaim() { + disconnect() + devicesUseCase.cancelReboot() + claimCancellable?.cancel() + claimWorkItem?.cancel() + claimState = .idle + } + + func disconnect() { + if let selectedBluetoothDevice { + devicesUseCase.disconnect(device: selectedBluetoothDevice) + } + } + + // MARK: - Camera + + func requestCameraPermission() { + AVCaptureDevice.requestAccess(for: .video) { [weak self] accessGranted in + DispatchQueue.main.async { [weak self] in + self?.hasRetrievedCameraPermission = true + self?.hasCameraPermission = accessGranted + } + } + } + + // MARK: - Location + + func moveToDetectedLocation() { + Task { + let result = await deviceLocationUseCase.getUserLocation() + DispatchQueue.main.async { + switch result { + case .success(let coordinates): + self.selectedCoordinates = coordinates + case .failure(let error): + switch error { + case .locationNotFound: + Toast.shared.show(text: error.description.attributedMarkdown ?? "") + case .permissionDenied: + let title = LocalizableString.ClaimDevice.confirmLocationNoAccessToServicesTitle.localized + let message = LocalizableString.ClaimDevice.confirmLocationNoAccessToServicesText.localized + let alertObj = AlertHelper.AlertObject.getNavigateToSettingsAlert(title: title, + message: message) + AlertHelper().showAlert(alertObj) + } + } + } + } + } + + func moveToLocationFromSearchResult(_ result: DeviceLocationSearchResult) { + locationSearchQuery = result.description + deviceLocationUseCase.locationFromSearchResult(result) + .sink { [weak self] location in + guard let self = self else { return } + self.selectedCoordinates = location.coordinates.toCLLocationCoordinate2D() + } + .store(in: &cancellableSet) + } + + private var locationUpdateWorkItem: DispatchWorkItem? + typealias CountryCodeFrequency = [String: Frequency?] + private var _countryCodeFrequency: CountryCodeFrequency? + func updateFrequencyFromCurrentLocationCountry() { + guard + let countryCode = selectedLocation?.countryCode?.uppercased(), + let frequencyForCurrentCountry = countryCodeFrequencies() + .first(where: { $0.key.uppercased() == countryCode })?.value + else { + preSelectedFrequency = .US915 + selectedFrequency = preSelectedFrequency + print("Cannot find frequency for current location.") + return + } + + didSelectFrequencyFromLocation = true + preSelectedFrequency = frequencyForCurrentCountry + selectedFrequency = preSelectedFrequency + } + + func showInvalidLocationToast() { + Toast.shared.show(text: LocalizableString.invalidLocationErrorText.localized.attributedMarkdown ?? "") + } +} + +private extension ClaimDeviceViewModel { + // MARK: Location + + func currentLocationCoordinatesUpdated() { + selectedLocation = nil + locationUpdateCancellable?.cancel() + locationUpdateWorkItem?.cancel() + let locationUpdateWorkItem = DispatchWorkItem(block: { [weak self] in + guard let self = self else { return } + + self.locationUpdateCancellable = self.deviceLocationUseCase.locationFromCoordinates( + LocationCoordinates.fromCLLocationCoordinate2D(self.selectedCoordinates) + ) + .sink { [weak self] location in + self?.selectedLocation = location + } + }) + self.locationUpdateWorkItem = locationUpdateWorkItem + + DispatchQueue.main.asyncAfter( + deadline: .now() + Self.LOCATION_UPDATE_DEBOUNCE_TIME_SECONDS, + execute: locationUpdateWorkItem + ) + } + + // MARK: Frequency + + func countryCodeFrequencies() -> CountryCodeFrequency { + if let _frequencyCountryCodes = _countryCodeFrequency { + return _frequencyCountryCodes + } + + let countryInfos = deviceLocationUseCase.getCountryInfos() + _countryCodeFrequency = countryInfos?.reduce([String: Frequency?]()) { result, info in + var result = result + result[info.code] = Frequency(rawValue: info.heliumFrequency ?? "") + return result + } + + return _countryCodeFrequency! + } + + // MARK: Device claiming + + func startClaimingFlow() { + claimCancellable?.cancel() + claimState = .claiming + } + + func performPersistentClaimDeviceCall(retries: Int) { + guard let heliumDeviceInformation = heliumDeviceInformation else { + claimState = .failed((LocalizableString.ClaimDevice.errorGeneric.localized, nil)) + return + } + + do { + let claimDeviceBody = ClaimDeviceBody( + serialNumber: heliumDeviceInformation.devEUI.replacingOccurrences(of: ":", with: ""), + location: selectedCoordinates, + secret: !heliumDeviceInformation.deviceKey.isEmpty + ? heliumDeviceInformation.deviceKey + : nil + ) + + claimCancellable = try meUseCase.claimDevice(claimDeviceBody: claimDeviceBody) + .sink { [weak self] response in + guard let self = self else { return } + switch response { + case.failure(let responseError): + if responseError.backendError?.code == FailAPICodeEnum.deviceClaiming.rawValue, + retries < Self.CLAIMING_RETRIES_MAX { + print("Claiming Failed with \(responseError). Retrying after 5 seconds...") + + // Still claiming. + self.claimWorkItem?.cancel() + let claimWorkItem = DispatchWorkItem { [weak self] in + self?.performPersistentClaimDeviceCall(retries: retries + 1) + } + self.claimWorkItem = claimWorkItem + + DispatchQueue.main.asyncAfter( + deadline: .now() + Self.CLAIMING_RETRIES_DELAY_SECONDS, + execute: claimWorkItem + ) + + return + } + + self.claimState = .failed( + (self.errorMessageForNetworkErrorResponse(responseError), responseError.backendError?.id) + ) + + case .success(let deviceResponse): + Task { @MainActor in + var followState: UserDeviceFollowState? + if let deviceId = deviceResponse.id { + followState = try? await self.meUseCase.getDeviceFollowState(deviceId: deviceId).get() + } + self.claimState = .success(deviceResponse, followState) + } + } + } + } catch { + claimState = .failed((LocalizableString.ClaimDevice.errorGeneric.localized, nil)) + } + } + + func errorMessageForNetworkErrorResponse(_ response: NetworkErrorResponse) -> String { + if let error = (response.initialError.underlyingError as? URLError) { + if error.code == .notConnectedToInternet { + return LocalizableString.ClaimDevice.errorNoInternet.localized + } else if error.code == .timedOut { + return LocalizableString.ClaimDevice.errorConnectionTimeOut.localized + } else { + return LocalizableString.ClaimDevice.errorGeneric.localized + } + } else if response.backendError?.code == FailAPICodeEnum.invalidClaimId.rawValue { + if isM5 { + return LocalizableString.ClaimDevice.errorInvalidIdM5.localized + } + + return LocalizableString.ClaimDevice.errorInvalidId.localized + } else if response.backendError?.code == FailAPICodeEnum.invalidClaimLocation.rawValue { + return LocalizableString.ClaimDevice.errorInvalidLocation.localized + } else if response.backendError?.code == FailAPICodeEnum.deviceAlreadyClaimed.rawValue { + return LocalizableString.ClaimDevice.alreadyClaimed.localized + } else if response.backendError?.code == FailAPICodeEnum.deviceNotFound.rawValue { + return LocalizableString.ClaimDevice.notFound.localized + } else if response.backendError?.code == FailAPICodeEnum.deviceClaiming.rawValue { + if isM5 { + return LocalizableString.ClaimDevice.claimingErrorM5.localized + } + + return LocalizableString.ClaimDevice.claimingError.localized + } + + return LocalizableString.ClaimDevice.errorGeneric.localized + } +} + +extension LocationCoordinates { + func toCLLocationCoordinate2D() -> CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: lat, longitude: lon) + } +} + +extension CLLocationCoordinate2D { + var isSet: Bool { + return latitude != 0 && longitude != 0 + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/ClaimDeviceNavView.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/ClaimDeviceNavView.swift new file mode 100644 index 00000000..b75b946a --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/ClaimDeviceNavView.swift @@ -0,0 +1,116 @@ +// +// ClaimDeviceNavView.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 27/9/22. +// + +import SwiftUI +import Toolkit + +struct ClaimDeviceNavView: View { + @StateObject var viewModel: ClaimDeviceViewModel + + @Environment(\.presentationMode) var presentationMode: Binding + + let claimViaBluetooth: Bool + + private var swinjectHelper: SwinjectInterface + public init(swinjectHelper: SwinjectInterface, claimViaBluetooth: Bool) { + self.swinjectHelper = swinjectHelper + self.claimViaBluetooth = claimViaBluetooth + + _viewModel = StateObject(wrappedValue: ViewModelsFactory.getClaimDeviceViewModel()) + } + + var body: some View { + return StepsNavView( + title: claimViaBluetooth ? LocalizableString.ClaimDevice.ws2000DeviceTitle.localized : LocalizableString.ClaimDevice.ws1000DeviceTitle.localized, + steps: claimViaBluetooth ? stepsAuto() : stepsManual() + ) + .onChange(of: viewModel.shouldExitClaimFlow) { didFinish in + if didFinish { + presentationMode.wrappedValue.dismiss() + } + } + .onAppear { + viewModel.isM5 = !claimViaBluetooth + Logger.shared.trackScreen(claimViaBluetooth ? .claimHelium : .claimM5) + } + } +} + +private extension ClaimDeviceNavView { + private func stepsAuto() -> [StepsNavView.Step] { + [ + StepsNavView.Step(title: LocalizableString.ClaimDevice.resetStepTitle.localized) { transport in + AnyView( + ClaimDeviceReset( + swinjectHelper: swinjectHelper, + transport: transport + ) + .environmentObject(viewModel) + ) + }, + StepsNavView.Step(title: LocalizableString.ClaimDevice.bluetoothTitle.localized) { transport in + AnyView( + ClaimDeviceBluetooth( + swinjectHelper: swinjectHelper, + transport: transport + ) + .environmentObject(viewModel) + ) + }, + StepsNavView.Step(title: LocalizableString.ClaimDevice.locationStepTitle.localized) { transport in + AnyView( + ClaimDeviceLocation( + swinjectHelper: swinjectHelper, + transport: transport + ) + .environmentObject(viewModel) + ) + }, + StepsNavView.Step(title: LocalizableString.ClaimDevice.frequencyStepTitle.localized) { transport in + AnyView( + ClaimDeviceFrequency( + swinjectHelper: swinjectHelper, + transport: transport + ) + .environmentObject(viewModel) + ) + } + ] + } + + private func stepsManual() -> [StepsNavView.Step] { + [ + StepsNavView.Step(title: LocalizableString.ClaimDevice.connectionStepTitle.localized) { transport in + AnyView( + ClaimDeviceConnection( + swinjectHelper: swinjectHelper, + transport: transport + ) + .environmentObject(viewModel) + ) + }, + StepsNavView.Step(title: LocalizableString.ClaimDevice.verifyStepTitle.localized) { transport in + AnyView( + ClaimDeviceVerify( + swinjectHelper: swinjectHelper, + transport: transport + ) + .environmentObject(viewModel) + ) + }, + StepsNavView.Step(title: LocalizableString.ClaimDevice.locationStepTitle.localized) { transport in + AnyView( + ClaimDeviceLocation( + swinjectHelper: swinjectHelper, + transport: transport + ) + .environmentObject(viewModel) + ) + } + ] + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Bluetooth/BluetoothMessageView.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Bluetooth/BluetoothMessageView.swift new file mode 100644 index 00000000..7dc4886a --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Bluetooth/BluetoothMessageView.swift @@ -0,0 +1,149 @@ +// +// BluetoothMessages.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 29/9/22. +// + +import SwiftUI + +struct BluetoothMessageView: View { + let message: Message + + enum Message { + case empty + case noAccess + case unsupported + case bluetoothOff + } + + var body: some View { + GeometryReader { geometry in + ScrollView { + VStack { + Spacer() + Image(asset: .bluetoothGray) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .darkGrey)) + + title + + text + + Spacer() + } + .frame(minHeight: geometry.size.height) + } + } + } + + var title: some View { + Group { + switch message { + case .empty: + return Text("") + case .bluetoothOff: + return Text(LocalizableString.Bluetooth.title.localized) + case .unsupported: + return Text(LocalizableString.Bluetooth.unsupportedTitle.localized) + case .noAccess: + return Text(LocalizableString.Bluetooth.noAccessTitle.localized) + } + } + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .darkestBlue)) + } + + var text: some View { + ZStack { + switch message { + case .empty: + emptyMessage + case .unsupported: + bluetoothUnsupportedMessage + case .bluetoothOff: + bluetoothOffMessage + case .noAccess: + noAccessMessage + } + } + } + + var emptyMessage: some View { + return VStack { + AttributedLabel(attributedText: .constant(NSAttributedString(string: ""))) + } + } + + var bluetoothUnsupportedMessage: some View { + return VStack { + attributedMessageWithText(LocalizableString.Bluetooth.offText(LocalizableString.ClaimDevice.manuallyButton.localized).localized) + } + } + + var bluetoothOffMessage: some View { + return VStack { + attributedMessageWithText(LocalizableString.Bluetooth.offText(LocalizableString.ClaimDevice.manuallyButton.localized).localized) + } + } + + var noAccessMessage: some View { + return VStack { + attributedMessageWithText(LocalizableString.Bluetooth.offText(LocalizableString.ClaimDevice.manuallyButton.localized).localized) + settingsButton(LocalizableString.Bluetooth.goToSettingsGrantAccess.localized) + } + } +} + +private extension BluetoothMessageView { + func attributedMessageWithText(_ text: String, boldText: String = LocalizableString.ClaimDevice.manuallyButton.localized) -> some View { + let formattedMessage = messageWithBoldClaimButtonTitle(text, boldText: boldText) + return AttributedLabel(attributedText: .constant(formattedMessage)) { uiLabel in + uiLabel.textAlignment = .center + uiLabel.textColor = UIColor(colorEnum: .text) + uiLabel.font = .systemFont(ofSize: CGFloat(.normalFontSize)) + } + .padding() + } + + func messageWithBoldClaimButtonTitle(_ text: String, boldText: String) -> NSAttributedString { + let attributedText = NSMutableAttributedString( + string: text, + attributes: [.font: UIFont.systemFont(ofSize: FontSizeEnum.caption.sizeValue)] + ) + + if let range = text.range(of: boldText) { + let boldTextRange = NSRange(range, in: text) + attributedText.setAttributes([.font: UIFont.systemFont(ofSize: FontSizeEnum.caption.sizeValue, weight: .bold)], range: boldTextRange) + } + + return attributedText + } + + func settingsButton(_ text: String) -> some View { + Button { + guard let url = URL(string: UIApplication.openSettingsURLString) else { + return + } + + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } label: { + Label(text, image: AssetEnum.claimBluetoothButton.rawValue) + } + .buttonStyle(WXMButtonStyle()) + .padding() + } +} + +extension NSAttributedString { + convenience init(format: NSAttributedString, _ args: NSAttributedString...) { + let mutableNSAttributedString = NSMutableAttributedString(attributedString: format) + + args.forEach { attributedString in + let range = NSString(string: mutableNSAttributedString.string).range(of: "%@") + mutableNSAttributedString.replaceCharacters(in: range, with: attributedString) + } + + self.init(attributedString: mutableNSAttributedString) + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Bluetooth/BluetoothScanView.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Bluetooth/BluetoothScanView.swift new file mode 100644 index 00000000..39b4a383 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Bluetooth/BluetoothScanView.swift @@ -0,0 +1,258 @@ +// +// BluetoothScanView.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 29/9/22. +// + +import DomainLayer +import SwiftUI +import Toolkit + +struct BluetoothScanView: View { + @EnvironmentObject var viewModel: ClaimDeviceViewModel + + private let didSelectDevice: (BTWXMDevice) -> Void + /// Will change on `viewModel.toggleShowClaimSheet` changes. + /// Used state to prevent unnecessary body calls and some animation issues with custom sheet dismissal + @State private var showStatusSheet = false + + public init(didSelectDevice: @escaping (BTWXMDevice) -> Void) { + self.didSelectDevice = didSelectDevice + } + + var body: some View { + VStack { + mainContent + if viewModel.isBluetoothReady { + if viewModel.isScanning { + scanProgress + } else { + scanButton + } + } + } + .background(Color(colorEnum: .layer1)) + .onChange(of: viewModel.selectedBluetoothDevice) { device in + if let device = device { + didSelectDevice(device) + } + } + .onChange(of: viewModel.toggleShowClaimSheet) { _ in + showStatusSheet = true + } + .onAppear { + viewModel.enableBluetooth() + } + .customSheet(isPresented: $showStatusSheet) { controller in + HeliumClaimingStatusView( + dismiss: controller.dismiss, + restartClaimFlow: { + controller.dismiss() + } + ) + .environmentObject(viewModel) + } + } + + @ViewBuilder + var mainContent: some View { + switch viewModel.bluetoothState { + case .unknown: + BluetoothMessageView(message: .empty) + case .unsupported: + BluetoothMessageView(message: .unsupported) + case .unauthorized: + BluetoothMessageView(message: .noAccess) + case .poweredOff: + BluetoothMessageView(message: .bluetoothOff) + case .resetting, .poweredOn: + if viewModel.devices.isEmpty { + if !viewModel.isScanning { + noDevicesFound + } else { + scanningForDevices + } + } else { + if #available(iOS 16.0, *) { + deviceList.scrollContentBackground(.hidden) + } else { + deviceList + } + } + } + } + + var scanningForDevices: some View { + return VStack(alignment: .center) { + Spacer() + + Image(asset: .bluetoothGray) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .darkGrey)) + .padding(.vertical) + + Text(LocalizableString.ClaimDevice.scanningForWXMDevices.localized) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .darkestBlue)) + + Spacer() + } + .padding(.horizontal) + } + + var noDevicesFound: some View { + return VStack(alignment: .center) { + Spacer() + + Image(asset: .wonderFace) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .darkGrey)) + .padding(.vertical) + + Text(LocalizableString.Bluetooth.noDevicesFoundTitle.localized) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .padding(.bottom) + + Text(LocalizableString.Bluetooth.noDevicesFoundText.localized) + .font(.system(size: CGFloat(.normalFontSize))) + .multilineTextAlignment(.center) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + .padding(.horizontal) + } + + @ViewBuilder + var deviceList: some View { + let list = List { + ForEach(viewModel.devices) { device in + let isLast = viewModel.devices.last?.identifier == device.identifier + if #available(iOS 15.0, *) { + deviceRow(device, isLast: isLast) + .listRowSeparator(.hidden) + } else { + deviceRow(device, isLast: isLast) + } + } + } + .listStyle(.plain) + .background(Color(colorEnum: .layer1)) + .onAppear { + // Required to remove default List separator and List background on older iOS versions. + UITableView.appearance().separatorStyle = .none + UITableView.appearance().backgroundColor = UIColor.clear + } + + if #available(iOS 16.0, *) { + list.scrollContentBackground(.hidden) + } else { + list + } + } + + var scanButton: some View { + Button { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .bleScanAgain]) + viewModel.startScanning() + } label: { + Label( + LocalizableString.ClaimDevice.scanAgain.localized, + image: AssetEnum.claimBluetoothButton.rawValue + ) + .foregroundColor(Color(colorEnum: .primary)) + } + .buttonStyle(WXMButtonStyle(fillColor: .layer1)) + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.bottom, CGFloat(.defaultSidePadding)) + } + + @State private var scanProgressScale: CGFloat = 0 + + var scanProgress: some View { + Label( + LocalizableString.ClaimDevice.scanningForWXMDevices.localized, + image: AssetEnum.claimBluetoothButton.rawValue + ) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .primary)) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + ZStack { + RoundedRectangle(cornerRadius: 5) + .style(withStroke: Color(colorEnum: .primary), lineWidth: 2, fill: Color(colorEnum: .layer1)) + + RoundedRectangle(cornerRadius: 3) + .style(withStroke: .clear, lineWidth: 0, fill: Color(colorEnum: .lightestBlue)) + .scaleEffect(CGSize(width: scanProgressScale, height: 1), anchor: .leading) + .padding(1) + } + ) + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.bottom, CGFloat(.defaultSidePadding)) + .onAppear { + scanProgressScale = 0 + withAnimation(.linear(duration: 5)) { + scanProgressScale = 1 + } + } + .onAnimationCompleted(for: scanProgressScale) { + viewModel.stopScanning() + } + } +} + +private extension BluetoothScanView { + private func deviceRow(_ device: BTWXMDevice, isLast: Bool) -> some View { + let name = "\(device.name ?? "")" + let isFetching = viewModel.pendingConnectionDevice == device + return Button { + if isFetching { return } + viewModel.selectDevice(device) + } label: { + VStack(alignment: .leading, spacing: 0) { + Spacer() + + HStack(spacing: 0) { + if isFetching { + ProgressView() + .frame(width: 20, height: 20) + } else { + Image(asset: .claimHelium) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + } + + Text(LocalizableString.ClaimDevice.deviceHelium.localized) + .font(.system(size: CGFloat(.normalFontSize))) + .padding(.leading, 12) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + .padding(.horizontal) + + Text(LocalizableString.ClaimDevice.deviceId(name).localized) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .primary)) + .padding(.horizontal) + .padding(.top, 7) + .padding(.bottom, 0) + Spacer() + + if !isLast { + WXMDivider() + } + } + } + .buttonStyle(WXMButtonStyle( + fillColor: .clear, + strokeColor: .clear + )) + .background(Color(colorEnum: .layer1)) + .listRowBackground(Color(colorEnum: .layer1)) + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceBluetooth.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceBluetooth.swift new file mode 100644 index 00000000..faa0522c --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceBluetooth.swift @@ -0,0 +1,82 @@ +// +// ClaimDeviceBluetooth.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 30/9/22. +// + +import SwiftUI + +struct ClaimDeviceBluetooth: View { + @EnvironmentObject private var viewModel: ClaimDeviceViewModel + + private let swinjectHelper: SwinjectInterface + private let transport: StepsNavView.Transport + + public init( + swinjectHelper: SwinjectInterface, + transport: StepsNavView.Transport + ) { + self.swinjectHelper = swinjectHelper + self.transport = transport + } + + var body: some View { + VStack(spacing: 0) { + text + results + #if targetEnvironment(simulator) + debugButton + #endif + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .layer1), insideHorizontalPadding: 0.0, insideVerticalPadding: 0.0) + .padding(.bottom) + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .onAppear { + viewModel.reset() + } + } + + var text: some View { + HStack { + VStack(alignment: .leading, spacing: CGFloat(.smallSpacing)) { + title + description + } + + Spacer() + } + .padding(CGFloat(.defaultSidePadding)) + .background(Color(colorEnum: .top)) + } + + var title: some View { + Text(LocalizableString.ClaimDevice.selectDeviceTitle.localized) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + } + + var description: some View { + Text(LocalizableString.ClaimDevice.selectDeviceDescription.localized) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + } + + var results: some View { + BluetoothScanView { _ in + transport.nextStep() + } + .environmentObject(viewModel) + } + + var debugButton: some View { + Button { + transport.nextStep() + } label: { + Text("DEBUG - Go to next") + } + .buttonStyle(WXMButtonStyle()) + .padding() + .background(Color(colorEnum: .layer1)) + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceConnection.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceConnection.swift new file mode 100644 index 00000000..622f1fe0 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceConnection.swift @@ -0,0 +1,154 @@ +// +// ClaimDeviceConnection.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 29/11/22. +// + +import SwiftUI + +struct ClaimDeviceConnection: View { + @EnvironmentObject var viewModel: ClaimDeviceViewModel + + @State private var didSelectManualFlow = true + + private let swinjectHelper: SwinjectInterface + private let transport: StepsNavView.Transport + + public init( + swinjectHelper: SwinjectInterface, + transport: StepsNavView.Transport + ) { + self.swinjectHelper = swinjectHelper + self.transport = transport + } + + var body: some View { + ZStack { + VStack(alignment: .leading, spacing: 0) { + title + + ScrollView { + section( + index: 1, + attributedText: attributedText( + text: LocalizableString.ClaimDevice.connectionBullet1(LocalizableString.ClaimDevice.connectionBullet1Bold.localized).localized, + boldText: LocalizableString.ClaimDevice.connectionBullet1Bold.localized + ) + ) + + section( + index: 2, + attributedText: attributedText( + text: LocalizableString.ClaimDevice.connectionBullet2(LocalizableString.ClaimDevice.connectionBullet2Bold.localized).localized, + boldText: LocalizableString.ClaimDevice.connectionBullet2Bold.localized + ) + ) + + section( + index: 3, + text: LocalizableString.ClaimDevice.connectionBullet3.localized + ) + + HStack { + Text(LocalizableString.ClaimDevice.connectionText.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + + Spacer() + } + + Button { + if + let url = URL(string: Constants.m5VideoLink), + UIApplication.shared.canOpenURL(url) + { + UIApplication.shared.open(url, options: [:]) + } + } label: { + Image(asset: .m5Video) + .resizable() + .aspectRatio(contentMode: .fit) + } + } + + Spacer() + + bottomButtons + } + .WXMCardStyle() + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.bottom, CGFloat(.defaultSidePadding)) + } + } + + var title: some View { + Text(LocalizableString.ClaimDevice.connectionTitle.localized) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .padding(.bottom, 30) + } + + var bottomButtons: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + Button { + transport.nextStep() + } label: { + Text(LocalizableString.ClaimDevice.iVeConnectMyM5Button.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + } + } +} + +private extension ClaimDeviceConnection { + func section( + index: Int, + attributedText: NSAttributedString? = nil, + text: String? = nil + ) -> some View { + HStack(alignment: .top) { + Text("\(index)") + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .top)) + .background( + Circle() + .background( + Circle().fill(Color(colorEnum: .darkGrey)) + ) + .frame(width: 24, height: 24) + ) + .padding(.vertical, 6) + .padding(.horizontal, 10) + + if let attributedText = attributedText { + AttributedLabel(attributedText: .constant(attributedText)) + } else if let text = text { + Text(text) + .font(.system(size: CGFloat(.normalFontSize))) + } + + Spacer() + } + .padding(.bottom, CGFloat(.defaultSidePadding)) + } + + func attributedText(text: String, boldText: String) -> NSAttributedString { + let attributedText = NSMutableAttributedString( + string: text, + attributes: [.font: UIFont.systemFont(ofSize: FontSizeEnum.normalFontSize.sizeValue)] + ) + + if let range = text.range(of: boldText) { + let boldTextRange = NSRange(range, in: text) + attributedText.setAttributes([.font: UIFont.systemFont(ofSize: FontSizeEnum.normalFontSize.sizeValue, weight: .bold)], range: boldTextRange) + } + + return attributedText + } +} + +private extension ClaimDeviceConnection { + enum Constants { + static let m5VideoLink = "https://www.youtube.com/watch?v=sUJEwuFq1CE" + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceFrequency.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceFrequency.swift new file mode 100644 index 00000000..e95e9c99 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceFrequency.swift @@ -0,0 +1,97 @@ +// +// ClaimDeviceFrequency.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 28/11/22. +// + +import DomainLayer +import SwiftUI + +struct ClaimDeviceFrequency: View { + @EnvironmentObject var viewModel: ClaimDeviceViewModel + + @Environment(\.presentationMode) private var presentationMode: Binding + + @State private var isImportantMessageClosed = false + @State private var isShowingClaimSheet = false + + private let swinjectHelper: SwinjectInterface + private let transport: StepsNavView.Transport + + public init(swinjectHelper: SwinjectInterface, transport: StepsNavView.Transport) { + self.swinjectHelper = swinjectHelper + self.transport = transport + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SelectFrequencyView(selectedFrequency: $viewModel.selectedFrequency, + isFrequencyAcknowledged: $viewModel.isFrequencyAcknowledged, + country: viewModel.selectedLocation?.country, + didSelectFrequencyFromLocation: viewModel.didSelectFrequencyFromLocation, + preSelectedFrequency: viewModel.preSelectedFrequency) + + bottomButtons + } + .WXMCardStyle() + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.bottom) + .onChange(of: viewModel.selectedLocation) { _ in + // This ensures that the frequency will be updated even if the map + // from the (previous) location screen has not yet settled scrolling. + // This can happen due to scrolling interia, when the user makes a very fast + // gesture and immediately leaves the location screen before map scrolling + // has stopped. + viewModel.updateFrequencyFromCurrentLocationCountry() + } + .onAppear { + viewModel.updateFrequencyFromCurrentLocationCountry() + } + .alert(isPresented: $viewModel.errorSettingFrequency) { + frequencyErrorAlert + } + } + + var frequencyErrorAlert: Alert { + Alert( + title: Text(LocalizableString.SelectFrequency.settingFailedTitle.localized), + message: Text(LocalizableString.SelectFrequency.settingFailedText.localized), + primaryButton: .default(Text(LocalizableString.SelectFrequency.tryAgainButton.localized)) { + viewModel.errorSettingFrequency = false + }, + secondaryButton: .cancel(Text(LocalizableString.SelectFrequency.quitClaimingButton.localized)) { + presentationMode.wrappedValue.dismiss() + } + ) + } + + var bottomButtons: some View { + Button { + viewModel.setFrequencyAndClaimSelectedDevice() + isShowingClaimSheet = true + } label: { + Text(LocalizableString.ClaimDevice.confirmLocationConfirmAndClaim.localized) + } + .disabled(!viewModel.isFrequencyAcknowledged) + .buttonStyle(WXMButtonStyle.filled()) + .customSheet( + isPresented: $isShowingClaimSheet, + allowSwipeAndTapToDismiss: .constant(false), + onDismiss: { + viewModel.cancelClaim() + } + ) { controller in + HeliumClaimingStatusView( + dismiss: { + viewModel.cancelClaim() + controller.dismiss() + }, + restartClaimFlow: { + viewModel.setFrequencyAndClaimSelectedDevice() + } + ) + .environmentObject(viewModel) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceLocation.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceLocation.swift new file mode 100644 index 00000000..02174d05 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceLocation.swift @@ -0,0 +1,152 @@ +// +// ClaimDeviceLocation.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 29/9/22. +// + +import SwiftUI + +struct ClaimDeviceLocation: View { + @EnvironmentObject var viewModel: ClaimDeviceViewModel + + @State private var isImportantMessageClosed = false + @State private var isShowingClaimSheet = false + + private let swinjectHelper: SwinjectInterface + private let transport: StepsNavView.Transport + + public init(swinjectHelper: SwinjectInterface, transport: StepsNavView.Transport) { + self.swinjectHelper = swinjectHelper + self.transport = transport + } + + var body: some View { + ZStack { + VStack(spacing: 0) { + mainView + acknowledgementContainer + } + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.bottom) + .animation(.default) + .onAppear { + DispatchQueue.main.async { + viewModel.moveToDetectedLocation() + } + } + } + + var title: some View { + HStack { + Text(LocalizableString.ClaimDevice.confirmLocationTitle.localized) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .padding(.bottom, 10) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + } + + var mainView: some View { + return GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + topText.zIndex(10) + map + } + .padding(.bottom, isImportantMessageClosed ? 0 : -20) + .WXMCardStyle( + insideHorizontalPadding: 1, + insideVerticalPadding: 1 + ) + .frame(height: geometry.size.height + 20, alignment: .top) + } + } + + var map: some View { + ZStack { + ClaimDeviceLocationMapView() + .environmentObject(viewModel) + .onAppear { + // Ensure that the coordinates will be set. When the map does not appear for the first + // time the selected coordinates will not be set again, causing the selected location + // to not be determined. That in turn, causes the default Helium frequency to not be + // auto-detected (since it is based on the selected location). + viewModel.selectedCoordinates = viewModel.selectedCoordinates + } + } + } + + var topText: some View { + VStack(alignment: .leading) { + title + } + .WXMCardStyle() + } + + var acknowledgeToggle: some View { + return Toggle( + LocalizableString.ClaimDevice.locationAcknowledgeText.localized, + isOn: $viewModel.isLocationAcknowledged + ) + .labelsHidden() + .toggleStyle(WXMToggleStyle.Default) + } + + var acknowledgeText: some View { + Text(LocalizableString.ClaimDevice.locationAcknowledgeText.localized) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .lineSpacing(4) + .padding(.bottom) + } + + var acknowledgementContainer: some View { + VStack { + HStack(alignment: .top, spacing: CGFloat(.mediumSpacing)) { + acknowledgeToggle + acknowledgeText + } + + bottomButtons + } + .WXMCardStyle() + } + + var bottomButtons: some View { + Button { + guard viewModel.isSelectedLocationValid() else { + viewModel.showInvalidLocationToast() + return + } + + if !transport.isLastStep() { + transport.nextStep() + return + } + + viewModel.claimDevice() + isShowingClaimSheet = true + } label: { + Text(LocalizableString.ClaimDevice.confirmLocationConfirmAndClaim.localized) + } + .disabled(!viewModel.isLocationAcknowledged) + .buttonStyle(WXMButtonStyle.filled()) + .customSheet( + isPresented: $isShowingClaimSheet, + allowSwipeAndTapToDismiss: .constant(false), + onDismiss: { + viewModel.cancelClaim() + } + ) { controller in + HeliumClaimingStatusView( + dismiss: controller.dismiss, + restartClaimFlow: { + viewModel.claimDevice() + } + ) + .environmentObject(viewModel) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceReset.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceReset.swift new file mode 100644 index 00000000..d9bf6ba2 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceReset.swift @@ -0,0 +1,97 @@ +// +// ClaimDeviceReset.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 29/9/22. +// + +import SwiftUI + +struct ClaimDeviceReset: View { + @EnvironmentObject var viewModel: ClaimDeviceViewModel + + private let swinjectHelper: SwinjectInterface + private let transport: StepsNavView.Transport + + public init( + swinjectHelper: SwinjectInterface, + transport: StepsNavView.Transport + ) { + self.swinjectHelper = swinjectHelper + self.transport = transport + } + + var body: some View { + ZStack { + VStack(alignment: .leading, spacing: 0) { + title + + ScrollView { + section( + index: 1, + markdown: LocalizableString.ClaimDevice.resetSection1Markdown.localized + ) + + section( + index: 2, + markdown: LocalizableString.ClaimDevice.resetSection2Markdown.localized + ) + + Image(asset: .stationResetSchematic) + .resizable() + .aspectRatio(contentMode: .fit) + } + + Spacer() + + bottomButtons + } + .WXMCardStyle() + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.bottom) + } + } + + var title: some View { + Text(LocalizableString.ClaimDevice.resetStationTitle.localized) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .padding(.bottom, 30) + } + + var bottomButtons: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + Button { + transport.nextStep() + } label: { + Text(LocalizableString.ClaimDevice.iVeResetMyDeviceButton.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + } + } +} + +private extension ClaimDeviceReset { + func section(index: Int, markdown: String) -> some View { + HStack(alignment: .top) { + Text("\(index)") + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .top)) + .background( + Circle() + .background( + Circle().fill(Color(colorEnum: .darkGrey)) + ) + .frame(width: 24, height: 24) + ) + .padding(.vertical, 6) + .padding(.horizontal, 10) + + Text(markdown.attributedMarkdown!) + .font(.system(size: CGFloat(.normalFontSize), weight: .regular)) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + .padding(.bottom, 20) + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceVerify.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceVerify.swift new file mode 100644 index 00000000..531653ec --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/ClaimDeviceVerify.swift @@ -0,0 +1,143 @@ +// +// ClaimDeviceVerify.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 29/9/22. +// + +import DomainLayer +import SwiftUI + +struct ClaimDeviceVerify: View { + @EnvironmentObject var viewModel: ClaimDeviceViewModel + @Environment(\.presentationMode) var presentationMode: Binding + + @State private var disallowSubmit = true + @State private var focusSerialNumber: Bool? = true + + private let swinjectHelper: SwinjectInterface + private let transport: StepsNavView.Transport + public init(swinjectHelper: SwinjectInterface, transport: StepsNavView.Transport) { + self.swinjectHelper = swinjectHelper + self.transport = transport + } + + var body: some View { + ZStack { + VStack(alignment: .leading, spacing: 0) { + title + + ScrollView { + textField + information + } + + bottomButtons + } + .WXMCardStyle() + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.bottom, CGFloat(.defaultSidePadding)) + .onChange(of: viewModel.device.devEUI, perform: { _ in + self.updateDisallowSubmit() + }) + .onAppear { + self.updateDisallowSubmit() + } + } + + func updateDisallowSubmit() { + disallowSubmit = viewModel.device.devEUI.count != 26 + } + + var title: some View { + Text(LocalizableString.ClaimDevice.verifyTitle.localized) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .padding(.bottom, 30) + } + + var textField: some View { + VStack(spacing: CGFloat(.minimumSpacing)) { + HStack { + Text(LocalizableString.deviceSerialNumber.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + Spacer() + } + let serialNumber = viewModel.device.devEUI.replacingOccurrences(of: ":", with: "") + ZStack(alignment: .leading) { + Text("\(UITextField.formatAsSerialNumber(serialNumber, placeholder: "A"))") + .font(.custom("Menlo-Regular", fixedSize: FontSizeEnum.mediumFontSize.sizeValue)) + .padding(CGFloat(.defaultSidePadding)) + .opacity(0.4) + + UberTextField( + text: $viewModel.device.devEUI, + isFirstResponder: $focusSerialNumber, + shouldChangeCharactersIn: { tf, range, string in + tf.updateSerialNumberCharactersIn(nsRange: range, for: string) + return false + }, + onSubmit: { _ in + focusSerialNumber = false + return true + }, + configuration: { tf in + tf.keyboardType = .asciiCapable + tf.returnKeyType = .continue + tf.horizontalPadding = CGFloat(.defaultSidePadding) + tf.verticalPadding = CGFloat(.defaultSidePadding) + tf.configureForSerialNumber() + tf.backgroundColor = .clear + } + ) + } + .strokeBorder(color: Color(colorEnum: .primary), lineWidth: 2.0, radius: CGFloat(.buttonCornerRadius)) + } + .padding(.bottom, 16) + } + + var information: some View { + InfoView(text: LocalizableString.ClaimDevice.information(LocalizableString.ClaimDevice.informationBold.localized).localized.attributedMarkdown ?? "") + } + + var bottomButtons: some View { + Button { + submit() + } label: { + Text(LocalizableString.ClaimDevice.verifyButton.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + .disabled(disallowSubmit) + } +} + +private extension ClaimDeviceVerify { + func submit() { + hideKeyboard() + + viewModel.heliumDeviceInformation = HeliumDevice( + devEUI: viewModel.device.devEUI, + deviceKey: "" + ) + + transport.nextStep() + } +} + +struct Previews_ClaimDeviceVerify_Previews: PreviewProvider { + static var previews: some View { + ClaimDeviceVerify(swinjectHelper: SwinjectHelper.shared, transport: StepsNavView.Transport(nextStep: { + + }, previousStep: { + + }, firstStep: { + + }, isLastStep: { + true + }, isFirstStep: { + true + })) + .environmentObject(ViewModelsFactory.getClaimDeviceViewModel()) + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Location/ClaimDeviceLocationMapView.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Location/ClaimDeviceLocationMapView.swift new file mode 100644 index 00000000..42c51466 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Steps/Location/ClaimDeviceLocationMapView.swift @@ -0,0 +1,173 @@ +// +// ClaimDeviceLocationMapView.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 15/10/22. +// + +import DomainLayer +import MapboxMaps +import SwiftUI +import Toolkit + +struct ClaimDeviceLocationMapView: View { + @EnvironmentObject var viewModel: ClaimDeviceViewModel + + @State private var showSearchResults = false + @State var stopMapScrolling = false + + var body: some View { + mapContainer + } + + var mapContainer: some View { + GeometryReader { geometry in + ZStack(alignment: .top) { + MapBoxClaimDeviceView( + location: $viewModel.selectedCoordinates, + annotationTitle: Binding(get: { viewModel.selectedLocation?.name }, set: { _ in }), + areLocationServicesAvailable: false, + geometryProxyForFrameOfMapView: geometry.frame(in: .global) + ) + .onTapGesture { + showSearchResults = false + hideKeyboard() + } + + searchViews + } + .transaction { transaction in + transaction.animation = nil + } + } + } + + var searchViews: some View { + ZStack { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: CGFloat(.smallSpacing)) { + searchField + homeLocationButton + } + + if viewModel.locationSearchResults.count > 0, showSearchResults { + searchResults + } + + Spacer().frame(minHeight: 50) + } + } + .padding() + } + + @ViewBuilder + var searchField: some View { + HStack { + UberTextField( + text: $viewModel.locationSearchQuery, + hint: .constant(LocalizableString.ClaimDevice.confirmLocationSearchHint.localized), + onEditingChanged: { _, isFocused in showSearchResults = isFocused }, + configuration: { + $0.font = UIFont.systemFont(ofSize: FontSizeEnum.normalFontSize.sizeValue) + $0.horizontalPadding = 16 + $0.textColor = UIColor(colorEnum: .text) + } + ) + + WXMDivider() + .padding(.vertical, 4) + + Image(asset: .search) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + .padding(.trailing, 9) + } + .frame(height: 50) + .background( + RoundedRectangle(cornerRadius: 10) + .style(withStroke: Color(colorEnum: .midGrey), lineWidth: 1, fill: Color(colorEnum: .top)) + ) + .cornerRadius(10) + } + + var homeLocationButton: some View { + Button { + viewModel.moveToDetectedLocation() + } label: { + Image(asset: .detectLocation) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + } + .frame(width: 50, height: 50) + .background( + RoundedRectangle(cornerRadius: 10) + .style(withStroke: Color(colorEnum: .midGrey), lineWidth: 1, fill: Color(colorEnum: .top)) + ) + } + + @ViewBuilder + var searchResults: some View { + ScrollViewReader { proxy in + List(viewModel.locationSearchResults, id: \.self) { searchResult in + Button { + viewModel.moveToLocationFromSearchResult(searchResult) + showSearchResults = false + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .searchLocation, + .contentType: .claimingAddressSearch, + .location: .custom(searchResult.description)]) + } label: { + AttributedLabel(attributedText: .constant( + searchResult.attributedDescriptionForQuery(viewModel.locationSearchQuery) + )) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .padding(.vertical, 5) + } + .buttonStyle(.borderless) // This modifier is necessary for iOS 15 builds. In general buttons inside lists are buggy. SwiftUI 🤌! + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .listRowBackground(Color(colorEnum: .top)) + } + .onChange(of: viewModel.locationSearchResults) { newValue in + if let firstLocation = newValue.first { + proxy.scrollTo(firstLocation, anchor: .top) + } + } + .environment(\.defaultMinListRowHeight, 0) + .listStyle(.plain) + .cornerRadius(10) + .background( + RoundedRectangle(cornerRadius: 10) + .style(withStroke: Color(colorEnum: .midGrey), lineWidth: 1, fill: Color(colorEnum: .top)) + ) + .animation(nil) + } + } +} + +extension DeviceLocationSearchResult { + func attributedDescriptionForQuery(_ query: String) -> NSAttributedString { + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: CGFloat(FontSizeEnum.normalFontSize)) + ] + + guard let range = description.range(of: query, options: .caseInsensitive) else { + return NSAttributedString(string: description, attributes: attributes) + } + + let attributedDescription = NSMutableAttributedString( + string: description, + attributes: attributes + ) + + let boldAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: CGFloat(FontSizeEnum.normalFontSize), weight: .bold) + ] + + attributedDescription.addAttributes( + boldAttributes, + range: NSRange(range, in: description) + ) + + return attributedDescription + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/HeliumClaimingStatusView+Content.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/HeliumClaimingStatusView+Content.swift new file mode 100644 index 00000000..d6d78098 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/HeliumClaimingStatusView+Content.swift @@ -0,0 +1,66 @@ +// +// HeliumClaimingStatusView+Content.swift +// PresentationLayer +// +// Created by Pantelis Giazitsis on 30/1/23. +// + +import DomainLayer +import SwiftUI + +extension HeliumClaimingStatusView { + func dismissAndNavigate(device: DeviceDetails?) { + dismiss() + DispatchQueue.main.async { + if let device { + Router.shared.popToRoot() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { // The only way found to avoid errors with navigation stack + let route = Route.stationDetails(ViewModelsFactory.getStationDetailsViewModel(deviceId: device.id ?? "", + cellIndex: device.cellIndex, + cellCenter: device.cellCenter?.toCLLocationCoordinate2D())) + Router.shared.navigateTo(route) + } + return + } + viewModel.shouldExitClaimFlow = true + } + } + + func dismissAndUpdateFirmware(device: DeviceDetails?) { + dismiss() + viewModel.disconnect() + if let device = device { + mainVM.showFirmwareUpdate(device: device) + } + DispatchQueue.main.async { + viewModel.shouldExitClaimFlow = true + } + } +} + +private extension HeliumClaimingStatusView { + var centeredParagraphStyle: NSParagraphStyle { + let style = NSMutableParagraphStyle() + style.alignment = .center + return style + } +} + +extension HeliumClaimingStatusView { + enum Steps: Int, CaseIterable, CustomStringConvertible { + case settingFrequency + case rebooting + case claiming + + var description: String { + switch self { + case .settingFrequency: + return LocalizableString.ClaimDevice.stepSettingFrequency.localized + case .rebooting: + return LocalizableString.rebootingStation.localized + case .claiming: + return LocalizableString.ClaimDevice.stepClaiming.localized + } + } + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/HeliumClaimingStatusView.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/HeliumClaimingStatusView.swift new file mode 100644 index 00000000..c669bc78 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/HeliumClaimingStatusView.swift @@ -0,0 +1,482 @@ +// +// HeliumClaimingStatusView.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 10/10/22. +// + +import DomainLayer +import SwiftUI +import Toolkit + +struct HeliumClaimingStatusView: View { + @EnvironmentObject var viewModel: ClaimDeviceViewModel + let mainVM: MainScreenViewModel = .shared + + @State private var steps: [StepsView.Step] = Steps.allCases.map { StepsView.Step(text: $0.description, isCompleted: false) } + @State private var stepIndex: Int? + @State private var showUpdateFirmwareAlert: Bool = false + @State private var updateFirmwareAlertDevice: DeviceDetails? + + let dismiss: () -> Void + let restartClaimFlow: () -> Void + + var body: some View { + ZStack { + VStack { + closeButton + + Spacer() + icon + title + information.padding(.bottom, 20) + + if !viewModel.isM5 { + stepsView + } + + switch viewModel.claimState { + case .connectionError, .failed: + contactSupport + default: + EmptyView() + } + Spacer() + + infoView + + bottomButtons + } + } + .WXMCardStyle() + .padding(CGFloat(.defaultSidePadding)) + .onChange(of: viewModel.claimState) { newValue in + updateSteps(for: newValue) + } + .onAppear { + updateSteps(for: viewModel.claimState) + trackViewContentEvent() + } + .alert(isPresented: $showUpdateFirmwareAlert) { + updateFirmwareGoToStationAlert + } + } + + @ViewBuilder + var closeButton: some View { + switch viewModel.claimState { + case .idle, .claiming, .rebooting, .settingFrequency: + HStack { + Spacer() + + Button { + dismiss() + } label: { + Image(asset: .closeButton) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + } + } + case .success: + EmptyView() + case .failed, .connectionError: + EmptyView() + } + } + + @ViewBuilder + var icon: some View { + switch viewModel.claimState { + case .idle: + EmptyView() + case .claiming, .rebooting, .settingFrequency: + spinner + case .success: + successIcon + case .failed, .connectionError: + failIcon + } + } + + @ViewBuilder + var spinner: some View { + LottieView(animationCase: AnimationsEnums.loading.animationString, loopMode: .repeat(.infinity)) + .background( + Circle().fill(Color(colorEnum: .layer2)) + ) + .frame(width: 150, height: 150) + .padding(.bottom, 20) + } + + @ViewBuilder + var successIcon: some View { + LottieView(animationCase: AnimationsEnums.success.animationString, loopMode: .playOnce) + .background( + Circle().fill(Color(colorEnum: .layer2)) + ) + .frame(width: 150, height: 150) + .padding(.bottom, 20) + } + + @ViewBuilder + var failIcon: some View { + LottieView(animationCase: AnimationsEnums.fail.animationString, loopMode: .playOnce) + .background( + Circle().fill(Color(colorEnum: .layer2)) + ) + .frame(width: 150, height: 150) + .padding(.bottom, 20) + } + + @ViewBuilder + var title: some View { + Group { + switch viewModel.claimState { + case .idle: + EmptyView() + case .claiming, .rebooting, .settingFrequency: + Text(LocalizableString.ClaimDevice.claimingTitle.localized) + .font(.system(size: CGFloat(.largeTitleFontSize), weight: .bold)) + case .success: + Text(LocalizableString.ClaimDevice.successTitle.localized) + .font(.system(size: CGFloat(.largeTitleFontSize), weight: .bold)) + case .failed: + Text(LocalizableString.ClaimDevice.failedTitle.localized) + .font(.system(size: CGFloat(.largeTitleFontSize), weight: .bold)) + case .connectionError: + Text(LocalizableString.ClaimDevice.connectionFailedTitle.localized) + .font(.system(size: CGFloat(.largeTitleFontSize), weight: .bold)) + } + } + .foregroundColor(Color(colorEnum: .text)) + } + + @ViewBuilder + var information: some View { + switch viewModel.claimState { + case .idle: + EmptyView() + case .claiming, .settingFrequency, .rebooting: + claimingInformation + case let .success(deviceResponse, _): + successInformation(stationName: deviceResponse?.displayName ?? "") + case let .failed(error): + failureInformation(errorMessage: error.message) + case .connectionError: + connectionFailureInformation + } + } + + @ViewBuilder + var claimingInformation: some View { + let boldText = viewModel.isM5 ? "" : LocalizableString.ClaimDevice.claimingTextInformation.localized + let text = LocalizableString.ClaimDevice.claimingText(boldText).localized + var attributedtext = NSMutableAttributedString( + string: text, + attributes: [.font: UIFont.systemFont(ofSize: FontSizeEnum.normalFontSize.sizeValue)] + ) + + if let range = text.range(of: boldText) { + let boldTextRange = NSRange(range, in: text) + attributedtext.setAttributes([.font: UIFont.systemFont(ofSize: FontSizeEnum.normalFontSize.sizeValue, weight: .bold)], range: boldTextRange) + } + + return AttributedLabel(attributedText: .constant(attributedtext)) { + $0.textAlignment = .center + } + } + + @ViewBuilder + func successInformation(stationName: String) -> some View { + let boldText = stationName + let text = LocalizableString.ClaimDevice.successText(boldText).localized + let attributedtext = NSMutableAttributedString( + string: text, + attributes: [.font: UIFont.systemFont(ofSize: FontSizeEnum.normalFontSize.sizeValue)] + ) + + if let range = text.range(of: boldText) { + let boldTextRange = NSRange(range, in: text) + attributedtext.setAttributes([.font: UIFont.systemFont(ofSize: FontSizeEnum.normalFontSize.sizeValue, weight: .bold)], range: boldTextRange) + } + + return AttributedLabel(attributedText: .constant(attributedtext)) { + $0.textAlignment = .center + } + } + + @ViewBuilder + func failureInformation(errorMessage: String) -> some View { + let contactLink = LocalizableString.ClaimDevice.failedTextLinkTitle.localized + let error = "**\(errorMessage)**" + let str = LocalizableString.ClaimDevice.failedText(error, contactLink).localized + let attributedStr = str.attributedMarkdown + + Text(attributedStr!) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + .multilineTextAlignment(.center) + } + + @ViewBuilder var connectionFailureInformation: some View { + // Contact + let contactLink = LocalizableString.ClaimDevice.failedTextLinkTitle.localized + + // Troubleshooting + let troubleshootingLink = LocalizableString.ClaimDevice.failedTroubleshootingTextLinkTitle.localized + + // Text format + let text = LocalizableString.ClaimDevice.connectionFailedMarkDownText(troubleshootingLink, contactLink).localized + + Text(text.attributedMarkdown!) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + .multilineTextAlignment(.center) + } + + @ViewBuilder + var stepsView: some View { + switch viewModel.claimState { + case .idle, .connectionError, .success, .failed: + EmptyView() + case .settingFrequency, .rebooting, .claiming: + StepsView(steps: steps, currentStepIndex: $stepIndex) + } + } + + @ViewBuilder + var bottomButtons: some View { + switch viewModel.claimState { + case .idle, .claiming, .rebooting, .settingFrequency: + EmptyView() + case let .success(device, followState): + successBottomButtons(device, followState: followState) + case .failed, .connectionError: + failureBottomButtons + } + } + + @ViewBuilder + func successBottomButtons(_ device: DeviceDetails?, followState: UserDeviceFollowState?) -> some View { + HStack(spacing: CGFloat(.defaultSpacing)) { + let needsUpdate = device?.needsUpdate(mainVM: mainVM, followState: followState) == true + let style = needsUpdate ? WXMButtonStyle(fillColor: .clear) : WXMButtonStyle() + Button { + if let event = viewModel.claimState.retryButtonEvent { + Logger.shared.trackEvent(event.event, parameters: event.parameters) + } + + if let device, + device.needsUpdate(mainVM: mainVM, followState: followState) { + updateFirmwareAlertDevice = device + showUpdateFirmwareAlert = true + return + } + dismissAndNavigate(device: device) + } label: { + Text(LocalizableString.ClaimDevice.viewStationButton.localized) + } + .buttonStyle(style) + + if device?.needsUpdate(mainVM: mainVM, followState: followState) == true { + Button { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .claimingResult, + .contentType: .claiming, + .action: .updateFirmware]) + + dismissAndUpdateFirmware(device: device) + } label: { + HStack(spacing: CGFloat(.smallSpacing)) { + Image(asset: .updateFirmwareIcon) + Text(LocalizableString.ClaimDevice.updateFirmwareButton.localized) + } + } + .buttonStyle(WXMButtonStyle.filled()) + } + } + } + + @ViewBuilder + var failureBottomButtons: some View { + HStack(spacing: CGFloat(.smallSpacing)) { + Button { + if let event = viewModel.claimState.cancelButtonEvent { + Logger.shared.trackEvent(event.event, parameters: event.parameters) + } + + dismiss() + viewModel.cancelClaim() + DispatchQueue.main.async { + viewModel.shouldExitClaimFlow = true + } + } label: { + Text(LocalizableString.ClaimDevice.cancelClaimButton.localized) + } + .buttonStyle(WXMButtonStyle()) + + Button { + if let event = viewModel.claimState.retryButtonEvent { + Logger.shared.trackEvent(event.event, parameters: event.parameters) + } + + restartClaimFlow() + } label: { + Text(LocalizableString.ClaimDevice.retryClaimButton.localized) + } + .buttonStyle( + WXMButtonStyle( + textColor: .top, + fillColor: .primary + ) + ) + } + } + + @ViewBuilder + var contactSupport: some View { + Button { + viewModel.handleContactSupportTap(userEmail: mainVM.userInfo?.email ?? "") + } label: { + Text(LocalizableString.contactSupport.localized) + } + .buttonStyle(WXMButtonStyle()) + } + + @ViewBuilder + var infoView: some View { + switch viewModel.claimState { + case .idle, .connectionError, .failed, .settingFrequency, .rebooting, .claiming: + EmptyView() + case let .success(device, followState): + if let device, + device.needsUpdate(mainVM: mainVM, followState: followState), + let text = LocalizableString.ClaimDevice.updateFirmwareInfoMarkdown.localized.attributedMarkdown { + InfoView(text: text) + .padding(.bottom, CGFloat(.defaultSidePadding)) + .onAppear { + Logger.shared.trackEvent(.prompt, parameters: [.promptName: .OTAAvailable, + .promptType: .warnPromptType, + .action: .viewAction]) + } + } else { + EmptyView() + } + } + } + + var updateFirmwareGoToStationAlert: Alert { + Alert( + title: Text(LocalizableString.ClaimDevice.updateFirmwareAlertTitle.localized), + message: Text(LocalizableString.ClaimDevice.updateFirmwareAlertText.localized), + primaryButton: .default(Text(LocalizableString.ClaimDevice.updateFirmwareAlertGoToStation.localized)) { + showUpdateFirmwareAlert = false + if let device = updateFirmwareAlertDevice { + dismissAndNavigate(device: device) + } + }, + secondaryButton: .default(Text(LocalizableString.ClaimDevice.updateFirmwareAlertUpdate.localized)) { + if let device = updateFirmwareAlertDevice { + dismissAndUpdateFirmware(device: device) + } + } + ) + } +} + +private extension HeliumClaimingStatusView { + func updateSteps(for state: ClaimDeviceViewModel.ClaimState) { + switch state { + case .idle, .connectionError, .success, .failed: + stepIndex = nil + case .settingFrequency: + stepIndex = Steps.settingFrequency.index + case .rebooting: + stepIndex = Steps.rebooting.index + case .claiming: + stepIndex = Steps.claiming.index + } + + (0 ..< steps.count).forEach { index in + guard let stepIndex else { + steps[index].setCompleted(false) + return + } + steps[index].setCompleted(stepIndex > index) + } + } + + func trackViewContentEvent() { + guard let success = viewModel.claimState.viewContentSuccessValue else { + return + } + + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .claimingResult, + .contentId: .claimingResultContentId, + .success: .custom(success)]) + } +} + +private extension ClaimDeviceViewModel.ClaimState { + typealias AnalyticsEvent = (event: Event, parameters: [Parameter: ParameterValue]) + + var viewContentSuccessValue: String? { + switch self { + case .connectionError: + return "0" + case .success: + return "1" + case .failed: + return "0" + default: + return nil + } + } + + var cancelButtonEvent: AnalyticsEvent? { + switch self { + case .idle: + return nil + case .settingFrequency: + return nil + case .rebooting: + return nil + case .claiming: + return nil + case .connectionError: + return (.userAction, [.actionName: .heliumBLEPopupError, + .contentType: .heliumBLEPopup, + .action: .quit]) + case .success: + return nil + case .failed: + return (.userAction, [.actionName: .claimingResult, + .contentType: .claiming, + .action: .cancel]) + } + } + + var retryButtonEvent: AnalyticsEvent? { + switch self { + case .idle: + return nil + case .settingFrequency: + return nil + case .rebooting: + return nil + case .claiming: + return nil + case .connectionError: + return (.userAction, [.actionName: .heliumBLEPopupError, + .contentType: .heliumBLEPopup, + .action: .tryAgain]) + case .success: + return (.userAction, [.actionName: .claimingResult, + .contentType: .claiming, + .action: .viewStation]) + case .failed: + return (.userAction, [.actionName: .claimingResult, + .contentType: .claiming, + .action: .retry]) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/UITextField+StationSerialNumber.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/UITextField+StationSerialNumber.swift new file mode 100644 index 00000000..5f978e16 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/Helium/Util/UITextField+StationSerialNumber.swift @@ -0,0 +1,226 @@ +// +// UITextField+StationSerialNumber.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 19/12/22. +// + +import UIKit + +extension UITextField { + private static let PLACEHOLDER_CHARACTER = " " + private static let SEPARATOR_CHARACTER = ":" + private static let DISALLOWED_CHARACTERS_REGEX = "[^0-9|A-F|a-f|:]" + private static let SEGMENTS = 9 + private static let MAX_LENGTH = SEGMENTS * 2 + private static let MONOSPACE_FONT_NAME = "Menlo-Regular" + + private static var textFieldAddTrailingCharacters = NSHashTable.weakObjects() + var addTrailingCharacters: Bool { + get { + return Self.textFieldAddTrailingCharacters.contains(self) + } + set(value) { + if value { + Self.textFieldAddTrailingCharacters.add(self) + } else { + Self.textFieldAddTrailingCharacters.remove(self) + } + } + } + + private static var textFieldPlaceholderCharacters = NSMapTable.weakToStrongObjects() + var placeholderCharacter: NSString { + get { + return Self.textFieldPlaceholderCharacters.object(forKey: self) ?? "" + } + set(value) { + Self.textFieldPlaceholderCharacters.setObject(value, forKey: self) + } + } + + func focusOnFirstOccuranceOf(_ character: Character) { + var index = 0 + for char in text ?? "" { + if char == character { + break + } + + index += 1 + } + + if let position = position(from: beginningOfDocument, offset: index) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.selectedTextRange = self.textRange(from: position, to: position) + } + } + } + + /** + This method optimizes the Serial Number editing experience, by providing a character mask and customizing + text input and selection for this particular usecase. + */ + func updateSerialNumberCharactersIn(nsRange: NSRange, for enteredString: String) { + if !enteredString.isEmpty, enteredString.matches(Self.DISALLOWED_CHARACTERS_REGEX) { + return + } + + let string = removeSeparators(enteredString) + if !enteredString.isEmpty, string.isEmpty { + return + } + + var nsRange = adjustedNsRange(from: nsRange, string: string) + + let placeholderCharacter = placeholderCharacter as String + let stringToReplace = string.isEmpty + ? (addTrailingCharacters + ? String(repeating: placeholderCharacter, count: nsRange.length) + : String(repeating: placeholderCharacter, count: 0) + ) + : string.uppercased() + + let text = removeSeparators(text ?? "") + + if nsRange.length < string.count { + let maxLength = text.count - nsRange.location + nsRange.length = min(string.count, maxLength) + } + + if let range = Range(nsRange, in: text) { + let newText = text.replacingCharacters(in: range, with: stringToReplace) + let selectedTextRange = adjustedTextRange(from: selectedTextRange, separators: .remove) + + if addTrailingCharacters { + self.text = Self.formatAsSerialNumber(newText) + } else { + self.text = Self.formatAsSerialNumber(newText, chars: 0) + } + + if + let selectedTextRange = selectedTextRange, + let newPosition = position( + from: selectedTextRange.start, + offset: string.count > 0 ? string.count : -1 + ) ?? position( + from: selectedTextRange.start, + offset: 0 + ) + { + self.selectedTextRange = adjustedTextRange( + from: textRange(from: newPosition, to: newPosition), + separators: .add + ) + } + } + } + + func configureForSerialNumber() { + // Monospaced font used here for an improved editing experience. + font = UIFont(name: Self.MONOSPACE_FONT_NAME, size: FontSizeEnum.mediumFontSize.sizeValue) + autocorrectionType = .no + autocapitalizationType = .none + } + + static func formatAsSerialNumber( + _ text: String, + chars: Int? = nil, + placeholder: String? = nil + ) -> String { + let charsToAdd = chars ?? Self.MAX_LENGTH - text.count + if charsToAdd <= 0 { + return String( + text + .prefix(Self.MAX_LENGTH) + .unfoldSubSequences(limitedTo: 2) + .joined(separator: Self.SEPARATOR_CHARACTER) + ) + } + + let placeholder = placeholder ?? Self.PLACEHOLDER_CHARACTER + return String( + (text + String(repeating: placeholder, count: charsToAdd)) + .prefix(Self.MAX_LENGTH) + ) + .unfoldSubSequences(limitedTo: 2) + .joined(separator: Self.SEPARATOR_CHARACTER) + } +} + +private extension UITextField { + enum SeparatorBehavior { + case add + case remove + } + + func offsetWithSeparators(_ ofs: Int, separators: SeparatorBehavior) -> Int { + let separatorCount = ofs / (separators == .add ? 2 : 3) + if separators == .add { + return ofs + separatorCount + } else { + return ofs - separatorCount + } + } + + func adjustedNsRange(from range: NSRange, string: String) -> NSRange { + var offset = 0 + if string.isEmpty, range.length == 1 { + // Special case when deleting a character after a separator. + if (range.location + range.length) % 3 == 0 { + offset = 1 + } + } + + let location = max(0, offsetWithSeparators(range.location, separators: .remove) - offset) + let end = max(0, offsetWithSeparators(range.location + range.length, separators: .remove)) + let adjusted = NSRange(location: location, length: max(0, end - location)) + return adjusted + } + + func adjustedTextRange( + from range: UITextRange?, + separators: SeparatorBehavior + ) -> UITextRange? { + guard let range = range else { + return nil + } + + let startOffset = min(max(0, offsetWithSeparators( + offset(from: beginningOfDocument, to: range.start), + separators: separators + )), (text ?? "").count) + + let endOffset = min(max(0, offsetWithSeparators( + offset(from: beginningOfDocument, to: range.end), + separators: separators + )), (text ?? "").count) + + guard + let startPosition = position(from: beginningOfDocument, offset: startOffset), + let endPosition = position(from: beginningOfDocument, offset: endOffset) + else { + return nil + } + + return textRange( + from: startPosition, + to: endPosition + ) + } + + func removeSeparators(_ text: String) -> String { + return text.replacingOccurrences(of: Self.SEPARATOR_CHARACTER, with: "") + } +} + +extension Collection { + func unfoldSubSequences(limitedTo maxLength: Int) -> UnfoldSequence { + sequence(state: startIndex) { start in + guard start < endIndex else { return nil } + let end = index(start, offsetBy: maxLength, limitedBy: endIndex) ?? endIndex + defer { start = end } + return self[start ..< end] + } + } +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/SelectDeviceTypeView.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/SelectDeviceTypeView.swift new file mode 100644 index 00000000..bdfb9c1d --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/SelectDeviceTypeView.swift @@ -0,0 +1,117 @@ +// +// SelectDeviceTypeView.swift +// PresentationLayer +// +// Created by Manolis Katsifarakis on 27/9/22. +// + +import SwiftUI +import Toolkit + +struct SelectDeviceTypeView: View { + let dismiss: () -> Void + let didSelectClaimFlow: (DeviceClaimFlow) -> Void + + enum DeviceClaimFlow { + case manual + case bluetooth + } + + var body: some View { + selectDeviceTypeContainer.shadow(radius: 4) + .onAppear { + Logger.shared.trackScreen(.claimDeviceTypeSelection) + } + } + + var selectDeviceTypeContainer: some View { + VStack(spacing: 0) { + title + ws1000Button() + divider + ws2000Button() + } + .WXMCardStyle( + backgroundColor: Color(colorEnum: .top), + foregroundColor: Color(colorEnum: .text), + insideHorizontalPadding: 0, + insideVerticalPadding: 0 + ) + } + + var title: some View { + HStack { + Text(LocalizableString.ClaimDevice.selectType.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .multilineTextAlignment(.leading) + .padding(20) + Spacer() + Button { + dismiss() + } label: { + Image(asset: .closeIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + } + .frame(width: 50, height: 50) + } + } + + func ws1000Button() -> some View { + Button { + didSelectClaimFlow(.manual) + } label: { + deviceEntry( + icon: AssetEnum.claimWiFi, + title: LocalizableString.ClaimDevice.typeWS1000Title.localized, + subtitle: LocalizableString.ClaimDevice.typeWS1000Subtitle.localized + ) + } + } + + func ws2000Button() -> some View { + Button { + didSelectClaimFlow(.bluetooth) + } label: { + deviceEntry( + icon: AssetEnum.claimHelium, + title: LocalizableString.ClaimDevice.typeWS2000Title.localized, + subtitle: LocalizableString.ClaimDevice.typeWS2000Subtitle.localized, + bottomPadding: 30 + ) + } + } + + func deviceEntry( + icon: AssetEnum, + title: String, + subtitle: String, + bottomPadding: CGFloat = CGFloat(.defaultSidePadding) + ) -> some View { + VStack { + HStack(spacing: CGFloat(.mediumSpacing)) { + Image(asset: icon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + + Text(title) + .font(.system(size: CGFloat(.normalMediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + Spacer() + } + HStack { + Text(subtitle) + .font(.system(size: CGFloat(.normalMediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .primary)) + Spacer() + } + } + .padding(.top, 20) + .padding(.bottom, bottomPadding) + .padding(.horizontal, 43) + .background(Color(colorEnum: .layer1)) + } + + var divider: some View = WXMDivider().padding(.horizontal, CGFloat(.defaultSidePadding)) +} diff --git a/PresentationLayer/UI Components/Screens/ClaimDevice/SuccessFailureView.swift b/PresentationLayer/UI Components/Screens/ClaimDevice/SuccessFailureView.swift new file mode 100644 index 00000000..fc904f51 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ClaimDevice/SuccessFailureView.swift @@ -0,0 +1,118 @@ +// +// SuccessFailureView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 23/5/22. +// + +import SwiftUI + +struct SuccessFailureView: View { + @Environment(\.presentationMode) var presentationMode: Binding + let title: String + let description: String + let isSuccess: Bool + let isBottomButtonAvailable: Bool + let serialNumber: String? + let email: String? + + public init(title: String, desc: String, isSuccess: Bool, isBottomButtonAvailable: Bool = false, serialNumber: String? = nil, email: String? = nil) { + self.title = title + description = desc + self.isSuccess = isSuccess + self.isBottomButtonAvailable = isBottomButtonAvailable + self.serialNumber = serialNumber + self.email = email + } + + var body: some View { + claimDeviceFailureScreen + } + + var claimDeviceFailureScreen: some View { + VStack { + claimDeviceFailureContainer + viewDeviceListButton + Spacer() + } + } + + var claimDeviceFailureContainer: some View { + VStack { + Spacer() + successFailIcon + successFailTitle + successFailDescription + Spacer() + bottomButton + } + .baseContainerStyle() + } + + @ViewBuilder + var viewDeviceListButton: some View { + if isBottomButtonAvailable { + BaseButton( + text: .viewDeviceList, + isEnabled: isSuccess, + isRedirection: false + ) { + presentationMode.wrappedValue.dismiss() + } + .customPadding(.top, .failureStandardPadding) + .padding(.horizontal, 34) + } + } + + var successFailIcon: some View { + LottieView(animationCase: getDeviceFlowFinalAnimation(), loopMode: .playOnce) + .frame( + width: CGFloat(IntConstants.iconDimensions), + height: CGFloat(IntConstants.iconDimensions) + ) + } + + var successFailTitle: some View { + Text(title) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .customPadding(.bottom, .failureStandardPadding) + .multilineTextAlignment(.center) + } + + var successFailDescription: some View { + Text(description) + .font(.system(size: CGFloat(.largeFontSize), weight: .thin)) + .multilineTextAlignment(.center) + } + + @ViewBuilder + var bottomButton: some View { + if isBottomButtonAvailable { + if !isSuccess { + BaseTextButton(text: .contactUsText, isRedirection: false) { + openEmailApp() + } + .scaleEffect(1.2) + .lineSpacing(1) + .multilineTextAlignment(.center) + } + } + } + + private func openEmailApp() { + let subject = StringConstants.cannotClaimDeviceTitleEmail.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "" + let firstBodyParameter: String = (StringConstants.cannotClaimDeviceBodyUserAccount + (email ?? "")).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "" + let secondBodyParameter = ("\n" + (StringConstants.cannotClaimDeviceBodyDeviceSN + (serialNumber?.replaceColonOcurrancies() ?? ""))).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "" + if let url = URL(string: "mailto:\(StringConstants.weatherXMSupportEmail)?subject=\(subject)&body=\(firstBodyParameter)\(secondBodyParameter)") { + UIApplication.shared.open(url) + } + } + + private func getDeviceFlowFinalAnimation() -> String { + if isSuccess { + return AnimationsEnums.success.animationString + } else { + return AnimationsEnums.fail.animationString + } + } +} diff --git a/PresentationLayer/UI Components/Screens/ConnectWallet/MyWalletView.swift b/PresentationLayer/UI Components/Screens/ConnectWallet/MyWalletView.swift new file mode 100644 index 00000000..d3b7d719 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ConnectWallet/MyWalletView.swift @@ -0,0 +1,262 @@ +// +// MyWalletView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 5/4/23. +// + +import SwiftUI +import CodeScanner +import Toolkit + +struct MyWalletView: View { + @EnvironmentObject var navigationObj: NavigationObject + @StateObject var viewModel: MyWalletViewModel + @State private var showScanner: Bool = false + @State private var bottomDrawerSize: CGSize = .zero + + var body: some View { + ZStack { + Color(colorEnum: .bg).ignoresSafeArea() + + ZStack { + TrackableScrollView(offsetObject: viewModel.trackableObject) { + VStack(spacing: CGFloat(.defaultSpacing)) { + #warning("Bring back this banner") + // Remove temporary this banner to pass the App Store review + /* + if !viewModel.isInEditMode { + InfoView(text: LocalizableString.Wallet.rewardsInfoText.localized.attributedMarkdown ?? "") + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .transition(AnyTransition.opacity.animation(.easeIn(duration: 0.2))) + } + */ + + enterAddressCard + .padding(.horizontal, CGFloat(.defaultSidePadding)) + + if !viewModel.isInEditMode { + Button { + viewModel.handleViewTransactionHistoryTap() + } label: { + Text(LocalizableString.Wallet.viewTransactionHistory.localized) + } + .buttonStyle(WXMButtonStyle(fillColor: .top)) + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .transition(AnyTransition.opacity.animation(.easeIn(duration: 0.2))) + } + + if viewModel.isWarningVisible { + warningCard + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .transition(AnyTransition.opacity.animation(.easeIn(duration: 0.2))) + } + } + .padding(.top) + } + .padding(.bottom, bottomDrawerSize.height) + + if viewModel.isInEditMode { + bottomDrawer + } + } + .spinningLoader(show: $viewModel.isLoading, hideContent: true) + .fail(show: $viewModel.isFailed, obj: viewModel.failObj) + } + .simultaneousGesture(TapGesture().onEnded { _ in + hideKeyboard() + }) + .onAppear { + navigationObj.title = LocalizableString.Wallet.myWallet.localized + navigationObj.navigationBarColor = Color(colorEnum: .bg) + Logger.shared.trackScreen(.wallet) + } + .sheet(isPresented: $viewModel.showQrScanner) { + CodeScannerView(codeTypes: [.qr], completion: viewModel.handleScanResult) + .overlay(QrScannerView()) + } + .customSheet(isPresented: $viewModel.showAccountConfirmation) { _ in + AccountConfirmationView(viewModel: viewModel.accountConfirmationViewModel!) + } + + } +} + +private extension MyWalletView { + @ViewBuilder + var enterAddressCard: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + VStack(spacing: CGFloat(.smallSpacing)) { + HStack { + Text(LocalizableString.Wallet.enterWallet.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + Spacer() + } + + TextFieldsWallet(type: viewModel.isInEditMode ? .newWXMAddress : .currentWXMAddress, + input: $viewModel.input, + newAddressError: viewModel.textFieldError) + } + + if viewModel.isInEditMode { + scanQRButton + } else { + editAddressButton + } + + VStack(spacing: CGFloat(.smallSpacing)) { + HStack { + Text(LocalizableString.Wallet.addressTextFieldCaption.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + Spacer() + } + + HStack { + Text(LocalizableString.Wallet.createMetaMaskLink.localized.attributedMarkdown!) + .tint(Color(colorEnum: .primary)) + .font(.system(size: CGFloat(.caption), weight: .bold)) + .simultaneousGesture(TapGesture().onEnded { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .createMetamask, + .itemId: .custom(viewModel.wallet?.address ?? "")]) + }) + Spacer() + } + } + } + .WXMCardStyle() + .wxmShadow() + } + + @ViewBuilder + var warningCard: some View { + CardWarningView(type: .error, + title: LocalizableString.Wallet.compatibility.localized, + message: LocalizableString.Wallet.compatibilityDescription.localized) { + viewModel.isWarningVisible = false + Logger.shared.trackEvent(.prompt, parameters: [.promptName: .walletCompatibility, + .promptType: .info, + .action: .dismissAction]) + } content: { + HStack { + Text(LocalizableString.Wallet.compatibilityCheckLink.localized.attributedMarkdown!) + .tint(Color(colorEnum: .primary)) + .font(.system(size: CGFloat(.caption), weight: .bold)) + .simultaneousGesture(TapGesture().onEnded { + Logger.shared.trackEvent(.prompt, parameters: [.promptName: .walletCompatibility, + .promptType: .info, + .action: .action]) + }) + Spacer() + } + } + .onAppear { + Logger.shared.trackEvent(.prompt, parameters: [.promptName: .walletCompatibility, + .promptType: .info, + .action: .viewAction]) + } + + } + + @ViewBuilder + var scanQRButton: some View { + Button { + viewModel.handleQRButtonTap() + } label: { + HStack { + Image(asset: .qrCodeBlue) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .primary)) + Text(LocalizableString.Wallet.scanQRCodeButton.localized) + } + } + .buttonStyle(WXMButtonStyle()) + } + + @ViewBuilder + var editAddressButton: some View { + Button { + withAnimation { + viewModel.handleEditButtonTap() + } + } label: { + HStack { + Image(asset: .editIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .primary)) + Text(LocalizableString.Wallet.editAddress.localized) + } + } + .buttonStyle(WXMButtonStyle()) + } + + @ViewBuilder + var bottomDrawer: some View { + VStack { + Spacer() + + VStack(spacing: CGFloat(.defaultSpacing)) { + HStack { + Toggle("", + isOn: $viewModel.isTermsOfServiceAccepted) + .labelsHidden() + .toggleStyle(WXMToggleStyle.Default) + + Text("\(LocalizableString.Wallet.acceptTermsOfService.localized) **[\(LocalizableString.Wallet.termsTitle.localized)](\(DisplayedLinks.termsLink.linkURL))**".attributedMarkdown!) + .tint(Color(colorEnum: .primary)) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + .simultaneousGesture(TapGesture().onEnded { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .walletTermsOfService]) + }) + + Spacer() + } + + HStack { + Toggle("", + isOn: $viewModel.isOwnershipAcknowledged) + .labelsHidden() + .toggleStyle(WXMToggleStyle.Default) + + Text(LocalizableString.Wallet.acknowledgementOfOwnership.localized) + .tint(Color(colorEnum: .primary)) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + + Spacer() + } + + Button { + viewModel.handleSaveButtonTap() + } label: { + Text(LocalizableString.Wallet.saveAddress.localized) + } + .buttonStyle(WXMButtonStyle(textColor: .top, + fillColor: .primary)) + .disabled(!viewModel.isSaveButtonEnabled) + } + .WXMCardStyle() + .sizeObserver(size: $bottomDrawerSize) + .background { + Color(colorEnum: .top) + .cornerRadius(CGFloat(.cardCornerRadius)) + .wxmShadow() + .drawingGroup() + .ignoresSafeArea() + } + } + .transition(AnyTransition.move(edge: .bottom).animation(.easeIn(duration: 0.2))) + + } +} + +struct MyWalletView_Previews: PreviewProvider { + static var previews: some View { + NavigationContainerView { + MyWalletView(viewModel: MyWalletViewModel(useCase: nil)) + .environmentObject(MainScreenViewModel.shared) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/ConnectWallet/MyWalletViewModel.swift b/PresentationLayer/UI Components/Screens/ConnectWallet/MyWalletViewModel.swift new file mode 100644 index 00000000..794ad071 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ConnectWallet/MyWalletViewModel.swift @@ -0,0 +1,186 @@ +// +// MyWalletViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 5/4/23. +// + +import Foundation +import Combine +import DomainLayer +import CodeScanner +import Toolkit + +class MyWalletViewModel: ObservableObject { + private let ethAddressPrefix = "0x" + private let ethAddressLength = 42 + + let trackableObject = TrackableScrollOffsetObject() + @Published var input: String = "" { + didSet { + textFieldError = nil + } + } + @Published var textFieldError: TextFieldError? + @Published var isTermsOfServiceAccepted: Bool = false + @Published var isOwnershipAcknowledged: Bool = false + @Published var isWarningVisible: Bool = false + @Published var isInEditMode: Bool = false + @Published var showQrScanner: Bool = false + @Published var showAccountConfirmation: Bool = false + @Published var isLoading: Bool = false + @Published var isFailed: Bool = false + private(set) var failObj: FailSuccessStateObject? + var isSaveButtonEnabled: Bool { + isTermsOfServiceAccepted && + isOwnershipAcknowledged + } + var accountConfirmationViewModel: AccountConfirmationViewModel? + private let mainVM: MainScreenViewModel = .shared + + private let useCase: MeUseCase? + private(set) var wallet: Wallet? + private var cancellableSet: Set = [] + + init(useCase: MeUseCase?) { + self.useCase = useCase + getUserWallet() + } + + func handleEditButtonTap() { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .editWallet, + .itemId: .custom(wallet?.address ?? "")]) + + accountConfirmationViewModel = AccountConfirmationViewModel(title: LocalizableString.confirmPasswordTitle.localized, + descriptionMarkdown: LocalizableString.Wallet.myAccountConfirmationDescription.localized, + useCase: SwinjectHelper.shared.getContainerForSwinject().resolve(AuthUseCase.self)) { [weak self] isvalid in + guard isvalid else { + return + } + self?.showAccountConfirmation = false + self?.isInEditMode = true + self?.isWarningVisible = true + } + showAccountConfirmation = true + } + + func handleSaveButtonTap() { + textFieldError = input.newAddressValidation() + guard textFieldError == nil else { + return + } + + performSaveProcess() + } + + func handleViewTransactionHistoryTap() { + let url = String(format: DisplayedLinks.networkAddressWebsiteFormat.linkURL, input) + HelperFunctions().openUrl(url) + + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .walletTransactions, + .itemId: .custom(wallet?.address ?? "")]) + } + + func handleQRButtonTap() { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .scanQRWallet]) + + showQrScanner = true + } + + func handleScanResult(result: Result) { + showQrScanner = false + switch result { + case let .success(result): + var input = result.string + + if let addressFirstIndex = input.firstIndex(substring: ethAddressPrefix), + let addressLastIndex = input.index(addressFirstIndex, offsetBy: ethAddressLength, limitedBy: input.endIndex) { + input = String(input[addressFirstIndex ..< addressLastIndex]) + } + + self.input = input + case let .failure(error): + print("Scanner failed: \(error.localizedDescription)") + } + } +} + +extension MyWalletViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + hasher.combine(wallet?.address) + } +} + +private extension MyWalletViewModel { + func getUserWallet() { + do { + isLoading = true + try useCase?.getUserWallet() + .sink { [weak self] response in + self?.isLoading = false + + if let error = response.error { + let info = error.uiInfo + let obj = info.defaultFailObject(type: .myWallet) { + self?.isFailed = false + self?.getUserWallet() + } + + self?.failObj = obj + self?.isFailed = true + } else { + self?.wallet = response.value + self?.initializeState() + } + }.store(in: &cancellableSet) + } catch {} + } + + func performSaveUserRequest() { + do { + LoaderView.shared.show() + try useCase?.saveUserWallet(address: input) + .sink { [weak self] response in + guard let self = self else { + return + } + + LoaderView.shared.dismiss { + if let error = response.error { + if let message = error.backendError?.message.attributedMarkdown { + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .failure, + .itemId: .custom(error.backendError?.code ?? "")]) + Toast.shared.show(text: message) + } + } else { + self.isInEditMode = false + self.isWarningVisible = false + Toast.shared.show(text: LocalizableString.addressAdded.localized.attributedMarkdown!, type: .info) + } + } + + }.store(in: &cancellableSet) + } catch {} + } + + func saveUserWallet() { + let okAction: AlertHelper.AlertObject.Action = (LocalizableString.confirm.localized, { [weak self] _ in self?.performSaveUserRequest() }) + let obj = AlertHelper.AlertObject(title: LocalizableString.Wallet.confirmOwnershipTitle.localized, + message: LocalizableString.Wallet.confirmOwnershipDescription(String(wallet?.address?.suffix(5) ?? "")).localized, + cancelActionTitle: LocalizableString.cancel.localized, + okAction: okAction) + DispatchQueue.main.async { + AlertHelper().showAlert(obj) + } + } + + func performSaveProcess() { + saveUserWallet() + } + + func initializeState() { + input = wallet?.address ?? "" + isInEditMode = (wallet?.address == nil) + isWarningVisible = isInEditMode + } +} diff --git a/PresentationLayer/UI Components/Screens/ConnectWallet/TextFieldsWallet.swift b/PresentationLayer/UI Components/Screens/ConnectWallet/TextFieldsWallet.swift new file mode 100644 index 00000000..4e93ff71 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ConnectWallet/TextFieldsWallet.swift @@ -0,0 +1,63 @@ +// +// TextFieldWalletView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 2/6/22. +// + +import SwiftUI + +struct TextFieldsWallet: View { + let type: BaseTextFieldEnum + let inputLimit: Int + @Binding var input: String + var newAddressError: TextFieldError? + + public init( + type: BaseTextFieldEnum, + currentAddressLabel: Binding = .constant(""), + inputLimit: Int = 42, + input: Binding = .constant(""), + newAddressError: TextFieldError? = nil + ) { + self.type = type + self.inputLimit = inputLimit + _input = input.trimmed().max(inputLimit) + self.newAddressError = newAddressError + } + + var body: some View { + VStack(alignment: .leading, spacing: CGFloat(.defaultSpacing)) { + textField + } + } + + var textField: some View { + BaseTextField(input: $input, + caption: type == .newWXMAddress ? "\(input.count)/\(inputLimit)" : nil, + textFieldStyle: type, + error: newAddressError) + } +} + +private extension Binding where Value == String { + func trimmed() -> Self { + if wrappedValue.containsSpaces() { + DispatchQueue.main.async { + self.wrappedValue = self.wrappedValue.trimWhiteSpaces().removeSpaces() + } + } + + return self + } + + func max(_ limit: Int) -> Self { + if self.wrappedValue.count > limit { + DispatchQueue.main.async { + self.wrappedValue = String(self.wrappedValue.dropLast()) + } + } + + return self + } +} diff --git a/PresentationLayer/UI Components/Screens/Daily Rewards /RewardDetailsView.swift b/PresentationLayer/UI Components/Screens/Daily Rewards /RewardDetailsView.swift new file mode 100644 index 00000000..09ba389c --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Daily Rewards /RewardDetailsView.swift @@ -0,0 +1,163 @@ +// +// RewardDetailsView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 31/10/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct RewardDetailsView: View { + + @StateObject var viewModel: RewardDetailsViewModel + @State private var showPopOverMenu: Bool = false + + var body: some View { + NavigationContainerView { + navigationBarRightView + } content: { + ContentView(viewModel: viewModel) + } + } + + @ViewBuilder + var navigationBarRightView: some View { + Button { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .rewardDetailsPopUp]) + + showPopOverMenu = true + } label: { + Text(FontIcon.threeDots.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: .primary)) + .frame(width: 30.0, height: 30.0) + } + .wxmPopOver(show: $showPopOverMenu) { + VStack { + Button { [weak viewModel] in + showPopOverMenu = false + viewModel?.handleReadMoreTap() + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .rewardDetailsReadMore]) + } label: { + Text(LocalizableString.RewardDetails.readMore.localized) + .font(.system(size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + } + .padding() + .background(Color(colorEnum: .top).scaleEffect(2.0).ignoresSafeArea()) + } + } +} + +private struct ContentView: View { + + @ObservedObject var viewModel: RewardDetailsViewModel + @EnvironmentObject var navigationObject: NavigationObject + + var body: some View { + content + .bottomSheet(show: $viewModel.showInfo, fitContent: true) { + bottomInfoView(info: viewModel.info) + } + } + + @ViewBuilder + var content: some View { + ZStack { + Color(colorEnum: .bg) + .ignoresSafeArea() + + TrackableScrollView { + VStack(spacing: CGFloat(.defaultSpacing)) { + VStack(spacing: CGFloat(.defaultSpacing)) { + StationRewardsOverviewView(overview: viewModel.rewardsCardOverview, showError: false, buttonActions: viewModel.buttonActions) + + if !viewModel.rewardsCardOverview.annnotationsList.isEmpty { + errorsList + } + } + .WXMCardStyle() + + if viewModel.followState?.relation == .owned { + Button { + viewModel.handleContactSupportTap() + } label: { + Text(LocalizableString.RewardDetails.contactSupportButtonTitle.localized) + } + .buttonStyle(WXMButtonStyle.solid) + } + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + } + .onAppear { + navigationObject.title = LocalizableString.RewardDetails.title.localized + navigationObject.subtitle = viewModel.device.displayName + navigationObject.titleColor = Color(colorEnum: .text) + navigationObject.navigationBarColor = Color(colorEnum: .bg) + + Logger.shared.trackScreen(.deviceRewardsDetails) + } + } + } + + @ViewBuilder + var errorsList: some View { + VStack(spacing: CGFloat(.mediumSpacing)) { + HStack { + Text(LocalizableString.RewardDetails.problemsTitle.localized) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.smallTitleFontSize), weight: .bold)) + Spacer() + } + + HStack { + Text(viewModel.problemsDescription.attributedMarkdown ?? "") + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + Spacer(minLength: 0.0) + } + + ForEach(viewModel.rewardsCardOverview.annnotationsList, id: \.annotation) { error in + CardWarningView(type: viewModel.rewardsCardOverview.lostAmountData.cardWarningType, + title: error.title, + message: error.dercription(for: viewModel.device.profile, + followState: viewModel.followState), + showContentFullWidth: true, + showBorder: true, + closeAction: nil) { + errorActionView(for: error) + } + } + } + } +} + +private extension ContentView { + @ViewBuilder + func errorActionView(for error: DeviceAnnotation) -> some View { + if let title = viewModel.annotationActionButtonTile(for: error.annotation) { + Button { + viewModel.handleButtonTap(for: error) + } label: { + Text(title) + } + .buttonStyle(WXMButtonStyle.transparent) + } else { + EmptyView() + } + } +} + +#Preview { + let device = DeviceDetails.mockDevice + return ZStack { + Color(colorEnum: .bg) + RewardDetailsView(viewModel: .init(device: device, + followState: .init(deviceId: device.id!, relation: .owned), + tokenUseCase: SwinjectHelper.shared.getContainerForSwinject().resolve(TokenUseCase.self)!, + rewardsCardOverview: .mock(title: "title"))) + } +} diff --git a/PresentationLayer/UI Components/Screens/Daily Rewards /RewardDetailsViewModel.swift b/PresentationLayer/UI Components/Screens/Daily Rewards /RewardDetailsViewModel.swift new file mode 100644 index 00000000..b28a1a95 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Daily Rewards /RewardDetailsViewModel.swift @@ -0,0 +1,211 @@ +// +// RewardDetailsViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 31/10/23. +// + +import Foundation +import DomainLayer +import SwiftUI +import Toolkit + +class RewardDetailsViewModel: ObservableObject { + + @Published var showInfo: Bool = false + private(set) var info: RewardsOverviewButtonActions.Info? + let useCase: TokenUseCase + var device: DeviceDetails + let followState: UserDeviceFollowState? + let rewardsCardOverview: StationRewardsCardOverview + lazy var buttonActions: RewardsOverviewButtonActions = { + getButtonActions() + }() + var problemsDescription: String { + if rewardsCardOverview.lostAmount > 0.0 { + return LocalizableString.RewardDetails.problemsDescription(rewardsCardOverview.lostAmount.toWXMTokenPrecisionString).localized + } + + return LocalizableString.RewardDetails.zeroLostProblemsDescription.localized + } + + init(device: DeviceDetails, followState: UserDeviceFollowState?, tokenUseCase: TokenUseCase, rewardsCardOverview: StationRewardsCardOverview) { + self.device = device + self.followState = followState + self.useCase = tokenUseCase + self.rewardsCardOverview = rewardsCardOverview + } + + func annotationActionButtonTile(for type: DeviceAnnotation.AnnotationType?) -> String? { + guard let type, followState?.relation == .owned else { + return nil + } + + switch type { + case .obc: + if device.needsUpdate(mainVM: MainScreenViewModel.shared, followState: followState) { + return LocalizableString.RewardDetails.upgradeFirmwareButtonTitle.localized + } + + return LocalizableString.contactSupport.localized + case .spikes, .unidentifiedSpike, .noMedian, .noData, .shortConst, + .longConst, .anomIncrease, .unidentifiedAnomalousChange, .noLocationData, .cellCapacityReached, + .polThresholdNotReached, .qodThresholdNotReached: + return LocalizableString.RewardDetails.readMore.localized + case .frozenSensor, .relocated: + return nil + case .locationNotVerified: + return LocalizableString.RewardDetails.editLocation.localized + case .unknown: + return LocalizableString.contactSupport.localized + case .noWallet: + return LocalizableString.RewardDetails.noWalletProblemButtonTitle.localized + } + } + + func handleButtonTap(for error: DeviceAnnotation) { + guard let type = error.annotation else { + return + } + + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .rewardDetailsError, + .itemId: .custom(error.annotation?.rawValue ?? "")]) + handleAnnotationType(annotation: error) + } + + func handleReadMoreTap() { + guard let url = URL(string: DisplayedLinks.rewardMechanism.linkURL) else { + return + } + + UIApplication.shared.open(url) + } + + func handleContactSupportTap() { + openContactSupport() + } +} + +private extension RewardDetailsViewModel { + func getButtonActions() -> RewardsOverviewButtonActions { + .init(rewardsScoreInfoAction: {[weak self] in + self?.info = RewardsOverviewButtonActions.rewardsScoreInfo + self?.showInfo = true + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .learnMore, + .itemId: .rewardsScore]) + + }, dailyMaxInfoAction: { [weak self] in + self?.info = RewardsOverviewButtonActions.dailyMaxInfo + self?.showInfo = true + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .learnMore, + .itemId: .maxRewards]) + }, timelineInfoAction: { [weak self] in + var offsetString: String? + if let identifier = self?.device.timezone, + let timezone = TimeZone(identifier: identifier), + !timezone.isUTC { + offsetString = timezone.hoursOffsetString + } + self?.info = RewardsOverviewButtonActions.timelineInfo(timezoneOffset: offsetString) + self?.showInfo = true + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .learnMore, + .itemId: .timeline]) + }, errorButtonAction: {}) + } + + func handleAnnotationType(annotation: DeviceAnnotation) { + guard let type = annotation.annotation else { + return + } + + switch type { + case .obc: + if device.needsUpdate(mainVM: MainScreenViewModel.shared, followState: followState) { + // show OTA + MainScreenViewModel.shared.showFirmwareUpdate(device: device) + + return + } + openContactSupport(annotation: annotation) + case .spikes, .unidentifiedSpike, .anomIncrease, .unidentifiedAnomalousChange: + if let url = URL(string: DisplayedLinks.documentationLink.linkURL) { + UIApplication.shared.open(url) + } + case .noMedian, .noData, .shortConst, .longConst, .noLocationData: + if let url = URL(string: DisplayedLinks.troubleshooting.linkURL) { + UIApplication.shared.open(url) + } + case .cellCapacityReached: + if let url = URL(string: DisplayedLinks.cellCapacity.linkURL) { + UIApplication.shared.open(url) + } + case .polThresholdNotReached: + if let url = URL(string: DisplayedLinks.polAlgorithm.linkURL) { + UIApplication.shared.open(url) + } + case .qodThresholdNotReached: + if let url = URL(string: DisplayedLinks.qodAlgorithm.linkURL) { + UIApplication.shared.open(url) + } + case .frozenSensor, .relocated: + break + case .locationNotVerified: + let viewModel = ViewModelsFactory.getSelectLocationViewModel(device: device, + followState: followState, + delegate: self) + Router.shared.navigateTo(.selectStationLocation(viewModel)) + case .unknown: + openContactSupport(annotation: annotation) + case .noWallet: + Router.shared.navigateTo(.wallet(ViewModelsFactory.getMyWalletViewModel())) + } + } + + func openContactSupport(annotation: DeviceAnnotation? = nil) { + HelperFunctions().openContactSupport(successFailureEnum: .stationRewardsIssue, + email: nil, + serialNumber: device.label, + errorString: annotation?.title, + addtionalInfo: getEmailAdditionalInfo()) + } + + func getEmailAdditionalInfo() -> String { + let stationInfoTitle = "Station Information" + let stationName = "Station Name: \(device.name)" + let stationId = "Station id: \(device.id ?? "-")" + let explorerUrl = "Explorer URL: \(device.explorerUrl)" + + let rewardInfoTitle = "Reward Information" + let timestamp = "Reward timestamp: \(rewardsCardOverview.date?.toTimestamp() ?? "-")" + let rewardScore = "Reward Score: \(rewardsCardOverview.rewardScore ?? 0)" + let rewardsEarned = "Rewards Earned: \(rewardsCardOverview.actualReward)" + let rewardsLost = "Rewards Lost: \(rewardsCardOverview.lostAmount)" + let periodMaxReward = "Period Max Reward: \(rewardsCardOverview.maxRewards ?? 0.0)" + let annotations = "Annotations: \(rewardsCardOverview.annnotationsList.compactMap { $0.annotation?.rawValue })" + + return [stationInfoTitle, + stationName, + stationId, + explorerUrl, + "", + rewardInfoTitle, + timestamp, + rewardScore, + rewardsEarned, + rewardsLost, + periodMaxReward, + annotations].joined(separator: "\n") + } +} + +extension RewardDetailsViewModel: SelectStationLocationViewModelDelegate { + func locationUpdated(with device: DeviceDetails) { + self.device = device + } +} + +extension RewardDetailsViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + hasher.combine("\(device.id)-\(rewardsCardOverview.hashValue)") + } +} diff --git a/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoRowView.swift b/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoRowView.swift new file mode 100644 index 00000000..17e92351 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoRowView.swift @@ -0,0 +1,152 @@ +// +// DeviceInfoRowView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 7/3/23. +// + +import SwiftUI + +struct DeviceInfoRowView: View { + let row: Row + + var body: some View { + VStack(spacing: CGFloat(.smallToMediumSpacing)) { + VStack(spacing: CGFloat(.minimumSpacing)) { + HStack { + Text(row.title) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + Spacer() + } + + HStack { + Text(row.description) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.normalFontSize))) + .tint(Color(colorEnum: .primary)) + Spacer() + } + } + + warningView + + VStack(spacing: CGFloat(.smallSpacing)) { + if let url = row.imageUrl { + AsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(MapBoxConstants.snapshotSize.width / MapBoxConstants.snapshotSize.height, + contentMode: .fill) + .frame(maxHeight: MapBoxConstants.snapshotSize.height) + .clipped() + .cornerRadius(CGFloat(.buttonCornerRadius)) + } placeholder: { + ProgressView() + } + } + + if let buttonInfo = row.buttonInfo { + Button(action: row.buttonAction) { + HStack(spacing: CGFloat(.smallSpacing)) { + if let icon = buttonInfo.icon { + Image(asset: icon) + .renderingMode(.template) + } + Text(buttonInfo.title ?? "") + } + } + .modify { button in + if let warning = row.warning { + button.buttonStyle(WXMButtonStyle(textColor: .darkestBlue, fillColor: warning.tintColor, strokeColor: warning.color)) + } else { + button.buttonStyle(buttonInfo.buttonStyle) + } + } + } + } + } + } +} + +extension DeviceInfoRowView { + struct Row: Equatable { + static func == (lhs: DeviceInfoRowView.Row, rhs: DeviceInfoRowView.Row) -> Bool { + lhs.title == rhs.title && + lhs.description == rhs.description && + lhs.buttonInfo == rhs.buttonInfo && + lhs.warning == rhs.warning + } + + let title: String + let description: AttributedString + let imageUrl: URL? + let buttonInfo: DeviceInfoButtonInfo? + var warning: Warning? + let buttonAction: () -> Void + } +} + +extension DeviceInfoRowView.Row { + enum Warning: Equatable { + case normal(String) + case desructive(String) + + var color: ColorEnum { + switch self { + case .normal: + return .warning + case .desructive: + return .error + } + } + + var tintColor: ColorEnum { + switch self { + case .normal: + return .warningTint + case .desructive: + return .errorTint + } + } + } +} + +private extension DeviceInfoRowView { + @ViewBuilder + var warningView: some View { + if let warning = row.warning { + HStack(spacing: 0.0) { + Image(asset: .warningIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: warning.color)) + + Group { + switch warning { + case .normal(let text): + Text(text) + case .desructive(let text): + Text(text) + } + } + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + + Spacer() + } + } else { + EmptyView() + } + } +} + +struct DeviceInfoRowView_Previews: PreviewProvider { + static var previews: some View { + DeviceInfoRowView(row: DeviceInfoRowView.Row(title: "TItle", + description: "This is a **desription**".attributedMarkdown!, + imageUrl: URL(string: "https://i0.wp.com/weatherxm.com/wp-content/uploads/2023/12/Home-header-image-1200-x-1200-px-5.png?w=1200&ssl=1"), + buttonInfo: .init(icon: nil, title: "Button title"), + warning: .desructive("This action is not reversible!")) {}) + } +} diff --git a/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoView.swift b/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoView.swift new file mode 100644 index 00000000..af18de6b --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoView.swift @@ -0,0 +1,102 @@ +// +// DeviceInfoView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 7/3/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct DeviceInfoView: View { + + @EnvironmentObject var navigationObject: NavigationObject + @StateObject var viewModel: DeviceInfoViewModel + + var body: some View { + ZStack { + Color(colorEnum: .bg) + .ignoresSafeArea() + + ZStack { + TrackableScrollView(showIndicators: false, + offsetObject: viewModel.offestObject) { completion in + viewModel.refresh(completion: completion) + } content: { + VStack(spacing: CGFloat(.defaultSpacing)) { + ForEach(0 ..< viewModel.sections.count, id: \.self) { index in + let rows = viewModel.sections[index] + VStack(spacing: CGFloat(.mediumSpacing)) { + ForEach(rows, id: \.title) { row in + DeviceInfoRowView(row: row) + if row != rows.last { + WXMDivider() + } + } + } + .WXMCardStyle() + .wxmShadow() + } + + StationInfoView(rows: viewModel.infoRows, + contactSupportTitle: viewModel.contactSupportButtonTitle) { + viewModel.handleShareButtonTap() + } contactSupportAction: { + viewModel.handleContactSupportButtonTap() + } + .WXMCardStyle() + .wxmShadow() + + ForEach(0 ..< viewModel.bottomSections.count, id: \.self) { index in + let rows = viewModel.bottomSections[index] + VStack(spacing: CGFloat(.mediumSpacing)) { + ForEach(rows, id: \.title) { row in + DeviceInfoRowView(row: row) + if row != rows.last { + WXMDivider() + } + } + } + .WXMCardStyle() + .wxmShadow() + } + } + .padding(CGFloat(.defaultSidePadding)) + } + } + .spinningLoader(show: $viewModel.isLoading, hideContent: true) + .fail(show: $viewModel.isFailed, obj: viewModel.failObj) + } + .onAppear { + navigationObject.title = LocalizableString.deviceInfoTitle.localized + navigationObject.navigationBarColor = Color(colorEnum: .bg) + Logger.shared.trackScreen(.stationSettings) + } + .fullScreenCover(isPresented: $viewModel.showRebootStation) { + NavigationContainerView { + RebootStationView(viewModel: viewModel.rebootStationViewModel) + } + } + .fullScreenCover(isPresented: $viewModel.showChangeFrequency) { + NavigationContainerView { + ChangeFrequencyView(viewModel: viewModel.changeFrequencyViewModel) + } + } + .customSheet(isPresented: $viewModel.showAccountConfirmation) { _ in + AccountConfirmationView(viewModel: viewModel.accountConfirmationViewModel) + } + } +} + +struct DeviceInfoView_Previews: PreviewProvider { + static var previews: some View { + var device = DeviceDetails.mockDevice + device.profile = .helium + + return NavigationContainerView { + DeviceInfoView(viewModel: DeviceInfoViewModel(device: device, + followState: .init(deviceId: device.id!, relation: .owned))) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoViewModel+Content.swift b/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoViewModel+Content.swift new file mode 100644 index 00000000..99902a8c --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoViewModel+Content.swift @@ -0,0 +1,359 @@ +// +// DeviceInfoViewModel+Content.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 8/3/23. +// + +import Foundation +import DomainLayer +import Toolkit +import SwiftUI + +struct DeviceInfoButtonInfo: Equatable { + static func == (lhs: DeviceInfoButtonInfo, rhs: DeviceInfoButtonInfo) -> Bool { + lhs.icon == rhs.icon && + lhs.title == rhs.title + } + + let icon: AssetEnum? + let title: String? + var buttonStyle: WXMButtonStyle = WXMButtonStyle() +} + +extension DeviceInfoViewModel { + enum Field { + case name + case frequency + case reboot + case maintenance + case remove + case reconfigureWifi + case stationLocation + + static func heliumSections(for followState: UserDeviceFollowState?) -> [[Field]] { + if followState?.state == .owned { + return [[.name, .frequency, .reboot], + [.stationLocation]] + } + + return [[.name], [.stationLocation]] + } + + static func m5Sections(for followState: UserDeviceFollowState?) -> [[Field]] { + if followState?.state == .owned { + return [[.name], + [.stationLocation]] + } + + return [[.name], [.stationLocation]] + } + + static func bottomSections(for followState: UserDeviceFollowState?) -> [[Field]] { + guard followState?.state == .owned else { + return [] + } + + return [[.remove]] + } + + var warning: DeviceInfoRowView.Row.Warning? { + switch self { + case .remove: + return .desructive(LocalizableString.deviceInfoStationRemoveWarning.localized) + default: + return nil + } + } + + func titleFor(devie: DeviceDetails) -> String { + switch self { + case .name: + return LocalizableString.deviceInfoStationName.localized + case .frequency: + return LocalizableString.deviceInfoStationFrequency.localized + case .reboot: + return LocalizableString.deviceInfoStationReboot.localized + case .maintenance: +#warning("TODO: Format when info is ready") + return LocalizableString.deviceInfoStationMaintenance("").localized + case .remove: + return LocalizableString.deviceInfoStationRemove.localized + case .reconfigureWifi: + return LocalizableString.deviceInfoStationReconfigureWifi.localized + case .stationLocation: + return LocalizableString.deviceInfoStationLocation.localized + } + } + + func descriptionFor(device: DeviceDetails, for followState: UserDeviceFollowState?) -> String { + switch self { + case .name: + return device.displayName + case .frequency: + switch device.profile { + case .helium: + return LocalizableString.deviceInfoStationHeliumFrequencyDescription(DisplayedLinks.heliumRegionFrequencies.linkURL).localized + case .m5, .none: + return LocalizableString.deviceInfoStationM5FrequencyDescription.localized + } + case .reboot: + return LocalizableString.deviceInfoStationRebootDescription.localized + case .maintenance: + return LocalizableString.deviceInfoStationMaintenanceDescription(DisplayedLinks.heliumTroubleshooting.linkURL).localized + case .remove: + return LocalizableString.deviceInfoStationRemoveDescription(DisplayedLinks.documentationLink.linkURL).localized + case .reconfigureWifi: + return LocalizableString.deviceInfoStationReconfigureWifiDescription.localized + case .stationLocation: + if followState?.relation == .owned { + return LocalizableString.deviceInfoOwnedStationLocationDescription(device.address ?? "").localized + } + return LocalizableString.deviceInfoStationLocationDescription(device.address ?? "").localized + } + } + + func imageUrlFor(device: DeviceDetails, followState: UserDeviceFollowState?) -> URL? { + switch self { + case .name: + return nil + case .frequency: + return nil + case .reboot: + return nil + case .maintenance: + return nil + case .remove: + return nil + case .reconfigureWifi: + return nil + case .stationLocation: + guard let cellCenterLocation = device.cellCenter?.toCLLocationCoordinate2D() else { + return nil + } + + let isOwned = followState?.relation == .owned + let marker = isOwned ? device.location?.toCLLocationCoordinate2D() : nil + let polygon = device.cellPolygon?.map { $0.toCLLocationCoordinate2D() } + let options = MapBoxSnapshotUrlGenerator.Options(location: cellCenterLocation, + markerLocation: marker, + size: MapBoxConstants.snapshotSize, + zoomLevel: MapBoxConstants.snapshotZoom, + polygon: polygon) + let fetcher = MapBoxSnapshotUrlGenerator(options: options) + + return fetcher.getUrl() + } + } + + func buttonInfoFor(devie: DeviceDetails, followState: UserDeviceFollowState?) -> DeviceInfoButtonInfo? { + switch self { + case .name: + return .init(icon: nil, title: LocalizableString.deviceInfoButtonChangeName.localized) + case .frequency: + return .init(icon: nil, title: LocalizableString.deviceInfoButtonChangeFrequency.localized) + case .reboot: + return .init(icon: nil, title: LocalizableString.deviceInfoButtonReboot.localized) + case .maintenance: + return .init(icon: nil, title: LocalizableString.deviceInfoButtonEnterMaintenance.localized) + case .remove: + return .init(icon: nil, title: LocalizableString.deviceInfoButtonRemove.localized) + case .reconfigureWifi: + return .init(icon: nil, title: LocalizableString.deviceInfoButtonReconfigureWifi.localized) + case .stationLocation: + guard followState?.relation == .owned else { + return nil + } + return .init(icon: .editIcon, + title: LocalizableString.deviceinfoStationLocationButtonTitle.localized, + buttonStyle: .filled()) + } + } + } + + enum InfoField { + + case name + case devEUI + case hardwareVersion + case firmwareVersion + case lastHotspot + case lastRSSI + case serialNumber + case ATECC + case GPS + case wifiSignal + case batteryState + case claimedAt + + static var heliumFields: [InfoField] { + [.name, .claimedAt, .batteryState, .devEUI, .hardwareVersion, .firmwareVersion, .lastHotspot, .lastRSSI] + } + + static var m5Fields: [InfoField] { + [.name, .claimedAt, .batteryState, .serialNumber, .ATECC, .hardwareVersion, .firmwareVersion, .GPS, .wifiSignal] + } + + static func getShareText(for device: DeviceDetails, deviceInfo: NetworkDevicesInfoResponse?, mainVM: MainScreenViewModel, followState: UserDeviceFollowState?) -> String { + var fields: [InfoField] = [] + switch device.profile { + case .helium: + fields = heliumFields + case .m5, .none: + fields = m5Fields + } + + let textComps: [String] = fields.compactMap { field in + guard let value = field.value(for: device, deviceInfo: deviceInfo, mainVM: mainVM, followState: followState) else { + return nil + } + return "\(field.title): \(value)" + } + + return textComps.joined(separator: "\n") + } + + var title: String { + switch self { + case .name: + return LocalizableString.deviceInfoStationInfoName.localized + case .devEUI: + return LocalizableString.deviceInfoStationInfoDevEUI.localized + case .hardwareVersion: + return LocalizableString.deviceInfoStationInfoHardwareVersion.localized + case .firmwareVersion: + return LocalizableString.deviceInfoStationInfoFirmwareVersion.localized + case .lastHotspot: + return LocalizableString.deviceInfoStationInfoLastHotspot.localized + case .lastRSSI: + return LocalizableString.deviceInfoStationInfoLastRSSI.localized + case .serialNumber: + return LocalizableString.deviceInfoStationInfoSerialNumber.localized + case .ATECC: + return LocalizableString.deviceInfoStationInfoATECC.localized + case .GPS: + return LocalizableString.deviceInfoStationInfoGPS.localized + case .wifiSignal: + return LocalizableString.deviceInfoStationInfoWifiSignal.localized + case .batteryState: + return LocalizableString.deviceInfoStationInfoBattery.localized + case .claimedAt: + return LocalizableString.deviceInfoClaimDate.localized + } + } + + func value(for device: DeviceDetails, deviceInfo: NetworkDevicesInfoResponse? = nil, mainVM: MainScreenViewModel, followState: UserDeviceFollowState?) -> String? { + switch self { + case .name: + return device.name + case .devEUI: + return deviceInfo?.weatherStation?.devEui?.convertedDeviceIdentifier ?? device.convertedLabel + case .hardwareVersion: + return deviceInfo?.weatherStation?.hwVersion + case .firmwareVersion: + guard let current = device.firmware?.current else { + return nil + } + + if device.needsUpdate(mainVM: mainVM, followState: followState) { + return device.firmware?.versionUpdateString + } + return current + case .lastHotspot: + return deviceInfo?.weatherStation?.lastHs + case .lastRSSI: + return deviceInfo?.weatherStation?.lastTxRssi + case .serialNumber: + return deviceInfo?.gateway?.serialNumber?.convertedDeviceIdentifier ?? device.convertedLabel + case .ATECC: + return nil + case .GPS: + return deviceInfo?.gateway?.gpsSats + case .wifiSignal: + guard let rssi = deviceInfo?.gateway?.wifiRssi else { + return nil + } + return "\(rssi) \(UnitConstants.DBM)" + case .batteryState: + return deviceInfo?.weatherStation?.batState?.description + case .claimedAt: + return deviceInfo?.claimedAt?.localizedDateString() + } + } + + func warning(for device: DeviceDetails, deviceInfo: NetworkDevicesInfoResponse?) -> (String, VoidCallback)? { + switch self { + case .name: + return nil + case .devEUI: + return nil + case .hardwareVersion: + return nil + case .firmwareVersion: + return nil + case .lastHotspot: + return nil + case .lastRSSI: + return nil + case .serialNumber: + return nil + case .ATECC: + return nil + case .GPS: + return nil + case .wifiSignal: + return nil + case .batteryState: + guard let state = deviceInfo?.weatherStation?.batState else { + return nil + } + switch state { + case .low: + let callback = { + Logger.shared.trackEvent(.prompt, parameters: [.promptName: .lowBattery, + .promptType: .warnPromptType, + .action: .viewAction, + .itemId: .custom(device.id ?? "")]) + } + return (LocalizableString.deviceInfoLowBatteryWarningMarkdown.localized, callback) + case .ok: + return nil + } + case .claimedAt: + return nil + } + } + + func button(for device: DeviceDetails, mainVM: MainScreenViewModel, followState: UserDeviceFollowState?) -> DeviceInfoButtonInfo? { + switch self { + case .name: + return nil + case .devEUI: + return nil + case .hardwareVersion: + return nil + case .firmwareVersion: + let buttonInfo = DeviceInfoButtonInfo(icon: .updateFirmwareIcon, + title: LocalizableString.ClaimDevice.updateFirmwareButton.localized, + buttonStyle: .filled()) + return device.needsUpdate(mainVM: mainVM, followState: followState) ? buttonInfo : nil + case .lastHotspot: + return nil + case .lastRSSI: + return nil + case .serialNumber: + return nil + case .ATECC: + return nil + case .GPS: + return nil + case .wifiSignal: + return nil + case .batteryState: + return nil + case .claimedAt: + return nil + } + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoViewModel.swift b/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoViewModel.swift new file mode 100644 index 00000000..4f7575b4 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Device Info/DeviceInfoViewModel.swift @@ -0,0 +1,381 @@ +// +// DeviceInfoViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 7/3/23. +// + +import Foundation +import DomainLayer +import Combine +import Toolkit +import UIKit + +class DeviceInfoViewModel: ObservableObject { + + let offestObject = TrackableScrollOffsetObject() + let mainVM: MainScreenViewModel = .shared + var sections: [[DeviceInfoRowView.Row]] { + var fields: [[Field]] = [] + switch device.profile { + case .helium: + fields = Field.heliumSections(for: followState) + case .m5, .none: + fields = Field.m5Sections(for: followState) + } + + let rows: [[DeviceInfoRowView.Row]] = fields.map { $0.map { field in + DeviceInfoRowView.Row(title: field.titleFor(devie: device), + description: field.descriptionFor(device: device, for: followState).attributedMarkdown ?? "", + imageUrl: field.imageUrlFor(device: device, followState: followState), + buttonInfo: field.buttonInfoFor(devie: device, followState: followState), + warning: field.warning, + buttonAction: { [weak self] in self?.handleButtonTap(field: field) }) + } + } + + return rows + } + + var bottomSections: [[DeviceInfoRowView.Row]] { + let fields = Field.bottomSections(for: followState) + let rows: [[DeviceInfoRowView.Row]] = fields.map { $0.map { field in + DeviceInfoRowView.Row(title: field.titleFor(devie: device), + description: field.descriptionFor(device: device, for: followState).attributedMarkdown ?? "", + imageUrl: field.imageUrlFor(device: device, followState: followState), + buttonInfo: field.buttonInfoFor(devie: device, followState: followState), + warning: field.warning, + buttonAction: { [weak self] in self?.handleButtonTap(field: field) }) + } + } + + return rows + } + + var infoRows: [StationInfoView.Row] { + var fields: [InfoField] = [] + switch device.profile { + case .helium: + fields = InfoField.heliumFields + case .m5, .none: + fields = InfoField.m5Fields + } + + let infoRows: [StationInfoView.Row] = fields.compactMap { field in + guard let value = field.value(for: device, + deviceInfo: deviceInfo, + mainVM: mainVM, + followState: followState) else { + return nil + } + + let buttonInfo: DeviceInfoButtonInfo? = field.button(for: device, mainVM: mainVM, followState: followState) + let warning = field.warning(for: device, deviceInfo: deviceInfo) + + let row = StationInfoView.Row(tile: field.title, + subtitle: value, + warning: warning, + buttonIcon: buttonInfo?.icon, + buttonTitle: buttonInfo?.title, + buttonStyle: buttonInfo?.buttonStyle ?? .init()) { [weak self] in + self?.handleInfoFieldButtonTap(infoField: field) + } + return row + } + + return infoRows + } + + @Published var isLoading: Bool = true + @Published var isFailed: Bool = false + var failObj: FailSuccessStateObject? + @Published private(set) var device: DeviceDetails + @Published private(set) var deviceInfo: NetworkDevicesInfoResponse? + let followState: UserDeviceFollowState? + @Published var showRebootStation = false + var rebootStationViewModel: RebootStationViewModel { + RebootStationViewModel(device: device, useCase: deviceInfoUseCase) + } + @Published var showChangeFrequency = false + var changeFrequencyViewModel: ChangeFrequencyViewModel { + ChangeFrequencyViewModel(device: device, useCase: deviceInfoUseCase) + } + + @Published var showAccountConfirmation = false + var accountConfirmationViewModel: AccountConfirmationViewModel { + AccountConfirmationViewModel(title: LocalizableString.confirmPasswordTitle.localized, + descriptionMarkdown: LocalizableString.deviceInfoRemoveStationAccountConfirmationMarkdown.localized, + useCase: SwinjectHelper.shared.getContainerForSwinject().resolve(AuthUseCase.self)) { [weak self] isvalid in + if isvalid { + self?.showAccountConfirmation = false + if let serialNumber = self?.device.convertedLabel { + self?.disclaimDevice(serialNumber: serialNumber) + } + } + } + } + var contactSupportButtonTitle: String { + let isFollowed = followState?.relation == .followed + return isFollowed ? LocalizableString.deviceInfoFollowedContactSupportTitle.localized : LocalizableString.contactSupport.localized + } + + private let deviceInfoUseCase: DeviceInfoUseCase? + private var cancellable: Set = [] + + init(device: DeviceDetails, followState: UserDeviceFollowState?) { + self.device = device + self.followState = followState + self.deviceInfoUseCase = SwinjectHelper.shared.getContainerForSwinject().resolve(DeviceInfoUseCase.self) + refresh() + } + + func handleShareButtonTap() { + let text = InfoField.getShareText(for: device, deviceInfo: deviceInfo, mainVM: mainVM, followState: followState) + ShareHelper().showShareDialog(text: text) + + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .shareStationInfo, + .contentType: .stationInfo, + .itemId: .custom(device.id ?? "")]) + } + + func handleContactSupportButtonTap() { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .contactSupport, + .source: .deviceInfoSource]) + + HelperFunctions().openContactSupport(successFailureEnum: .weatherStations, + email: mainVM.userInfo?.email, + serialNumber: device.label, + addtionalInfo: InfoField.getShareText(for: device, deviceInfo: deviceInfo, mainVM: mainVM, followState: followState), + trackSelectContentEvent: false) + } + + func refresh(completion: VoidCallback? = nil) { + guard let deviceId = device.id else { + return + } + + do { + try deviceInfoUseCase?.getDeviceInfo(deviceId: deviceId).sink { [weak self] response in + completion?() + self?.isLoading = false + if let error = response.error { + self?.failObj = error.uiInfo.defaultFailObject(type: .deviceInfo) { + self?.isFailed = false + self?.isLoading = true + self?.refresh() + } + self?.isFailed = true + } + self?.deviceInfo = response.value + }.store(in: &self.cancellable) + } catch { + isLoading = false + print(error) + completion?() + } + } +} + +extension DeviceInfoViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + hasher.combine(device.id) + } +} + +extension DeviceInfoViewModel: SelectStationLocationViewModelDelegate { + func locationUpdated(with device: DeviceDetails) { + self.device = device + } +} + +private extension DeviceInfoViewModel { + func handleButtonTap(field: Field) { + switch field { + case .name: + showChangeNameAlert() + case .frequency: + showChangeFrequency = true + case .reboot: + showRebootStation = true + case .maintenance: + break + case .remove: + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .removeDevice, + .itemId: .custom(device.id ?? "")]) + showAccountConfirmation = true + case .reconfigureWifi: + break + case .stationLocation: + let viewModel = ViewModelsFactory.getSelectLocationViewModel(device: device, followState: followState, delegate: self) + Router.shared.navigateTo(.selectStationLocation(viewModel)) + } + } + + func handleInfoFieldButtonTap(infoField: InfoField) { + switch infoField { + case .name: + break + case .devEUI: + break + case .hardwareVersion: + break + case .firmwareVersion: + mainVM.showFirmwareUpdate(device: device) + case .lastHotspot: + break + case .lastRSSI: + break + case .serialNumber: + break + case .ATECC: + break + case .GPS: + break + case .wifiSignal: + break + case .batteryState: + break + case .claimedAt: + break + } + } + + func showChangeNameAlert() { + let okAction: AlertHelper.AlertObject.Action = (LocalizableString.save.localized, { [weak self] text in + guard let self, + let deviceId = self.device.id, + let text = text?.trimWhiteSpaces(), + !text.isEmpty else { + return + } + + self.setFriendlyName(deviceId: deviceId, name: text.trimWhiteSpaces()) + + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .changeStationNameResult, + .contentType: .changeStationName, + .action: .edit]) + }) + + let clearAction: AlertHelper.AlertObject.Action = (LocalizableString.clear.localized, { [weak self] _ in + guard let self, + let deviceId = self.device.id else { + return + } + + self.deleteFriendlyName(deviceId: deviceId) + + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .changeStationNameResult, + .contentType: .changeStationName, + .action: .clear]) + }) + + let alertObject = AlertHelper.AlertObject(title: LocalizableString.deviceInfoEditNameAlertTitle.localized, + message: LocalizableString.deviceInfoEditNameAlertMessage.localized, + textFieldPlaceholder: Field.name.titleFor(devie: device), + textFieldValue: device.displayName, + textFieldDelegate: AlertTexFieldDelegate(), + cancelAction: { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .changeStationNameResult, + .contentType: .changeStationName, + .action: .cancel]) + }, + okAction: okAction, + secondaryActions: [clearAction]) + AlertHelper().showAlert(alertObject) + // Since we present a system dialog, + // we track the screen here. + Logger.shared.trackScreen(.changeStationName, parameters: [.itemId: .custom(device.id ?? "")]) + } + + func setFriendlyName(deviceId: String, name: String) { + LoaderView.shared.show() + do { + try deviceInfoUseCase?.setFriendlyName(deviceId: deviceId, name: name).sink { [weak self] response in + LoaderView.shared.dismiss { + self?.trackChangeNameViewContent(isSuccessful: response.error == nil) + + if let error = response.error { + self?.showErrorToast(error: error) { + self?.setFriendlyName(deviceId: deviceId, name: name) + } + return + } + self?.device.friendlyName = name + } + }.store(in: &self.cancellable) + } catch { + LoaderView.shared.dismiss() + } + } + + func deleteFriendlyName(deviceId: String) { + LoaderView.shared.show() + do { + try deviceInfoUseCase?.deleteFriendlyName(deviceId: deviceId).sink { [weak self] response in + LoaderView.shared.dismiss { + self?.trackChangeNameViewContent(isSuccessful: response.error == nil) + + if let error = response.error { + self?.showErrorToast(error: error) { + self?.deleteFriendlyName(deviceId: deviceId) + } + return + } + self?.device.friendlyName = nil + } + }.store(in: &self.cancellable) + } catch { + LoaderView.shared.dismiss() + } + } + + func disclaimDevice(serialNumber: String) { + LoaderView.shared.show() + do { + try deviceInfoUseCase?.disclaimDevice(serialNumber: serialNumber).sink { [weak self] response in + LoaderView.shared.dismiss { + if let error = response.error { + self?.showErrorToast(error: error) { + self?.disclaimDevice(serialNumber: serialNumber) + } + return + } + Router.shared.popToRoot() + } + }.store(in: &cancellable) + } catch { + LoaderView.shared.dismiss() + } + } + + func showErrorToast(error: NetworkErrorResponse, retry: @escaping VoidCallback) { + guard let message = error.uiInfo.description?.attributedMarkdown else { + return + } + + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .failure, + .itemId: .custom(error.backendError?.code ?? "")]) + Toast.shared.show(text: message, retryAction: retry) + } + + func trackChangeNameViewContent(isSuccessful: Bool) { + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .changeStationNameResult, + .contentId: .changeStationNameResultContentId, + .success: .custom(isSuccessful ? "1" : "0")]) + } +} + +private extension DeviceInfoViewModel { + class AlertTexFieldDelegate: NSObject, UITextFieldDelegate { + var textLimit: Int? = 64 + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + guard let textLimit else { + return true + } + + let newText = (textField.text as? NSString)?.replacingCharacters(in: range, with: string) ?? "" + return newText.count <= textLimit + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Device Info/StationInfoView.swift b/PresentationLayer/UI Components/Screens/Device Info/StationInfoView.swift new file mode 100644 index 00000000..952eb877 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Device Info/StationInfoView.swift @@ -0,0 +1,134 @@ +// +// StationIfoView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 7/3/23. +// + +import SwiftUI +import Toolkit + +struct StationInfoView: View { + let rows: [Row] + var contactSupportTitle: String = LocalizableString.contactSupport.localized + let shareAction: () -> Void + let contactSupportAction: () -> Void + + var body: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + HStack { + Text(LocalizableString.deviceInfoStationInformation.localized) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + + Spacer() + + Button(action: shareAction) { + HStack(spacing: 0.0) { + Image(asset: .shareIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .primary)) + + Text(LocalizableString.share.localized) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .primary)) + } + } + .buttonStyle(.plain) + + } + + ForEach(rows, id: \.tile) { row in + VStack(spacing: CGFloat(.mediumSpacing)) { + rowView(for: row) + if row != rows.last { + WXMDivider() + } + } + } + + Button(action: contactSupportAction) { + Text(contactSupportTitle) + } + .buttonStyle(WXMButtonStyle()) + } + } +} + +extension StationInfoView { + struct Row: Equatable { + static func == (lhs: StationInfoView.Row, rhs: StationInfoView.Row) -> Bool { + lhs.tile == rhs.tile && + lhs.subtitle == rhs.subtitle && + lhs.warning?.title == rhs.warning?.title && + lhs.buttonIcon == rhs.buttonIcon && + lhs.buttonTitle == rhs.buttonTitle + } + + let tile: String + let subtitle: String + var warning: (title: String, appearAction: VoidCallback?)? + let buttonIcon: AssetEnum? + let buttonTitle: String? + let buttonStyle: WXMButtonStyle + var buttonAction: (() -> Void)? + } +} + +private extension StationInfoView { + @ViewBuilder + func rowView(for row: Row) -> some View { + VStack(spacing: CGFloat(.smallSpacing)) { + HStack { + VStack(alignment: .leading, spacing: 0.0) { + Text(row.tile) + .font(.system(size: CGFloat(.caption))) + Text(row.subtitle) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + } + Spacer() + } + + if let warning = row.warning { + CardWarningView(message: warning.title, closeAction: nil) { + EmptyView() + } + .onAppear { + warning.appearAction?() + } + } + + if let icon = row.buttonIcon, + let title = row.buttonTitle, + let action = row.buttonAction { + Button(action: action) { + HStack(spacing: CGFloat(.smallSpacing)) { + Image(asset: icon) + .renderingMode(.template) + Text(title) + } + .padding() + } + .buttonStyle(row.buttonStyle) + } + } + } +} + +struct StationInfoView_Previews: PreviewProvider { + static var previews: some View { + StationInfoView(rows: [StationInfoView.Row(tile: "title", + subtitle: "subtile", + warning: (LocalizableString.deviceInfoLowBatteryWarningMarkdown.localized, nil), + buttonIcon: .updateFirmwareIcon, + buttonTitle: "Update firmware", + buttonStyle: .filled()) {}, + StationInfoView.Row(tile: "title1", + subtitle: "subtile", + buttonIcon: nil, + buttonTitle: nil, + buttonStyle: .filled()) {}], + shareAction: {}, + contactSupportAction: {}) + .padding() + } +} diff --git a/PresentationLayer/UI Components/Screens/Explorer Stations List/ExplorerStationsListView.swift b/PresentationLayer/UI Components/Screens/Explorer Stations List/ExplorerStationsListView.swift new file mode 100644 index 00000000..79af6ad0 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Explorer Stations List/ExplorerStationsListView.swift @@ -0,0 +1,68 @@ +// +// ExplorerStationsListView.swift +// PresentationLayer +// +// Created by Lampros Zouloumis on 22/8/22. +// + +import SwiftUI +import Toolkit + +struct ExplorerStationsListView: View { + @StateObject var viewModel: ExplorerStationsListViewModel + @EnvironmentObject var navigationObject: NavigationObject + + var body: some View { + ZStack { + Color(colorEnum: .layer2) + .ignoresSafeArea() + + VStack { + TrackableScrollView { + VStack { + ForEach(viewModel.devices) { device in + WeatherStationCard(device: device, + followState: viewModel.getFollowState(for: device), + followAction: { viewModel.followButtonTapped(device: device) }) + .onTapGesture { + viewModel.navigateToDeviceDetails(device) + } + } + } + .padding(CGFloat(.defaultSpacing)) + } + .spinningLoader(show: $viewModel.isLoadingDeviceList, hideContent: true) + .fail(show: $viewModel.isDeviceListFailVisible, obj: viewModel.deviceListFailObject) + .wxmAlert(show: $viewModel.showLoginAlert) { + WXMAlertView(show: $viewModel.showLoginAlert, + configuration: viewModel.alertConfiguration!) { + Button { + viewModel.signupButtonTapped() + } label: { + HStack { + Text(LocalizableString.dontHaveAccount.localized) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + + Text(LocalizableString.signUp.localized.uppercased()) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .primary)) + } + } + } + } + } + } + .navigationBarHidden(true) + .onAppear { + navigationObject.title = LocalizableString.NetStats.weatherStations.localized + navigationObject.navigationBarColor = Color(colorEnum: .layer2) + + Logger.shared.trackScreen(.explorerCellScreen, + parameters: [.itemId: .custom(viewModel.cellIndex)]) + } + .onChange(of: viewModel.address) { newValue in + navigationObject.subtitle = newValue ?? "" + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Explorer Stations List/ExplorerStationsListViewModel.swift b/PresentationLayer/UI Components/Screens/Explorer Stations List/ExplorerStationsListViewModel.swift new file mode 100644 index 00000000..e3b833c9 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Explorer Stations List/ExplorerStationsListViewModel.swift @@ -0,0 +1,245 @@ +// +// ExplorerStationsListViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 23/6/23. +// + +import Combine +import DomainLayer +import CoreLocation +import Toolkit + +class ExplorerStationsListViewModel: ObservableObject { + + @Published var isLoadingDeviceList: Bool = false + @Published var isDeviceListFailVisible: Bool = false + @Published var showLoginAlert: Bool = false + @Published var devices = [DeviceDetails]() + @Published var address: String? + @Published private(set) var userDeviceFolowStates: [UserDeviceFollowState] = [] + var deviceListFailObject: FailSuccessStateObject? + var alertConfiguration: WXMAlertConfiguration? + + let cellIndex: String + private let useCase: ExplorerUseCase? + private let cellCenter: CLLocationCoordinate2D + private var cancellableSet: Set = .init() + + init(useCase: ExplorerUseCase?, cellIndex: String, cellCenter: CLLocationCoordinate2D) { + self.useCase = useCase + self.cellIndex = cellIndex + self.cellCenter = cellCenter + fetchDeviceList() + + useCase?.userDevicesListChangedPublisher.sink { [weak self] _ in + self?.refreshFollowStates() + }.store(in: &cancellableSet) + } + + func navigateToDeviceDetails(_ device: DeviceDetails) { + guard let cellIndex = device.cellIndex, + let deviceId = device.id else { + return + } + + Router.shared.navigateTo(.stationDetails(ViewModelsFactory.getStationDetailsViewModel(deviceId: deviceId, cellIndex: cellIndex, cellCenter: cellCenter))) + } + + func signupButtonTapped() { + showLoginAlert = false + Router.shared.navigateTo(.register(ViewModelsFactory.getRegisterViewModel())) + } + + func followButtonTapped(device: DeviceDetails) { + guard let deviceId = device.id else { + return + } + + guard MainScreenViewModel.shared.isUserLoggedIn else { + showLogin(device: device) + return + } + + let followState = userDeviceFolowStates.first(where: { $0.deviceId == deviceId}) + if followState?.relation == .followed { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .explorerDevicesListFollow, + .contentType: .unfollow]) + + performUnfollow(device: device) + } else { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .explorerDevicesListFollow, + .contentType: .follow]) + + performFollow(device: device) + } + } + + func performFollow(device: DeviceDetails) { + guard let deviceId = device.id else { + return + } + + let followAction = { [weak self] in + guard let self else { + return + } + LoaderView.shared.show() + Task { + guard let result = try await self.useCase?.followStation(deviceId: deviceId) else { + return + } + + DispatchQueue.main.async { + LoaderView.shared.dismiss { + self.handleFollowResult(result) + } + } + } + } + + if device.isActive == false { + let title = LocalizableString.followAlertTitle.localized + let description = LocalizableString.followAlertDescription(device.name).localized + let okAction: AlertHelper.AlertObject.Action = (LocalizableString.confirm.localized, { _ in followAction() }) + let obj = AlertHelper.AlertObject(title: title, + message: description, + okAction: okAction) + AlertHelper().showAlert(obj) + + } else { + followAction() + } + } + + func performUnfollow(device: DeviceDetails) { + guard let deviceId = device.id else { + return + } + + let okAction: AlertHelper.AlertObject.Action = (LocalizableString.confirm.localized, { _ in + LoaderView.shared.show() + Task { [weak self] in + guard let self, + let result = try await useCase?.unfollowStation(deviceId: deviceId) else { + return + } + + DispatchQueue.main.async { + LoaderView.shared.dismiss { + self.handleFollowResult(result) + } + } + } + }) + + let title = LocalizableString.unfollowAlertTitle.localized + let description = LocalizableString.unfollowAlertDescription(device.name).localized + let obj = AlertHelper.AlertObject(title: title, + message: description, + okAction: okAction) + AlertHelper().showAlert(obj) + } + + func handleFollowResult(_ result: Result) { + switch result { + case .success: + self.refreshFollowStates() + case .failure(let error): + let info = error.uiInfo + DispatchQueue.main.async { + Toast.shared.show(text: info.description?.attributedMarkdown ?? "") + } + } + } + + func getFollowState(for device: DeviceDetails) -> UserDeviceFollowState? { + userDeviceFolowStates.first(where: { $0.deviceId == device.id }) + } + + func showLogin(device: DeviceDetails) { + alertConfiguration = generateLoginAlertConfiguration(device: device) + showLoginAlert = true + } +} + +private extension ExplorerStationsListViewModel { + func fetchDeviceList() { + isDeviceListFailVisible = false + isLoadingDeviceList = true + useCase?.getPublicDevicesOfHexIndex(hexIndex: cellIndex, hexCoordinates: cellCenter) { [weak self] result in + guard let self else { + return + } + + DispatchQueue.main.async { + switch result { + case let .success(devices): + self.devices = devices.sortedByCriteria(criterias: [ { $0.lastActiveAt.stringToDate() > $1.lastActiveAt.stringToDate() }, { $0.name > $1.name }]) + self.address = devices.first?.address ?? "" + self.refreshFollowStates() + case let .failure(error): + print(error) + self.updateDeviceListFailObj(error: error) { + self.isDeviceListFailVisible = false + self.fetchDeviceList() + } + } + self.isLoadingDeviceList = false + } + } + } + + func generateLoginAlertConfiguration(device: DeviceDetails) -> WXMAlertConfiguration { + let conf = WXMAlertConfiguration(title: LocalizableString.favoritesloginAlertTitle.localized, + text: LocalizableString.favoritesloginAlertText(device.name).localized.attributedMarkdown ?? "", + primaryButtons: [.init(title: LocalizableString.signIn.localized, action: { Router.shared.navigateTo(.signIn(ViewModelsFactory.getSignInViewModel())) })]) + return conf + } +} + +private extension ExplorerStationsListViewModel { + func updateDeviceListFailObj(error: PublicHexError, retryAction: @escaping VoidCallback) { + var description: String? + switch error { + case .infrastructure, .serialization: + description = LocalizableString.emptyGenericDescription.localized + case .networkRelated(let networkErrorResponse): + description = networkErrorResponse?.uiInfo.description + } + + let obj = FailSuccessStateObject(type: .explorerDeviceList, + title: LocalizableString.emptyGenericTitle.localized, + subtitle: description?.attributedMarkdown, + cancelTitle: nil, + retryTitle: LocalizableString.retry.localized, + contactSupportAction: { + HelperFunctions().openContactSupport(successFailureEnum: .explorerDeviceList, + email: nil, + serialNumber: nil, + trackSelectContentEvent: true) + + }, cancelAction: nil, retryAction: retryAction) + + deviceListFailObject = obj + isDeviceListFailVisible = true + } + + func refreshFollowStates() { + Task { @MainActor [weak self] in + guard let self else { return } + self.userDeviceFolowStates = await self.devices.asyncCompactMap { device in + guard let deviceId = device.id else { + return nil + } + return try? await self.useCase?.getDeviceFollowState(deviceId: deviceId).get() + } + } + } +} + +extension ExplorerStationsListViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + hasher.combine(cellIndex) + } +} diff --git a/PresentationLayer/UI Components/Screens/Explorer/ExplorerView.swift b/PresentationLayer/UI Components/Screens/Explorer/ExplorerView.swift new file mode 100644 index 00000000..9827dd6f --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Explorer/ExplorerView.swift @@ -0,0 +1,130 @@ +// +// ExplorerView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 12/5/22. +// + +import SwiftUI +import Toolkit + +struct ExplorerView: View { + @StateObject var viewModel: ExplorerViewModel + + let settingsDotsDimensions: CGFloat = 60 + + var body: some View { + ZStack { + MapBoxMapView() + .environmentObject(viewModel) + .edgesIgnoringSafeArea(.all) + explorerContent + .zIndex(1) + + if viewModel.showTopOfMapItems { + SearchView(shouldShowSettingsButton: true, + viewModel: viewModel.searchViewModel) + .transition(AnyTransition.opacity.animation(.easeIn)) + .zIndex(2) + } + + } + .navigationTitle(Text(LocalizableString.explorerViewTitle.localized)) + .navigationBarHidden(true) + .onAppear { + Logger.shared.trackScreen(.explorerLanding) + viewModel.showTopOfMapItems = true + } + .shimmerLoader(show: $viewModel.isLoading) + } + + var explorerContent: some View { + VStack(spacing: CGFloat(.defaultSidePadding)) { + if viewModel.showTopOfMapItems { + Spacer() + + VStack(spacing: CGFloat(.defaultSpacing)) { + HStack { + Spacer() + userLocationButton + } + + HStack { + Spacer() + netStatsButton + } + } + .transition(AnyTransition.move(edge: .trailing)) + .animation(.easeIn) + + signInContainer + } + } + .padding(CGFloat(.defaultSidePadding)) + } + + var signInContainer: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + signInButton + signUpTextButton + } + .WXMCardStyle() + .padding(.bottom, CGFloat(.mediumSidePadding)) + .transition(.move(edge: .bottom)) + .animation(.easeIn) + } + + var signInButton: some View { + Button { + Router.shared.navigateTo(.signIn(ViewModelsFactory.getSignInViewModel())) + } label: { + Text(LocalizableString.signIn.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + } + + var signUpTextButton: some View { + Button { + Router.shared.navigateTo(.register(ViewModelsFactory.getRegisterViewModel())) + } label: { + HStack { + Text(LocalizableString.dontHaveAccount.localized) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + + Text(LocalizableString.signUp.localized.uppercased()) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .primary)) + } + } + } + + @ViewBuilder + var netStatsButton: some View { + Button { + Router.shared.navigateTo(.netStats(ViewModelsFactory.getNetworkStatsViewModel())) + } label: { + Image(asset: .networkStatsIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .netStatsFabTextColor)) + .padding(CGFloat(.smallSidePadding)) + .background(Color(colorEnum: .netStatsFabColor)) + .cornerRadius(CGFloat(.cardCornerRadius)) + } + .wxmShadow() + } + + @ViewBuilder + var userLocationButton: some View { + Button { + viewModel.userLocationButtonTapped() + } label: { + Image(asset: .detectLocation) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + .padding(CGFloat(.smallSidePadding)) + .background(Circle().foregroundColor(Color(colorEnum: .top))) + } + .wxmShadow() + } +} diff --git a/PresentationLayer/UI Components/Screens/Explorer/ExplorerViewModel.swift b/PresentationLayer/UI Components/Screens/Explorer/ExplorerViewModel.swift new file mode 100644 index 00000000..25850c4b --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Explorer/ExplorerViewModel.swift @@ -0,0 +1,151 @@ +// +// ExplorerViewModel.swift +// PresentationLayer +// +// Created by Hristos Condrea on 17/5/22. +// + +import Combine +import CoreLocation +import DomainLayer +import Toolkit + +public final class ExplorerViewModel: ObservableObject { + private final let explorerUseCase: ExplorerUseCase + /// Keep a ref in map controller in order to persist and show + /// the same instance between rerenders + var mapController: MapViewController? + + public init(explorerUseCase: ExplorerUseCase) { + self.explorerUseCase = explorerUseCase + } + + @Published var showTopOfMapItems: Bool = false + + @Published var isLoading: Bool = false + @Published var isSearchActive: Bool = false + + @Published var locationToSnap: MapBoxMapView.SnapLocation? + @Published var showUserLocation: Bool = false + private var isInitialSnapped: Bool = false + + private(set) lazy var searchViewModel = { + let vm = ViewModelsFactory.getNetworkSearchViewModel() + vm.delegate = self + return vm + }() + + func fetchExplorerData(completion: @escaping (ExplorerData?) -> Void) { + isLoading = true + explorerUseCase.getPublicHexes { result in + self.isLoading = false + switch result { + case let .success(explorerData): + completion(explorerData) + case let .failure(error): + print(error) + switch error { + case .infrastructure, .serialization: + if let message = LocalizableString.Error.genericMessage.localized.attributedMarkdown { + Toast.shared.show(text: message) + } + case .networkRelated(let neworkError): + if let message = neworkError?.uiInfo.description?.attributedMarkdown { + Toast.shared.show(text: message) + } + } + completion(nil) + } + } + } + + func routeToDeviceListFor(_ hexIndex: String, _ coordinates: CLLocationCoordinate2D?) { + if let coordinates { + let route = Route.explorerList(ViewModelsFactory.getExplorerStationsListViewModel(cellIndex: hexIndex, cellCenter: coordinates)) + Router.shared.navigateTo(route) + } + } + + func userLocationButtonTapped() { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .myLocation]) + handleUserLocationTap() + } + + func snapToInitialLocation() { + guard !isInitialSnapped else { + return + } + isInitialSnapped = true + + let status = explorerUseCase.userLocationAuthorizationStatus + switch status { + case .authorized: + Task { + await snapToUserLocation(zoomEnabled: false) + } + default: + if let suggestedLocation = explorerUseCase.getSuggestedDeviceLocation() { + locationToSnap = MapBoxMapView.SnapLocation(coordinates: suggestedLocation, zoomLevel: nil) + } + } + } +} + +private extension ExplorerViewModel { + + func handleUserLocationTap() { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .myLocation]) + Task { + await snapToUserLocation() + } + } + + func navigateToDeviceDetails(deviceId: String, cellIndex: String, cellCenter: CLLocationCoordinate2D?) { + let route = Route.stationDetails(ViewModelsFactory.getStationDetailsViewModel(deviceId: deviceId, cellIndex: cellIndex, cellCenter: cellCenter)) + Router.shared.navigateTo(route) + } + + func snapToUserLocation(zoomEnabled: Bool = true) async { + let result = await explorerUseCase.getUserLocation() + DispatchQueue.main.async { + switch result { + case .success(let coordinates): + let zoomLevel: CGFloat? = zoomEnabled ? MapBoxMapView.SnapLocation.DEFAULT_SNAP_ZOOM_LEVEL : nil + self.locationToSnap = MapBoxMapView.SnapLocation(coordinates: coordinates, zoomLevel: zoomLevel) + self.showUserLocation = true + case .failure(let error): + print(error) + switch error { + case .locationNotFound: + Toast.shared.show(text: error.description.attributedMarkdown ?? "") + case .permissionDenied: + let title = LocalizableString.ClaimDevice.confirmLocationNoAccessToServicesTitle.localized + let message = LocalizableString.ClaimDevice.confirmLocationNoAccessToServicesText.localized + let alertObj = AlertHelper.AlertObject.getNavigateToSettingsAlert(title: title, + message: message) + AlertHelper().showAlert(alertObj) + } + } + } + } +} + +extension ExplorerViewModel: ExplorerSearchViewModelDelegate { + func settingsButtonTapped() { + Router.shared.navigateTo(.settings(ViewModelsFactory.getSettingsViewModel(userId: ""))) + } + + func rowTapped(coordinates: CLLocationCoordinate2D, deviceId: String?, cellIndex: String?) { + locationToSnap = MapBoxMapView.SnapLocation(coordinates: coordinates) + + if let deviceId, let cellIndex { + navigateToDeviceDetails(deviceId: deviceId, cellIndex: cellIndex, cellCenter: coordinates) + } + } + + func searchWillBecomeActive(_ active: Bool) { + DispatchQueue.main.async { + self.isSearchActive = active + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Explorer/Search/ExplorerSearchViewModel.swift b/PresentationLayer/UI Components/Screens/Explorer/Search/ExplorerSearchViewModel.swift new file mode 100644 index 00000000..48c4fee8 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Explorer/Search/ExplorerSearchViewModel.swift @@ -0,0 +1,232 @@ +// +// ExplorerSearchViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 21/6/23. +// + +import Combine +import DomainLayer +import CoreLocation +import Toolkit +import SwiftUI + +protocol ExplorerSearchViewModelDelegate: AnyObject { + func rowTapped(coordinates: CLLocationCoordinate2D, deviceId: String?, cellIndex: String?) + func searchWillBecomeActive(_ active: Bool) + func settingsButtonTapped() +} + +class ExplorerSearchViewModel: ObservableObject { + + @Published var isSearchActive: Bool = false { + didSet { + delegate?.searchWillBecomeActive(isSearchActive) + } + } + @Published var isLoading = false + @Published var showNoResults: Bool = false + @Published var searchResults: [SearchView.Row] = [] + @Published var isShowingRecent: Bool = true + /// Will be assigned from the view. We do not assign directly this proprety as a binding in theTextfield + @Published private var searchTerm: String = "" { + didSet { + handleSearchTermChanges() + } + } + + weak var delegate: ExplorerSearchViewModelDelegate? + private let useCase: NetworkUseCase? + private var searchCancellable: AnyCancellable? + private let searchTermLimit = 2 + private var cancellables = Set() + + init(useCase: NetworkUseCase? = nil) { + self.useCase = useCase + + $searchTerm + .debounce(for: 1.0, scheduler: DispatchQueue.main) + .sink { [weak self] newValue in + self?.performSearch(searchTerm: newValue) + } + .store(in: &cancellables) + } + + func handleTapOnResult(_ result: SearchView.Row) { + guard let lat = result.networkModel?.lat, let lon = result.networkModel?.lon else { + return + } + + isSearchActive = false + delegate?.rowTapped(coordinates: CLLocationCoordinate2D(latitude: lat, longitude: lon), + deviceId: result.networkModel?.deviceId, + cellIndex: result.networkModel?.cellIndex) + + if let model = result.networkModel { + saveRecent(model: model) + } + + searchTerm.removeAll() + + let isStation = result.networkModel?.deviceId != nil + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .networkSearch, + .itemId: isShowingRecent ? .recent : .search, + .itemListId: isStation ? .station : .location]) + } + + func handleSettingsButtonTap() { + Logger.shared.trackEvent(.selectContent, parameters: [.actionName: .explorerSettings]) + delegate?.settingsButtonTapped() + } + + func handleSubmitButtonTap() { + guard searchTerm.count < searchTermLimit else { + return + } + + let toastText = LocalizableString.Search.termLimitMessage(searchTermLimit).localized.attributedMarkdown + Toast.shared.show(text: toastText ?? "", type: .info) + } + + func updateSearchTerm(_ term: String) { + let trimmed = term.trimWhiteSpaces() + guard searchTerm != trimmed else { + return + } + searchTerm = trimmed + } +} + +private extension ExplorerSearchViewModel { + + /// Handles search term changes. + /// 1. Cancels the pending requst + /// 2. If search term lenght is less than `searchTermLimit` we do nothing + /// 3. If search term is empty we show recent results + /// 4. Otherwise we schedule a new search request (ln 46) + func handleSearchTermChanges() { + searchCancellable?.cancel() + isLoading = false + + guard searchTerm.count >= searchTermLimit else { + if searchTerm.isEmpty { + + loadRecent() + isShowingRecent = true + } + return + } + + isLoading = true + } + + func performSearch(searchTerm: String) { + searchCancellable?.cancel() + isLoading = false + + guard searchTerm.count >= searchTermLimit else { + if searchTerm.isEmpty { + loadRecent() + isShowingRecent = true + } + return + } + + isLoading = true + + do { + searchCancellable = try useCase?.search(term: searchTerm).sink { [weak self] response in + self?.isLoading = false + if let error = response.error { + let info = error.uiInfo + if let message = info.description?.attributedMarkdown { + Toast.shared.show(text: message) + } + } + + self?.updateSearchResults(response: response.value) + self?.isShowingRecent = false + } + } catch { + + } + } + + func updateSearchResults(response: NetworkSearchResponse?) { + let devices: [any NetworkSearchItem] = response?.devices ?? [] + let addresses: [any NetworkSearchItem] = response?.addresses ?? [] + let items: [any NetworkSearchItem] = [devices, addresses].flatMap { $0 } + + updateSearchResults(data: items) + } + + func updateSearchResults(data: [any NetworkSearchItem]) { + self.searchResults = data.compactMap { item in + if let device = item as? NetworkSearchDevice { + guard let icon = device.connectivity?.icon, + let name = device.name?.withHighlightedPart(text: searchTerm, color: Color(colorEnum: .text)) else { + return nil + } + + return SearchView.Row(icon: icon, + title: name, + subtitle: nil, + networkModel: device) + + } + + if let address = item as? NetworkSearchAddress { + guard let name = address.name?.withHighlightedPart(text: searchTerm, color: Color(colorEnum: .text)), + let place = address.place else { + return nil + } + + return SearchView.Row(fontIcon: .locationDot, + title: name, + subtitle: place, + networkModel: address) + } + + return nil + } + self.showNoResults = searchResults.isEmpty + } + + func loadRecent() { + let recent = useCase?.getSearchRecent() + updateSearchResults(data: recent ?? []) + } + + func saveRecent(model: NetworkSearchModel) { + if let device = model as? NetworkSearchDevice { + useCase?.insertSearchRecentDevice(device: device) + return + } + + if let address = model as? NetworkSearchAddress { + useCase?.insertSearchRecentAddress(address: address) + return + } + } +} + +extension ExplorerSearchViewModel { + static var mock: ExplorerSearchViewModel { + let viewModel = ExplorerSearchViewModel() + viewModel.searchTerm = "Search text" + viewModel.isSearchActive = true + viewModel.searchResults = [.init(icon: .wifi, + title: "list item", + subtitle: nil, + networkModel: nil), + .init(icon: .helium, + title: "List item 1", + subtitle: "subtitle", + networkModel: nil), + .init(fontIcon: .locationDot, + title: "List item 2", + subtitle: "subtitle", + networkModel: nil)] + return viewModel + } +} diff --git a/PresentationLayer/UI Components/Screens/Explorer/Search/SearchView+Content.swift b/PresentationLayer/UI Components/Screens/Explorer/Search/SearchView+Content.swift new file mode 100644 index 00000000..ebebfa25 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Explorer/Search/SearchView+Content.swift @@ -0,0 +1,209 @@ +// +// SearchView+Content.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 21/6/23. +// + +import SwiftUI +import CoreLocation +import Toolkit + +extension SearchView { + struct Row: Identifiable { + var id: String { + "\(icon?.rawValue ?? "")-\(fontIcon?.rawValue ?? "")-\(title)-\(subtitle ?? "")-\(String(describing: networkModel))" + } + var icon: AssetEnum? + var fontIcon: FontIcon? + let title: AttributedString + let subtitle: String? + let networkModel: NetworkSearchModel? + } + + @ViewBuilder + var nonActiveView: some View { + VStack { + HStack(spacing: CGFloat(.mediumSpacing)) { + Image(asset: .xmSearchLogo) + + TextField("", + text: $term, + prompt: Text(LocalizableString.Search.fieldPlaceholder.localized).foregroundColor(Color(colorEnum: .darkGrey))) + .font(.system(size: CGFloat(.mediumFontSize))) + .tint(Color(colorEnum: .text)) + .foregroundColor(Color(colorEnum: .text)) + .focused($noActiveTextfieldIsFocused) + .onChange(of: noActiveTextfieldIsFocused) { newValue in + // This hacky way is to avoid interaction with the outer textfield + noActiveTextfieldIsFocused = false + if newValue { + viewModel.isSearchActive = true + } + } + + Spacer() + + if shouldShowSettingsButton { + Button { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .explorerPopUp]) + showSettingsPopOver = true + } label: { + Text(FontIcon.threeDots.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.smallTitleFontSize))) + .foregroundColor(Color(colorEnum: .darkGrey)) + .padding(.horizontal, CGFloat(.smallSidePadding)) + } + .background(Color(colorEnum: .top)) + .wxmPopOver(show: $showSettingsPopOver) { + VStack { + Button { + showSettingsPopOver = false + viewModel.handleSettingsButtonTap() + } label: { + Text(LocalizableString.settings.localized) + .font(.system(size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + } + .padding() + .background(Color(colorEnum: .top).scaleEffect(2.0).ignoresSafeArea()) + } + } + } + .padding(.vertical, CGFloat(.mediumSidePadding)) + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .background(Capsule().foregroundColor(Color(colorEnum: .top))) + .compositingGroup() + .wxmShadow() + .padding(CGFloat(.defaultSidePadding)) + .animation(.easeIn(duration: 0.2), + value: term) + + Spacer() + } + } + + @ViewBuilder + var activeView: some View { + ZStack { + Color(colorEnum: .top) + .ignoresSafeArea() + + VStack(spacing: 0.0) { + HStack(spacing: CGFloat(.defaultSpacing)) { + Button { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .explorerSearch]) + isFocused = false + viewModel.isSearchActive = false + } label: { + Image(asset: .backArrow) + .renderingMode(.template) + .tint(Color(colorEnum: .text)) + } + + TextField("", + text: $term, prompt: Text(LocalizableString.Search.fieldPlaceholder.localized).foregroundColor(Color(colorEnum: .darkGrey))) + .textFieldClearButton(text: $term, + isLoading: $viewModel.isLoading, + icon: .clearIcon) + .submitLabel(.search) + .onSubmit { + viewModel.handleSubmitButtonTap() + } + .focused($isFocused) + .font(.system(size: CGFloat(.mediumFontSize))) + .tint(Color(colorEnum: .text)) + .foregroundColor(Color(colorEnum: .text)) + } + .padding(.vertical, CGFloat(.mediumSidePadding)) + .padding(.leading, CGFloat(.defaultSidePadding)) + .padding(.trailing, CGFloat(.smallToMediumSpacing)) + .overlay { + Capsule().stroke(lineWidth: 1.0).foregroundColor(Color(colorEnum: .darkGrey)) + } + .padding(CGFloat(.defaultSidePadding)) + .animation(.easeIn(duration: 0.1), + value: term) + + ScrollView { + VStack(spacing: CGFloat(.largeSpacing)) { + if viewModel.isShowingRecent { + HStack { + Text(LocalizableString.Search.resultsRecent.localized) + .font(.system(size: CGFloat(.largeFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + } + + if viewModel.showNoResults { + noResultsView + .padding(.top, viewModel.isShowingRecent ? CGFloat(.smallSidePadding) : CGFloat(.XLSidePadding)) + } else { + ForEach(viewModel.searchResults) { result in + Button { + isFocused = false + term.removeAll() + viewModel.handleTapOnResult(result) + } label: { + rowView(row: result) + } + } + } + } + .padding(.horizontal, 36.0) + } + } + } + } + + @ViewBuilder + func rowView(row: Row) -> some View { + HStack(spacing: CGFloat(CGFloat(.mediumSpacing))) { + ZStack { + if let icon = row.icon { + Image(asset: icon) + .renderingMode(.template) + } else if let fontIcon = row.fontIcon { + Text(fontIcon.rawValue) + .font(.fontAwesome(font: .FAPro, size: CGFloat(.mediumFontSize))) + } + } + .foregroundColor(Color(colorEnum: .text)) + .frame(width: 40.0, height: 40.0) + .background(Circle().foregroundColor(Color(colorEnum: .layer1))) + + VStack(alignment: .leading, spacing: 0.0) { + Text(row.title) + .foregroundColor(Color(colorEnum: .darkGrey)) + .font(.system(size: CGFloat(.mediumFontSize))) + .multilineTextAlignment(.leading) + + if let subtitle = row.subtitle { + Text(subtitle) + .foregroundColor(Color(colorEnum: .darkGrey)) + .font(.system(size: CGFloat(.normalFontSize))) + .multilineTextAlignment(.leading) + } + } + + Spacer() + } + } + + @ViewBuilder + var noResultsView: some View { + VStack(spacing: CGFloat(.smallSpacing)) { + Text(viewModel.isShowingRecent ? LocalizableString.Search.noRecentResultsTitle.localized : LocalizableString.Search.noResultsTitle.localized) + .font(.system(size: CGFloat(.smallTitleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + + Text(viewModel.isShowingRecent ? LocalizableString.Search.noRecentResultsSubtitle.localized : LocalizableString.Search.noResultsSubtitle.localized) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + .multilineTextAlignment(.center) + } +} diff --git a/PresentationLayer/UI Components/Screens/Explorer/Search/SearchView.swift b/PresentationLayer/UI Components/Screens/Explorer/Search/SearchView.swift new file mode 100644 index 00000000..5be59531 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Explorer/Search/SearchView.swift @@ -0,0 +1,51 @@ +// +// SearchView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 20/6/23. +// + +import SwiftUI +import Toolkit + +struct SearchView: View { + var shouldShowSettingsButton: Bool = false + @StateObject var viewModel: ExplorerSearchViewModel + @FocusState var isFocused: Bool + @FocusState var noActiveTextfieldIsFocused: Bool + /// This state variable is injected in Textfields becauses assigning directy the view model's property `searchTerm` + /// causes a bug with multiple changes callback even if the term doesn't change + @State var term: String = "" + @State var showSettingsPopOver = false + + var body: some View { + ZStack { + nonActiveView + + if viewModel.isSearchActive { + activeView + .transition(AnyTransition.opacity.animation(.easeIn(duration: 0.2))) + .onAppear { + isFocused = true + } + .onChange(of: term) { newValue in + // Assign the updated term to perform the search request + viewModel.updateSearchTerm(newValue) + } + } + } + .onAppear { + Logger.shared.trackScreen(.explorerSearch) + } + } +} + +struct SearchView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.gray + .ignoresSafeArea() + SearchView(viewModel: ViewModelsFactory.getNetworkSearchViewModel()) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/ExplorerSignIn/RegisterView.swift b/PresentationLayer/UI Components/Screens/ExplorerSignIn/RegisterView.swift new file mode 100644 index 00000000..0c465556 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ExplorerSignIn/RegisterView.swift @@ -0,0 +1,88 @@ +// +// RegisterView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 17/5/22. +// + +import SwiftUI +import Toolkit + +struct RegisterView: View { + @Environment(\.presentationMode) var presentationMode: Binding + @StateObject var viewModel: RegisterViewModel + + var body: some View { + ZStack { + VStack { + registerFlow + } + .WXMCardStyle() + .padding(.top, CGFloat(.largeSidePadding)) + .padding(.bottom, CGFloat(.defaultSidePadding)) + .padding(.horizontal, CGFloat(.defaultSidePadding)) + } + .navigationBarTitle(Text(LocalizableString.createAccount.localized), displayMode: .large) + .onChange(of: viewModel.userEmail) { email in + viewModel.userEmail = email.trimWhiteSpaces() + viewModel.checkSignUpButtonAvailability() + } + .onAppear { + Logger.shared.trackScreen(.signup) + } + } + + var registerFlow: some View { + registerContainer + .fail(show: $viewModel.isFail, obj: viewModel.failSuccessObj) + .success(show: $viewModel.isSuccess, obj: viewModel.failSuccessObj) + .spinningLoader(show: $viewModel.isCallInProgress, hideContent: true) + } + + var registerContainer: some View { + VStack(spacing: CGFloat(.largeSpacing)) { + resisterDescription + textFields + Spacer() + signUpButton + } + } + + @ViewBuilder + var resisterDescription: some View { + Text(LocalizableString.registerDescription.localized) + .font(.system(size: CGFloat(.normalMediumFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + + @ViewBuilder + var textFields: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + BaseTextField(input: $viewModel.userEmail, textFieldStyle: .mandatoryEmail) + + BaseTextField(input: $viewModel.userName, textFieldStyle: .name) + + BaseTextField(input: $viewModel.userSurname, textFieldStyle: .surname) + } + } + + @ViewBuilder + var signUpButton: some View { + Button { + viewModel.register() + } label: { + Text(LocalizableString.signUp.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + .disabled(!viewModel.isSignUpButtonAvailable) + } +} + +struct Previews_RegisterView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.gray.ignoresSafeArea() + RegisterView(viewModel: ViewModelsFactory.getRegisterViewModel()) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/ExplorerSignIn/RegisterViewModel.swift b/PresentationLayer/UI Components/Screens/ExplorerSignIn/RegisterViewModel.swift new file mode 100644 index 00000000..d514b9aa --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ExplorerSignIn/RegisterViewModel.swift @@ -0,0 +1,104 @@ +// +// RegisterViewModel.swift +// PresentationLayer +// +// Created by Hristos Condrea on 23/5/22. +// + +import Combine +import DomainLayer +import Toolkit + +final class RegisterViewModel: ObservableObject { + @Published var userEmail: String = "" + @Published var userName: String = "" + @Published var userSurname: String = "" + @Published var isSignUpButtonAvailable: Bool = false + @Published var isCallInProgress: Bool = false + @Published var isSuccess: Bool = false + @Published var isFail = false + + var failSuccessObj: FailSuccessStateObject? + private var cancellableSet: Set = [] + + private final let authUseCase: AuthUseCase + + init(authUseCase: AuthUseCase) { + self.authUseCase = authUseCase + } + + func register() { + do { + isCallInProgress = true + try authUseCase.register(email: userEmail, firstName: userName, lastName: userSurname) + .sink { [weak self] response in + guard let self else { + return + } + + self.isCallInProgress = false + + if let error = response.error { + let info = error.uiInfo + let title = info.title + var description = info.description + if error.backendError?.code == FailAPICodeEnum.userAlreadyExists.rawValue { + description = LocalizableString.Error.signupUserAlreadyExists1.localized + self.userEmail + LocalizableString.Error.signupUserAlreadyExists2.localized + } + + let failObj = FailSuccessStateObject(type: .register, + title: title, + subtitle: description?.attributedMarkdown, + cancelTitle: nil, + retryTitle: LocalizableString.retry.localized, + contactSupportAction: { + HelperFunctions().openContactSupport(successFailureEnum: .register, + email: nil, + serialNumber: nil, + trackSelectContentEvent: true) + }, + cancelAction: nil, + retryAction: { [weak self] in self?.isFail = false }) + + self.failSuccessObj = failObj + self.isFail = true + self.isSuccess = false + } else { + let description = LocalizableString.successRegisterDesc1.localized + self.userEmail + LocalizableString.successRegisterDesc2.localized + + let successObj = FailSuccessStateObject(type: .register, + title: LocalizableString.success.localized, + subtitle: description.attributedMarkdown, + cancelTitle: nil, + retryTitle: nil, + contactSupportAction: nil, + cancelAction: nil, + retryAction: nil) + self.failSuccessObj = successObj + self.isFail = false + self.isSuccess = true + } + + let isSuccessful = response.error == nil + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .signup, + .contentId: .signUpContentId, + .method: .emailMethod, + .success: .custom(isSuccessful ? "1" : "0")]) + }.store(in: &cancellableSet) + + } catch {} + } + + func checkSignUpButtonAvailability() { + if userEmail.isEmpty || !userEmail.isValidEmail() { + isSignUpButtonAvailable = false + } else { + isSignUpButtonAvailable = true + } + } +} + +extension RegisterViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + } +} diff --git a/PresentationLayer/UI Components/Screens/ExplorerSignIn/ResetPasswordView.swift b/PresentationLayer/UI Components/Screens/ExplorerSignIn/ResetPasswordView.swift new file mode 100644 index 00000000..aa197fc7 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ExplorerSignIn/ResetPasswordView.swift @@ -0,0 +1,80 @@ +// +// ResetPasswordView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 17/5/22. +// + +import SwiftUI +import Toolkit + +struct ResetPasswordView: View { + @StateObject var viewModel: ResetPasswordViewModel + + var body: some View { + ZStack { + VStack { + resetPasswordFlow + } + .WXMCardStyle() + .padding(.top, CGFloat(.largeSidePadding)) + .padding(.bottom, CGFloat(.defaultSidePadding)) + .padding(.horizontal, CGFloat(.defaultSidePadding)) + } + .navigationBarTitle(Text(LocalizableString.resetPasswordTitle.localized), + displayMode: .large) + .onChange(of: viewModel.userEmail, perform: { text in + viewModel.userEmail = text.trimWhiteSpaces() + viewModel.isResetPasswordButtonAvailable() + }) + .onAppear { + Logger.shared.trackScreen(.passwordReset) + } + } + + private var resetPasswordFlow: some View { + resetPasswordContainer + .fail(show: $viewModel.isFail, obj: viewModel.failSuccessObj) + .success(show: $viewModel.isSuccess, obj: viewModel.failSuccessObj) + .spinningLoader(show: $viewModel.isCallInProgress, hideContent: true) + } + + var resetPasswordContainer: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + containerDescription + emailTextField + Spacer() + sendEmailButton + } + } + + var containerDescription: some View { + Text(LocalizableString.resetPassword.localized) + .foregroundColor(Color(colorEnum: .text)) + .multilineTextAlignment(.leading) + .font(.system(size: CGFloat(.normalMediumFontSize))) + } + + var emailTextField: some View { + BaseTextField(input: $viewModel.userEmail, textFieldStyle: .email) + } + + var sendEmailButton: some View { + Button { + viewModel.resetPassword() + } label: { + Text(LocalizableString.sendEmail.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + .disabled(!viewModel.isSendResetPasswordButtonAvailable) + } +} + +struct Previews_ResetPasswordView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.gray.ignoresSafeArea() + ResetPasswordView(viewModel: ViewModelsFactory.getResetPasswordViewModel()) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/ExplorerSignIn/ResetPasswordViewModel.swift b/PresentationLayer/UI Components/Screens/ExplorerSignIn/ResetPasswordViewModel.swift new file mode 100644 index 00000000..aff7e9bb --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ExplorerSignIn/ResetPasswordViewModel.swift @@ -0,0 +1,77 @@ +// +// ResetPasswordViewModel.swift +// PresentationLayer +// +// Created by Hristos Condrea on 30/5/22. +// + +import Combine +import DomainLayer +import Toolkit + +final class ResetPasswordViewModel: ObservableObject { + @Published var userEmail: String = "" + @Published var isSendResetPasswordButtonAvailable: Bool = false + @Published var isCallInProgress: Bool = false + @Published var isSuccess: Bool = false + @Published var isFail = false + + var failSuccessObj: FailSuccessStateObject? + private var cancellableSet: Set = [] + + private final let authUseCase: AuthUseCase + + init(authUseCase: AuthUseCase) { + self.authUseCase = authUseCase + } + + func resetPassword() { + do { + isCallInProgress = true + try authUseCase.resetPassword(email: userEmail) + .sink { [weak self] response in + guard let self else { + return + } + self.isCallInProgress = false + + if let error = response.error { + let info = error.uiInfo + let failObj = info.defaultFailObject(type: .resetPassword) {[weak self] in + self?.isFail = false + } + + self.failSuccessObj = failObj + self.isFail = true + self.isSuccess = false + } else { + let successObj = FailSuccessStateObject(type: .resetPassword, + title: LocalizableString.successResetPasswordTitle.localized, + subtitle: LocalizableString.successResetPasswordDesc.localized.attributedMarkdown, + cancelTitle: nil, + retryTitle: nil, + contactSupportAction: nil, + cancelAction: nil, + retryAction: nil) + self.failSuccessObj = successObj + self.isFail = false + self.isSuccess = true + } + + let isSuccessful = response.error == nil + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .sendEmailForgotPassword, + .contentId: .forgotPasswordEmailContentId, + .success: .custom(isSuccessful ? "1" : "0")]) + }.store(in: &cancellableSet) + } catch {} + } + + func isResetPasswordButtonAvailable() { + isSendResetPasswordButtonAvailable = !userEmail.isEmpty && userEmail.isValidEmail() + } +} + +extension ResetPasswordViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + } +} diff --git a/PresentationLayer/UI Components/Screens/ExplorerSignIn/SignInView.swift b/PresentationLayer/UI Components/Screens/ExplorerSignIn/SignInView.swift new file mode 100644 index 00000000..ab171af6 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ExplorerSignIn/SignInView.swift @@ -0,0 +1,94 @@ +// +// SignInView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 16/5/22. +// + +import SwiftUI +import Toolkit + +struct SignInView: View { + private let mainScreenVM: MainScreenViewModel = .shared + @StateObject var viewModel: SignInViewModel + + var body: some View { + VStack { + loginContainer + } + .padding(.top, CGFloat(.largeSidePadding)) + .padding(.bottom, CGFloat(.defaultSidePadding)) + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .onChange(of: viewModel.password, perform: { _ in + viewModel.checkSignInButtonAvailability() + }) + .onChange(of: viewModel.email, perform: { _ in + viewModel.checkSignInButtonAvailability() + }) + .navigationBarTitle(Text(LocalizableString.signIn.localized), displayMode: .large) + .onAppear { + Logger.shared.trackScreen(.login) + } + } + + var loginContainer: some View { + VStack(spacing: CGFloat(.largeSpacing)) { + containerTitle + VStack(spacing: CGFloat(.smallSpacing)) { + signInTextFields + forgotPasswordButton + } + Spacer() + signInButton + } + .WXMCardStyle() + } + + var containerTitle: some View { + HStack { + Text(LocalizableString.signInDescription.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalMediumFontSize))) + + Spacer() + } + } + + @ViewBuilder + var signInTextFields: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + BaseTextField(input: $viewModel.email, textFieldStyle: .user, keyboardType: .emailAddress) + + BaseTextField(input: $viewModel.password, textFieldStyle: .password) + } + } + + var forgotPasswordButton: some View { + HStack { + Spacer() + Button { + Router.shared.navigateTo(.resetPassword(ViewModelsFactory.getResetPasswordViewModel())) + } label: { + Text(LocalizableString.forgotPassword.localized) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .primary)) + .padding(.bottom, CGFloat(.defaultSpacing)) + } + } + } + + var signInButton: some View { + Button { + viewModel.login { error in + if error == nil { + mainScreenVM.isUserLoggedIn = true + Router.shared.pop() + } + } + } label: { + Text(LocalizableString.signIn.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + .disabled(!viewModel.isSignInButtonAvailable) + } +} diff --git a/PresentationLayer/UI Components/Screens/ExplorerSignIn/SignInViewModel.swift b/PresentationLayer/UI Components/Screens/ExplorerSignIn/SignInViewModel.swift new file mode 100644 index 00000000..73beeddc --- /dev/null +++ b/PresentationLayer/UI Components/Screens/ExplorerSignIn/SignInViewModel.swift @@ -0,0 +1,74 @@ +// +// SignInViewModel.swift +// PresentationLayer +// +// Created by Hristos Condrea on 18/5/22. +// + +import Combine +import DomainLayer +import Toolkit + +final class SignInViewModel: ObservableObject { + @Published var email: String = "" + @Published var password: String = "" + @Published var tokenResponse = NetworkTokenResponse() + @Published var isSignInButtonAvailable: Bool = false + private var cancellableSet: Set = [] + private final let authUseCase: AuthUseCase + private final let keychainUseCase: KeychainUseCase + + public init(authUseCase: AuthUseCase, keychainUseCase: KeychainUseCase) { + self.authUseCase = authUseCase + self.keychainUseCase = keychainUseCase + } + + public func login(completion: @escaping (String?) -> Void) { + isSignInButtonAvailable = false + do { + try authUseCase.login(username: email, password: password) + .sink { response in + if let responseError = response.error { + self.isSignInButtonAvailable = true + let info = responseError.uiInfo + let text = info.description ?? LocalizableString.Error.genericMessage.localized + completion(text) + Toast.shared.show(text: text.attributedMarkdown ?? "") + } else { + self.isSignInButtonAvailable = true + self.setEmailAndPassword() + self.setTokenResponse(networkTokenResponse: response.value!) + completion(nil) + } + + let isSuccessful = response.error == nil + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .login, + .contentId: .loginContentId, + .method: .emailMethod, + .success: .custom(isSuccessful ? "1" : "0")]) + + }.store(in: &cancellableSet) + } catch {} + } + + func checkSignInButtonAvailability() { + if email.isEmpty || password.isEmpty { + isSignInButtonAvailable = false + } else { + isSignInButtonAvailable = true + } + } + + private func setEmailAndPassword() { + keychainUseCase.saveAccountInfoToKeychain(email: email, password: password) + } + + private func setTokenResponse(networkTokenResponse: NetworkTokenResponse) { + keychainUseCase.saveNetworkTokenResponseToKeychain(item: networkTokenResponse) + } +} + +extension SignInViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + } +} diff --git a/PresentationLayer/UI Components/Screens/HistoryScreen/History Container/HistoryContainerView.swift b/PresentationLayer/UI Components/Screens/HistoryScreen/History Container/HistoryContainerView.swift new file mode 100644 index 00000000..677373a3 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/HistoryScreen/History Container/HistoryContainerView.swift @@ -0,0 +1,141 @@ +// +// HistoryContainerView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 7/9/23. +// + +import SwiftUI +import Toolkit +import DomainLayer + +struct HistoryContainerView: View { + @StateObject var viewModel: HistoryContainerViewModel + @State private var showCalendarPopOver: Bool = false + + var body: some View { + NavigationContainerView { + navigationBarRightView + } content: { + HistoryPagerView(viewModel: viewModel) + } + } + + @ViewBuilder + var navigationBarRightView: some View { + Button { + showCalendarPopOver = true + } label: { + Text(FontIcon.calendar.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: .primary)) + .frame(width: 30.0, height: 30.0) + } + .bottomSheet(show: $showCalendarPopOver) { [weak viewModel] in + DatePicker("", + selection: Binding(get: { [weak viewModel] in + viewModel?.currentDate ?? .now + }, + set: { [weak viewModel] value in + guard let fixedDate = viewModel?.historyDates.getFixedDate(from: value) else { + return + } + + showCalendarPopOver = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + viewModel?.currentDate = fixedDate + } + }), + in: viewModel!.historyDates.range, + displayedComponents: [.date]) + .datePickerStyle(.graphical) + .labelsHidden() + } + } +} + +private struct HistoryPagerView: View { + + @StateObject var viewModel: HistoryContainerViewModel + @EnvironmentObject var navigationObject: NavigationObject + + var body: some View { + ZStack { + Color(colorEnum: .bg) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 0.0) { + dateCarousel + .zIndex(1) + + ZStack { + LazyLoadingPagerView(data: $viewModel.currentDate) { _ in + HistoryView(viewModel: ViewModelsFactory.getHistoryViewModel(device: viewModel.device, + date: viewModel.currentDate)) + } previous: { date in + guard let previousDate = viewModel.historyDates.getPreviousDate(from: date) else { + return nil + } + return HistoryView(viewModel: ViewModelsFactory.getHistoryViewModel(device: viewModel.device, + date: previousDate)) + + } next: { date in + guard let nextDate = viewModel.historyDates.getNextDate(from: date) else { + return nil + } + return HistoryView(viewModel: ViewModelsFactory.getHistoryViewModel(device: viewModel.device, + date: nextDate)) + } previousData: { date in + viewModel.historyDates.getPreviousDate(from: date) + } nextData: { date in + viewModel.historyDates.getNextDate(from: date) + } scrollDirection: { fromData, toData in + fromData < toData ? .forward : .reverse + } + .zIndex(0) + } + } + } + .onAppear { + if let date = viewModel.historyDates.last { + viewModel.handleDateTap(date: date) + } + navigationObject.title = LocalizableString.historyTitle.localized + navigationObject.subtitle = viewModel.device.address + Logger.shared.trackScreen(.history) + } + .onChange(of: viewModel.currentDate) { value in + print("Current date \(value)") + } + } +} + +private extension HistoryPagerView { + @ViewBuilder + var dateCarousel: some View { + ScrollingPickerView(selectedIndex: Binding(get: { + guard let index = viewModel.historyDates.getIndexOfDate(viewModel.currentDate) else { + return 0 + } + return viewModel.historyDates.distance(from: viewModel.historyDates.startIndex, to: index) + }, set: { value in + let date = viewModel.historyDates[.inRange(value)] + viewModel.currentDate = date + }), textCallback: { index in + viewModel.historyDates[.inRange(index)].getDateStringRepresentation() + }, countCallback: { + viewModel.historyDates.count + }) + .background { + Color(colorEnum: .top) + } + .cornerRadius(CGFloat(.cardCornerRadius), + corners: [.bottomLeft, .bottomRight]) + .wxmShadow() + } +} + +#Preview { + HistoryContainerView(viewModel: ViewModelsFactory.getHistoryContainerViewModel(device: DeviceDetails.emptyDeviceDetails)) +} diff --git a/PresentationLayer/UI Components/Screens/HistoryScreen/History Container/HistoryContainerViewModel.swift b/PresentationLayer/UI Components/Screens/HistoryScreen/History Container/HistoryContainerViewModel.swift new file mode 100644 index 00000000..c6616399 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/HistoryScreen/History Container/HistoryContainerViewModel.swift @@ -0,0 +1,53 @@ +// +// HistoryContainerViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 7/9/23. +// + +import Foundation +import Combine +import DomainLayer +import Toolkit + +class HistoryContainerViewModel: ObservableObject { + + private let historyUseCase: HistoryUseCase + private let SEVEN_DAYS_OFFSET = 6 + let historyDates: DateRange + let device: DeviceDetails + + @Published var currentDate: Date + + init(device: DeviceDetails, historyUseCase: HistoryUseCase) { + self.device = device + self.historyUseCase = historyUseCase + let epoch = device.claimedAt?.timestampToDate() ?? .now.advancedByDays(days: -SEVEN_DAYS_OFFSET) + let offset = Date.now.days(from: epoch) + self.historyDates = DateRange(epoch: epoch, + values: 0...offset) + self.currentDate = historyDates.last! + } + + func handleDateTap(date: Date) { + guard let deviceId = device.id else { + return + } + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .historyDay, + .itemId: .custom(deviceId), + .date: .custom(date.getFormattedDate(format: .onlyDate))]) + + // Prevent unecessary UI refreshes + guard currentDate != date else { + return + } + + currentDate = date + } +} + +extension HistoryContainerViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + hasher.combine(device.id) + } +} diff --git a/PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartCardTypes.swift b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartCardTypes.swift new file mode 100644 index 00000000..ef613777 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartCardTypes.swift @@ -0,0 +1,108 @@ +// +// ChartCardTypes.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 11/5/23. +// + +import SwiftUI +import Charts +import DomainLayer + +enum ChartCardType: CaseIterable, CustomStringConvertible { + case temperature + case precipitation + case wind + case humidity + case pressure + case solar + + var description: String { + switch self { + case .temperature: + return LocalizableString.temperature.localized + case .precipitation: + return LocalizableString.precipitation.localized + case .wind: + return LocalizableString.wind.localized + case .humidity: + return LocalizableString.humidity.localized + case .pressure: + return LocalizableString.pressure.localized + case .solar: + return LocalizableString.solar.localized + } + } + + var icon: AssetEnum { + switch self { + case .temperature: + return .temperatureIcon + case .precipitation: + return .precipitationIcon + case .wind: + return .windIcon + case .humidity: + return .humidityIcon + case .pressure: + return .pressureIcon + case .solar: + return .solarIcon + } + } + + var weatherFields: [WeatherField] { + switch self { + case .temperature: + return [.temperature, .feelsLike] + case .precipitation: + return [.precipitationRate, .dailyPrecipitation] + case .wind: + return [.wind, .windGust] + case .humidity: + return [.humidity] + case .pressure: + return [.pressure] + case .solar: + return [.uv, .solarRadiation] + } + } + + var isRightAxisEnabled: Bool { + let axisDependencies = weatherFields.map { getAxisDependecy(for: $0) } + return axisDependencies.contains(.right) + } + + func getAxisDependecy(for weatherField: WeatherField) -> YAxis.AxisDependency { + switch self { + case .temperature, .wind, .humidity, .pressure: + return .left + case .precipitation: + switch weatherField { + case .precipitationRate: + return .left + case .dailyPrecipitation: + return .right + default: + return .left + } + case .solar: + switch weatherField { + case .uv: + return .left + case .solarRadiation: + return .right + default: + return .left + } + } + } +} + +class ChartDelegate: ObservableObject, ChartViewDelegate { + @Published var selectedIndex: Int? + + func chartValueSelected(_ chartView: ChartViewBase, entry: ChartDataEntry, highlight: Highlight) { + selectedIndex = Int(entry.x) + } +} diff --git a/PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartCardView.swift b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartCardView.swift new file mode 100644 index 00000000..48290007 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartCardView.swift @@ -0,0 +1,135 @@ +// +// ChartCardView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 11/5/23. +// + +import SwiftUI +import Charts + +struct ChartCardView: View { + @EnvironmentObject var delegate: ChartDelegate + let type: ChartCardType + let chartDataModels: [WeatherChartDataModel] + + private let unitsManager: WeatherUnitsManager = .default + + var body: some View { + VStack(spacing: 0.0) { + HStack(alignment: .center, spacing: CGFloat(.minimumSpacing)) { + Image(asset: type.icon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + + Text(type.description.capitalized) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + .lineLimit(1) + .fixedSize() + + Spacer() + + if chartDataModels.count > 1 { + legend + } + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.vertical, CGFloat(.smallSidePadding)) + + VStack(spacing: CGFloat(.smallSpacing)) { + HStack { + Text(currentValueText) + .font(.system(size: CGFloat(.smallFontSize))) + .foregroundColor(Color(colorEnum: .darkestBlue)) + + Spacer() + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .layer1), + insideHorizontalPadding: CGFloat(.smallSidePadding), + insideVerticalPadding: CGFloat(.minimumPadding), + cornerRadius: CGFloat(.buttonCornerRadius)) + + WeatherLineChart(type: type, chartData: chartDataModels, delegate: delegate) + .frame(height: 160.0) + } + .WXMCardStyle() + + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .layer1), + insideHorizontalPadding: 0.0, + insideVerticalPadding: 0.0) + .wxmShadow() + } +} + +private extension ChartCardView { + @ViewBuilder + var legend: some View { + HStack(spacing: CGFloat(.smallSpacing)) { + ForEach(0 ..< chartDataModels.count, id: \.self) { index in + let model = chartDataModels[index] + VStack(alignment: .leading, spacing: CGFloat(.minimumSpacing)) { + Text(model.weatherField.displayTitle) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.caption))) + .lineLimit(1) + Color(colorEnum: WeatherChartsConstants.legendColors[safe: index] ?? .primary) + .frame(width: 44.0, height: 8.0) + .cornerRadius(CGFloat(.lightCornerRadius)) + } + } + } + } + + var currentValueText: AttributedString { + guard let index = delegate.selectedIndex, + let timestamp = chartDataModels.first?.timestamps[safe: index] else { + return "" + } + + let comps: [String] = chartDataModels.map { model in + let entry = model.entries[index] + let value = entry.y + let literals = model.weatherField.createWeatherLiterals(from: value, + addditonalInfo: entry.data, + unitsManager: unitsManager, + shouldConvertUnits: false) + let formattedValue = "\(literals?.value ?? "")\(model.weatherField.shouldHaveSpaceWithUnit ? " " : "")\(literals?.unit ?? "")".trimWhiteSpaces() + return "\(model.weatherField.graphHighlightTitle): **\(formattedValue)**" + } + let text = comps.joined(separator: "﹒") + + return "**\(timestamp)** \(text)".attributedMarkdown ?? "" + } +} + +struct ChartCardView_Previews: PreviewProvider { + static var previews: some View { + let entries = [ChartDataEntry(x: 0.0, y: 19.3), + ChartDataEntry(x: 1.0, y: 18.8), + ChartDataEntry(x: 2.0, y: 18.3), + ChartDataEntry(x: 3.0, y: 17.8), + ChartDataEntry(x: 4.0, y: 17.5), + ChartDataEntry(x: 5.0, y: 17.6), + ChartDataEntry(x: 6.0, y: 18.6), + ChartDataEntry(x: 7.0, y: 19.9), + ChartDataEntry(x: 8.0, y: 20.8), + ChartDataEntry(x: 9.0, y: 21.8), + ChartDataEntry(x: 10.0, y: 22.8), + ChartDataEntry(x: 11.0, y: 23.6), + ChartDataEntry(x: 12.0, y: 24.3), + ChartDataEntry(x: 13.0, y: 24.9), + ChartDataEntry(x: 14.0, y: 25.5)] + + ChartCardView(type: .temperature, + chartDataModels: [WeatherChartDataModel.mock(type: .temperature, + timestamps: entries.map { "\($0.x.rounded())" }, + dataEntries: entries), + WeatherChartDataModel.mock(type: .feelsLike, + timestamps: entries.map { "\($0.x.rounded())" }, + dataEntries: entries)]) + .padding() + .environmentObject(ChartDelegate()) + } +} diff --git a/PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartsContainer.swift b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartsContainer.swift new file mode 100644 index 00000000..3a7dfa76 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/ChartsContainer.swift @@ -0,0 +1,37 @@ +// +// ChartContainer.swift +// PresentationLayer +// +// Created by Lampros Zouloumis on 6/9/22. +// + +import Charts +import DomainLayer +import SwiftUI + +struct ChartsContainer: View { + let historyData: HistoryChartModels + @StateObject var delegate: ChartDelegate + + var body: some View { + VStack(spacing: CGFloat(.mediumSpacing)) { + ForEach(ChartCardType.allCases, id: \.self) { chart in + ChartCardView(type: chart, + chartDataModels: chart.weatherFields.compactMap { historyData.dataModels[$0] }) + .environmentObject(delegate) + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + + if let timezone = historyData.tz { + HStack { + Text(LocalizableString.timeZoneDisclaimer(timezone).localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.caption))) + Spacer() + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.bottom, CGFloat(.defaultSidePadding)) + } + } + } +} diff --git a/PresentationLayer/UI Components/Screens/HistoryScreen/History View/HistoryView.swift b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/HistoryView.swift new file mode 100644 index 00000000..7b6c69e9 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/HistoryView.swift @@ -0,0 +1,49 @@ +// +// HistoryView.swift +// PresentationLayer +// +// Created by Lampros Zouloumis on 23/8/22. +// + +import DomainLayer +import SwiftUI +import Toolkit + +struct HistoryView: View { + @EnvironmentObject var navigationObject: NavigationObject + @StateObject var viewModel: HistoryViewModel + + var body: some View { + ZStack { + TrackableScrollView(offsetObject: viewModel.scrollObject) { completion in + viewModel.refresh(completion: completion) + } content: { + if let historyData = viewModel.currentHistoryData, !historyData.isEmpty() { + ChartsContainer(historyData: historyData, delegate: viewModel.chartDelegate) + .id(historyData.markDate) + .padding(.top) + } + } + } + .wxmEmptyView(show: Binding(get: { viewModel.currentHistoryData?.isEmpty() ?? true }, set: { _ in }), + configuration: .init(animationEnum: .emptyGeneric, + title: LocalizableString.StationDetails.noWeatherData.localized, + description: viewModel.getNoDataDateFormat().attributedMarkdown ?? "")) + + .spinningLoader(show: $viewModel.loadingData, hideContent: true) + .fail(show: $viewModel.isFailed, obj: viewModel.failObj) + .onAppear { + viewModel.refresh(force: false, showFullScreenLoader: true) { + + } + } + } +} + +struct Previews_HistoryView_Previews: PreviewProvider { + static var previews: some View { + NavigationContainerView { + HistoryView(viewModel: ViewModelsFactory.getHistoryViewModel(device: DeviceDetails.emptyDeviceDetails, date: .now)) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/ChartsFactory.swift b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/ChartsFactory.swift new file mode 100644 index 00000000..dd89b779 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/ChartsFactory.swift @@ -0,0 +1,137 @@ +// +// ChartsFactory.swift +// DomainLayer +// +// Created by Pantelis Giazitsis on 9/5/23. +// + +import Charts +import UIKit +import Toolkit +import DomainLayer + +class ChartsFactory { + private let unitConverter: UnitsConverter + private let weatherUnitFormatter: WeatherUnitsConverter + + init() { + unitConverter = UnitsConverter() + let userDefaultsRepository = SwinjectHelper.shared.getContainerForSwinject().resolve(UserDefaultsRepository.self)! + weatherUnitFormatter = WeatherUnitsConverter(userDefaultsRepository: userDefaultsRepository, unitConverter: unitConverter) + } + + func createHourlyCharts(timeZone: TimeZone, date: Date, hourlyWeatherData: [CurrentWeather]) -> HistoryChartModels { + var entries: [WeatherField: [ChartDataEntry]] = [:] + + var timestamps = [String]() + let dates = date.dailyHourlySamples(timeZone: timeZone) + for (index, date) in dates.enumerated() { + let timestamp = date.toTimestamp(with: timeZone) + timestamps.append(timestamp.timestampToDate(timeZone: timeZone).twelveHourPeriodTime) + let xVal = Double(index) + + let element = hourlyWeatherData.first(where: { $0.timestamp == timestamp }) + WeatherField.allCases.forEach { type in + var chartDataEntry: ChartDataEntry? + if let element { + chartDataEntry = getChartDataEntry(type: type, element: element, xVal: xVal) + } else { + chartDataEntry = ChartDataEntry(x: xVal, y: .nan) + } + + if let chartDataEntry { + var typeEntries: [ChartDataEntry] = entries[type] ?? [] + typeEntries.append(chartDataEntry) + entries[type] = typeEntries + } + } + } + + var dataModels: [WeatherField: WeatherChartDataModel] = [:] + WeatherField.allCases.forEach { + dataModels[$0] = WeatherChartDataModel(weatherField: $0, + timestamps: timestamps, + entries: entries[$0] ?? []) + } + + return HistoryChartModels(markDate: date, + tz: timeZone.identifier, + dataModels: dataModels) + } +} + +private extension ChartsFactory { + func getWindImage(for windDirection: Int) -> UIImage? { + let index = unitConverter.getIndexOfCardinal(value: windDirection) + let rotation: Float = 180.0 + Float(index) * 22.5 + let image = UIImage(named: .windDirIconSmall)?.withRenderingMode(.alwaysTemplate).withTintColor(UIColor(colorEnum: .warning)).rotate(degrees: rotation) + + return image + } + + func getChartDataEntry(type: WeatherField, element: CurrentWeather, xVal: Double) -> ChartDataEntry? { + var chartDataEntry: ChartDataEntry? + switch type { + case .temperature: + if let temperature = element.temperature { + chartDataEntry = ChartDataEntry(x: xVal, y: weatherUnitFormatter.convertTemp(value: temperature, decimals: 1)) + } + case .feelsLike: + if let feelsLike = element.feelsLike { + chartDataEntry = ChartDataEntry(x: xVal, y: weatherUnitFormatter.convertTemp(value: feelsLike, decimals: 1)) + } + case .precipitationRate: + if let precipitation = element.precipitation { + chartDataEntry = ChartDataEntry(x: xVal, y: weatherUnitFormatter.convertPrecipitation(value: precipitation)) + } + case .wind: + if let windSpeed = weatherUnitFormatter.convertWindSpeed(value: element.windSpeed) { + var windDirectionAsset: UIImage? + if let windDirection = element.windDirection, + let windGust = weatherUnitFormatter.convertWindSpeed(value: element.windGust), + windGust > 0 { + windDirectionAsset = getWindImage(for: windDirection) + } + chartDataEntry = ChartDataEntry(x: xVal, y: windSpeed, icon: windDirectionAsset, data: element.windDirection) + } + case .windDirection: + break + case .windGust: + if let windGust = weatherUnitFormatter.convertWindSpeed(value: element.windGust) { + chartDataEntry = ChartDataEntry(x: xVal, y: windGust, data: element.windDirection) + } + case .humidity: + if let humidity = element.humidity { + chartDataEntry = ChartDataEntry(x: xVal, y: Double(humidity)) + } + case .pressure: + if let pressure = element.pressure { + chartDataEntry = ChartDataEntry(x: xVal, y: weatherUnitFormatter.convertPressure(value: pressure)) + } + case .uv: + if let uvIndex = element.uvIndex { + chartDataEntry = BarChartDataEntry(x: xVal, y: Double(uvIndex)) + } + case .precipitationProbability: + if let precipitationProbability = element.precipitationProbability { + chartDataEntry = ChartDataEntry(x: xVal, y: precipitationProbability) + } + case .dailyPrecipitation: + if let precipitationAccumulated = element.precipitationAccumulated { + chartDataEntry = ChartDataEntry(x: xVal, y: weatherUnitFormatter.convertPrecipitation(value: precipitationAccumulated)) + } + case .solarRadiation: + if let solarIrradiance = element.solarIrradiance { + chartDataEntry = ChartDataEntry(x: xVal, y: solarIrradiance) + } + case .illuminance: + break + case .dewPoint: + if let dewPoint = element.dewPoint { + chartDataEntry = ChartDataEntry(x: xVal, y: weatherUnitFormatter.convertTemp(value: dewPoint, decimals: 1)) + } + } + + return chartDataEntry + } +} diff --git a/PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/HistoryChartModels.swift b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/HistoryChartModels.swift new file mode 100644 index 00000000..7d511a08 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/HistoryChartModels.swift @@ -0,0 +1,24 @@ +// +// HistoryChartModels.swift +// DomainLayer +// +// Created by Pantelis Giazitsis on 9/5/23. +// + +import Foundation +import DomainLayer + +public struct HistoryChartModels { + /// Used to indicate ONLY the day of the chart + var markDate: Date? + var tz: String? + var dataModels: [WeatherField: WeatherChartDataModel] + + public var dateStringRepresentation: String? { + markDate?.getDateStringRepresentation() + } + + func isEmpty() -> Bool { + dataModels.allSatisfy { $0.value.isNilOrEmpty() } + } +} diff --git a/PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/HistoryViewModel.swift b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/HistoryViewModel.swift new file mode 100644 index 00000000..ac74a3cd --- /dev/null +++ b/PresentationLayer/UI Components/Screens/HistoryScreen/History View/View Model/HistoryViewModel.swift @@ -0,0 +1,120 @@ +// +// HistoryViewModel.swift +// PresentationLayer +// +// Created by Lampros Zouloumis on 31/8/22. +// + +import Combine +import DomainLayer +import Toolkit + +class HistoryViewModel: ObservableObject { + private let historyUseCase: HistoryUseCase + + @Published var loadingData: Bool = true + @Published var currentHistoryData: HistoryChartModels? + @Published var noAvailableData: Bool = false + @Published var isFailed: Bool = false + private(set) var failObj: FailSuccessStateObject? + let scrollObject = TrackableScrollOffsetObject() + @Published private(set) var chartDelegate: ChartDelegate = ChartDelegate() + private var cancellableSet: Set = [] + private let chartsFactory = ChartsFactory() + let device: DeviceDetails + let currentDate: Date + + init(device: DeviceDetails, historyUseCase: HistoryUseCase, date: Date) { + self.device = device + self.historyUseCase = historyUseCase + self.currentDate = date + } + + func getNoDataDateFormat() -> String { + return currentDate.getFormattedDate(format: .monthLiteralDayYear).capitalized + } + + func refresh(force: Bool = true, showFullScreenLoader: Bool = false, completion: @escaping VoidCallback) { + guard let deviceId = device.id else { + completion() + return + } + getHistoricalChartsData(deviceId: deviceId, + date: currentDate, + force: force, + showFullScreenLoader: showFullScreenLoader, + completion: completion) + } +} + +extension HistoryViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + hasher.combine(device.id) + } +} + +private extension HistoryViewModel { + func getHistoricalChartsData(deviceId: String?, date: Date, force: Bool, showFullScreenLoader: Bool, completion: VoidCallback? = nil) { + guard let deviceId = deviceId else { + completion?() + return + } + + /// Prevent from showing full screen loader in case of pull to refresh + loadingData = showFullScreenLoader + isFailed = false + do { + try historyUseCase.getWeatherHourlyHistory(deviceId: deviceId, + date: date, + force: force).sink { [weak self] response in + completion?() + self?.loadingData = false + + if let error = response.error { + let info = error.uiInfo + let obj = info.defaultFailObject(type: .history) { + self?.isFailed = false + self?.getHistoricalChartsData(deviceId: deviceId, date: date, force: force, showFullScreenLoader: true) + } + self?.failObj = obj + self?.isFailed = true + + return + } + + self?.handleHistoryResponse(historicalData: response.value) + }.store(in: &cancellableSet) + } catch {} + } + + func handleHistoryResponse(historicalData: [NetworkDeviceHistoryResponse]?) { + guard let hourlyWeatherData: [CurrentWeather] = historicalData?.reduce(into: [], { accumulator, forecastResponse in + if let hourlyData = forecastResponse.hourly { + accumulator.append(contentsOf: hourlyData) + }}), + let date = hourlyWeatherData.first?.timestamp?.timestampToDate() + else { + currentHistoryData = nil + return + } + + let timeZone = TimeZone(identifier: historicalData?.first?.tz ?? "") ?? .current + let chartModels = chartsFactory.createHourlyCharts(timeZone: timeZone, date: date, hourlyWeatherData: hourlyWeatherData) + currentHistoryData = chartModels + generateDelegate() + } + + func generateDelegate() { + guard let currentHistoryData else { + return + } + let delegate = ChartDelegate() + let entries = currentHistoryData.dataModels.values.first?.entries + + let isToday = currentDate.isToday + let index = isToday ? entries?.lastIndex(where: { !$0.y.isNaN }) : entries?.firstIndex(where: { !$0.y.isNaN }) + + delegate.selectedIndex = index + self.chartDelegate = delegate + } +} diff --git a/PresentationLayer/UI Components/Screens/LoggedInViews/LoggedInTabViewContainer.swift b/PresentationLayer/UI Components/Screens/LoggedInViews/LoggedInTabViewContainer.swift new file mode 100644 index 00000000..c03c538d --- /dev/null +++ b/PresentationLayer/UI Components/Screens/LoggedInViews/LoggedInTabViewContainer.swift @@ -0,0 +1,185 @@ +// +// LoggedInTabViewContainer.swift +// PresentationLayer +// +// Created by Hristos Condrea on 28/5/22. +// + +import DomainLayer +import SwiftUI +import Toolkit + +struct LoggedInTabViewContainer: View { + @StateObject var mainViewModel: MainScreenViewModel = .shared + @State var isTabBarShowing: Bool = true + @StateObject var explorerViewModel: ExplorerViewModel + @StateObject var profileViewModel: ProfileViewModel + @State var tabBarItemsSize: CGSize = .zero + + public init(swinjectHelper: SwinjectInterface) { + let container = swinjectHelper.getContainerForSwinject() + _explorerViewModel = StateObject(wrappedValue: ViewModelsFactory.getExplorerViewModel()) + _profileViewModel = StateObject(wrappedValue: ViewModelsFactory.getProfileViewModel()) + } + + var body: some View { + ZStack { + selectedTabView + .animation(.easeIn(duration: 0.3), value: mainViewModel.selectedTab) + + if !explorerViewModel.isSearchActive { + tabBar + } + } + .fullScreenCover(isPresented: $mainViewModel.showFirmwareUpdate) { + NavigationContainerView { + UpdateFirmwareView(viewModel: UpdateFirmwareViewModel(device: mainViewModel.deviceToUpdate ?? DeviceDetails.emptyDeviceDetails) { + mainViewModel.showFirmwareUpdate = false + if let device = mainViewModel.deviceToUpdate { + Router.shared.navigateTo(.stationDetails(ViewModelsFactory.getStationDetailsViewModel(deviceId: device.id ?? "", + cellIndex: device.cellIndex, + cellCenter: device.cellCenter?.toCLLocationCoordinate2D()))) + } + } cancelCallback: { + mainViewModel.showFirmwareUpdate = false + }) + } + } + .fullScreenCover(isPresented: $mainViewModel.showAnalyticsPrompt) { + AnalyticsView(show: $mainViewModel.showAnalyticsPrompt, + viewModel: ViewModelsFactory.getAnalyticsViewModel()) + } + } + + @ViewBuilder + private var selectedTabView: some View { + ZStack { + switch mainViewModel.selectedTab { + case .homeTab: + WeatherStationsHomeView(swinjectHelper: mainViewModel.swinjectHelper, + isTabBarShowing: $isTabBarShowing, + tabBarItemsSize: $tabBarItemsSize, + isWalletEmpty: $mainViewModel.isWalletMissing) + .onAppear { + Logger.shared.trackScreen(.deviceList) + } + case .mapTab: + explorer + .onAppear { + Logger.shared.trackScreen(.explorer) + explorerViewModel.showTopOfMapItems = true + } + case .profileTab: + ProfileView(viewModel: profileViewModel, + isTabBarShowing: $isTabBarShowing, + tabBarItemsSize: $tabBarItemsSize) + .onAppear { + Logger.shared.trackScreen(.profile) + } + } + } + } + + private var tabBar: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + Spacer() + VStack(spacing: CGFloat(.defaultSpacing)) { + if mainViewModel.selectedTab == .mapTab { + fabButtons + } + + if mainViewModel.selectedTab == .homeTab { + addStationsButton + } + + TabBarView($mainViewModel.selectedTab, mainViewModel.isWalletMissing) + .opacity(isTabBarShowing ? 1 : 0) + } + .sizeObserver(size: $tabBarItemsSize) + } + } +} + +private extension LoggedInTabViewContainer { + @ViewBuilder + var explorer: some View { + ZStack { + MapBoxMapView() + .environmentObject(explorerViewModel) + .edgesIgnoringSafeArea(.all) + .navigationBarHidden(true) + .zIndex(0) + .shimmerLoader(show: $explorerViewModel.isLoading) + + if explorerViewModel.showTopOfMapItems { + SearchView(viewModel: explorerViewModel.searchViewModel) + .transition(.move(edge: .top).animation(.easeIn(duration: 0.5))) + .zIndex(1) + } + } + .animation(.easeIn(duration: 0.4), value: explorerViewModel.showTopOfMapItems) + } + + @ViewBuilder + var fabButtons: some View { + VStack(spacing: CGFloat(.defaultSidePadding)) { + if explorerViewModel.showTopOfMapItems { + Spacer() + + VStack(spacing: CGFloat(.defaultSpacing)) { + HStack { + Spacer() + userLocationButton + } + + HStack { + Spacer() + netStatsButton + } + } + .transition(AnyTransition.move(edge: .trailing)) + .animation(.easeIn) + } + } + .padding(CGFloat(.defaultSidePadding)) + } + + @ViewBuilder + var netStatsButton: some View { + Button { + Router.shared.navigateTo(.netStats(ViewModelsFactory.getNetworkStatsViewModel())) + } label: { + Image(asset: .networkStatsIcon) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .netStatsFabTextColor)) + } + .frame(width: CGFloat(.fabButtonsDimension), height: CGFloat(.fabButtonsDimension)) + .background(Color(colorEnum: .netStatsFabColor)) + .cornerRadius(CGFloat(.cardCornerRadius)) + .wxmShadow() + } + + @ViewBuilder + var userLocationButton: some View { + Button { + explorerViewModel.userLocationButtonTapped() + } label: { + Image(asset: .detectLocation) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + } + .frame(width: CGFloat(.fabButtonsDimension), height: CGFloat(.fabButtonsDimension)) + .background(Circle().foregroundColor(Color(colorEnum: .top))) + .wxmShadow() + } + + @ViewBuilder + var addStationsButton: some View { + HStack { + Spacer() + AddButton() + .opacity(isTabBarShowing ? 1 : 0) + } + .padding(CGFloat(.defaultSidePadding)) + } +} diff --git a/PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabBarView.swift b/PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabBarView.swift new file mode 100644 index 00000000..d40c0be2 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabBarView.swift @@ -0,0 +1,49 @@ +// +// TapBarView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 11/5/22. +// + +import SwiftUI + +struct TabBarView: View { + @Binding var selectedTab: TabSelectionEnum + let isProfileTabNotificationIconShowing: Bool + private let itemsSpacing = 70.0 + + public init(_ selectedTab: Binding, _ isProfileTabNotificationIconShowing: Bool) { + _selectedTab = selectedTab + self.isProfileTabNotificationIconShowing = isProfileTabNotificationIconShowing + } + + var body: some View { + tabBar.tabBarStyle() + } + + var tabBar: some View { + HStack(spacing: itemsSpacing) { + ForEach(TabSelectionEnum.allCases, id: \.self) { tab in + tabIcon(tab: tab) + } + } + } + + func tabIcon(tab: TabSelectionEnum) -> some View { + ZStack { + TabItemView(tab: tab, selectedTab: $selectedTab) + if tab == TabSelectionEnum.profileTab { + Image(asset: .badge) + .padding(.leading, CGFloat(.defaultSidePadding)) + .padding(.bottom, CGFloat(.defaultSidePadding)) + .opacity(isProfileTabNotificationIconShowing ? 1 : 0) + } + } + } +} + +struct Previews_TabBarView_Previews: PreviewProvider { + static var previews: some View { + TabBarView(.constant(.homeTab), true) + } +} diff --git a/PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabItemView.swift b/PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabItemView.swift new file mode 100644 index 00000000..0828ed6f --- /dev/null +++ b/PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabItemView.swift @@ -0,0 +1,31 @@ +// +// TabViewItem.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 11/5/22. +// + +import SwiftUI + +struct TabItemView: View { + let tab: TabSelectionEnum + @Binding var selectedTab: TabSelectionEnum + + var body: some View { + tabItem + } + + var tabItem: some View { + Button { + selectedTab = tab + } label: { + tabIcon + } + } + + var tabIcon: some View { + tab.tabIcon + .renderingMode(.template) + .foregroundColor(tab == selectedTab ? tab.tabSelected : tab.tabNotSelected) + } +} diff --git a/PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabSelectionEnum.swift b/PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabSelectionEnum.swift new file mode 100644 index 00000000..e2cd4fe4 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/LoggedInViews/TabBar/TabSelectionEnum.swift @@ -0,0 +1,40 @@ +// +// TabTypeEnum.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 11/5/22. +// + +import Foundation +import SwiftUI + +enum TabSelectionEnum: CaseIterable, Hashable { + case homeTab + case mapTab + case profileTab + + var tabIcon: Image { + switch self { + case .homeTab: + return Image(asset: .home) + case .mapTab: + return Image(asset: .globe) + case .profileTab: + return Image(asset: .user) + } + } + + var tabSelected: Color { + switch self { + case .homeTab, .mapTab, .profileTab: + return Color(colorEnum: .primary) + } + } + + var tabNotSelected: Color { + switch self { + case .homeTab, .mapTab, .profileTab: + return Color(colorEnum: .darkGrey) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Main Screen/MainScreen.swift b/PresentationLayer/UI Components/Screens/Main Screen/MainScreen.swift new file mode 100644 index 00000000..92b43b02 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Main Screen/MainScreen.swift @@ -0,0 +1,65 @@ +// +// ContentView.swift +// PresentationLayer +// +// Created by Hristos Condrea on 6/5/22. +// + +import Network +import SwiftUI +import DomainLayer + +public struct MainScreen: View { + @StateObject var viewModel: MainScreenViewModel + @Environment(\.scenePhase) var scenePhase + + public init(swinjectHelper: SwinjectInterface) { + _viewModel = StateObject(wrappedValue: MainScreenViewModel.shared) + } + + public var body: some View { + RouterView { + mainScreenSwitch + .fullScreenCover(isPresented: $viewModel.showAppUpdatePrompt) { + AppUpdateView(show: $viewModel.showAppUpdatePrompt, + viewModel: ViewModelsFactory.getAppUpdateViewModel()) + } + .onChange(of: scenePhase) { phase in + if phase == .active { + viewModel.initializeConfigurations() + } + } + .onChange(of: viewModel.isUserLoggedIn) { _ in + viewModel.initializeConfigurations() + } + } + .preferredColorScheme(viewModel.theme.colorScheme) + .onOpenURL { + viewModel.deepLinkHandler.handleUrl($0) + } + } + + public var mainScreenSwitch: some View { + ZStack { + Color(colorEnum: .bg).edgesIgnoringSafeArea(.all) + switch viewModel.isUserLoggedIn { + case false: + loggedOutUser.environmentObject(viewModel) + case true: + loggedInUser.environmentObject(viewModel) + } + } + } + + public var loggedOutUser: some View { + ZStack { + ExplorerView(viewModel: ViewModelsFactory.getExplorerViewModel()) + } + } + + @ViewBuilder + public var loggedInUser: some View { + LoggedInTabViewContainer(swinjectHelper: viewModel.swinjectHelper) + .environmentObject(viewModel) + } +} diff --git a/PresentationLayer/UI Components/Screens/Main Screen/MainScreenViewModel.swift b/PresentationLayer/UI Components/Screens/Main Screen/MainScreenViewModel.swift new file mode 100644 index 00000000..02b059d6 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Main Screen/MainScreenViewModel.swift @@ -0,0 +1,271 @@ +// +// ContentViewModel.swift +// PresentationLayer +// +// Created by Hristos Condrea on 17/5/22. +// + +import Combine +import DomainLayer +import Foundation +import Network +import SwiftUI +import Toolkit +import WidgetKit + +class MainScreenViewModel: ObservableObject { + + static let shared = MainScreenViewModel() + + @Published private(set) var theme: Theme = .system { + willSet { + updateThemeOption(newValue) + } + + didSet { + Logger.shared.setUserProperty(key: .theme, value: theme.analyticsValue) + } + } + /// The active theme of the device. The value will be only light or dark. + /// eg if the user's choice is `system` and the device's theme is dark, the returned value will be dark + var deviceActiveTheme: Theme? { + getCurrentActiveTheme() + } + + /// The interval to check if should show or hide wallet warning + private let showWarningWalletInterval: TimeInterval = 24.0 * TimeInterval.hour // 1 day + @Published private(set) var showWalletWarning: Bool = false + + let deepLinkHandler = DeepLinkHandler(useCase: SwinjectHelper.shared.getContainerForSwinject().resolve(NetworkUseCase.self)!) + + private let mainUseCase: MainUseCase + private let meUseCase: MeUseCase + private let settingsUseCase: SettingsUseCase + private var cancellableSet: Set = [] + let networkMonitor: NWPathMonitor + @Published var isUserLoggedIn: Bool = false + @Published var isInternetAvailable: Bool = false + @Published var selectedTab: TabSelectionEnum = .homeTab + @Published var showAnalyticsPrompt: Bool = false + @Published var userInfo: NetworkUserInfoResponse? { + didSet { + updateIsWalletMissing() + } + } + @Published var isWalletMissing: Bool = false + @Published var showAppUpdatePrompt: Bool = false + + let swinjectHelper: SwinjectInterface + + private init() { + self.swinjectHelper = SwinjectHelper.shared + mainUseCase = swinjectHelper.getContainerForSwinject().resolve(MainUseCase.self)! + meUseCase = swinjectHelper.getContainerForSwinject().resolve(MeUseCase.self)! + + /// The following line migrates standard user defaults to group user defaults + /// and keychain to group keychain. + /// Should run once for every user, eventually will be removed + mainUseCase.performMigrationsIfNeeded() + + networkMonitor = NWPathMonitor() + settingsUseCase = swinjectHelper.getContainerForSwinject().resolve(SettingsUseCase.self)! + + checkIfUserIsLoggedIn() + settingsUseCase.initializeAnalyticsTracking() + + NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { _ in + WidgetCenter.shared.reloadAllTimelines() + } + + meUseCase.userInfoPublisher.sink { [weak self] response in + self?.userInfo = response + }.store(in: &cancellableSet) + + // Set the cache size in order to handle image download requests + URLCache.shared.memoryCapacity = 10_000_000 // ~10 MB memory space + URLCache.shared.diskCapacity = 500_000_000 // ~500 MB disk cache space + + RemoteConfigManager.shared.$iosAppLatestVersion.sink { [weak self] latestVersion in + guard let latestVersion else { + return + } + let minimumVersion = RemoteConfigManager.shared.iosAppMinimumVersion + self?.showAppUpdatePrompt = self?.mainUseCase.shouldShowUpdatePrompt(for: latestVersion, minimumVersion: minimumVersion) ?? false + }.store(in: &cancellableSet) + + RemoteConfigManager.shared.$iosAppMinimumVersion.sink { [weak self] minVersion in + guard let minVersion, + let latestVersion = RemoteConfigManager.shared.iosAppLatestVersion else { + return + } + + self?.showAppUpdatePrompt = self?.mainUseCase.shouldShowUpdatePrompt(for: latestVersion, minimumVersion: minVersion) ?? false + }.store(in: &cancellableSet) + } + + @Published var showFirmwareUpdate = false + var deviceToUpdate: DeviceDetails? + func showFirmwareUpdate(device: DeviceDetails) { + showFirmwareUpdate = true + deviceToUpdate = device + } + + func initializeConfigurations() { + theme = getThemeOption() + updateShowWalletWarning() + startMonitoring() + checkIfShouldShowAnalyticsPrompt(settingsUseCase: settingsUseCase) + cleanupAnalyticsUserIdIfNeeded() + } + + private func checkIfUserIsLoggedIn() { + let container = swinjectHelper.getContainerForSwinject() + let keychainUsecase = container.resolve(KeychainUseCase.self)! + isUserLoggedIn = keychainUsecase.isUserLoggedIn() + } + + private func startMonitoring() { + networkMonitor.pathUpdateHandler = { path in + if path.status == .satisfied { + self.isInternetAvailable = true + } else { + self.isInternetAvailable = false + } + } + networkMonitor.start(queue: DispatchQueue.main) + } + + // MARK: Temperature userDefaults + + private func getTemperatureMetricEnum() -> TemperatureUnitsEnum { + guard let temperatureUnits = mainUseCase.readOrCreateWeatherMetric(key: UserDefaults.WeatherUnitKey.temperature.rawValue) as? TemperatureUnitsEnum else { + return .celsius + } + return temperatureUnits + } + + // MARK: Percipitation userDefaults + + private func getPrecipitationMetricEnum() -> PrecipitationUnitsEnum { + guard let precipitationUnits = mainUseCase.readOrCreateWeatherMetric(key: UserDefaults.WeatherUnitKey.precipitation.rawValue) as? PrecipitationUnitsEnum else { + return .millimeters + } + return precipitationUnits + } + + // MARK: Wind Speed userDefaults + + private func getWindSpeedMetricEnum() -> WindSpeedUnitsEnum { + guard let windSpeedUnits = mainUseCase.readOrCreateWeatherMetric(key: UserDefaults.WeatherUnitKey.windSpeed.rawValue) as? WindSpeedUnitsEnum else { + return .kilometersPerHour + } + return windSpeedUnits + } + + // MARK: Wind Direction userDefaults + + private func getWindDirectionMetricEnum() -> WindDirectionUnitsEnum { + guard let windDirectionUnits = mainUseCase.readOrCreateWeatherMetric(key: UserDefaults.WeatherUnitKey.windDirection.rawValue) as? WindDirectionUnitsEnum else { + return .cardinal + } + return windDirectionUnits + } + + // MARK: Pressure userDefaults + + private func getPressureMetricEnum() -> PressureUnitsEnum { + guard let pressureUnits = mainUseCase.readOrCreateWeatherMetric(key: UserDefaults.WeatherUnitKey.pressure.rawValue) as? PressureUnitsEnum else { + return .hectopascal + } + return pressureUnits + } + + // MARK: Display Theme + + func setTheme(_ theme: Theme) { + self.theme = theme + } + + private func updateThemeOption(_ option: Theme) { + mainUseCase.saveValue(key: UserDefaults.GenericKey.displayTheme.rawValue, value: option.rawValue) + UIApplication.shared.currentKeyWindow?.overrideUserInterfaceStyle = option.interfaceStyle + } + + private func getThemeOption() -> Theme { + guard let persistedValue: String = mainUseCase.getValue(key: UserDefaults.GenericKey.displayTheme.rawValue) else { + return .system + } + return Theme(rawValue: persistedValue) ?? .system + } + + private func getCurrentActiveTheme() -> Theme? { + let interfaceStyle = UIScreen.main.traitCollection.userInterfaceStyle + let activeTheme = Theme(interfaceStyle: interfaceStyle) + return activeTheme + } + + // MARK: Wallet warning timestamp + + func hideWalletWarning() { + mainUseCase.saveValue(key: UserDefaults.GenericKey.hideWalletTimestamp.rawValue, value: Date.now) + updateShowWalletWarning() + } + + private func updateShowWalletWarning() { + guard let lastTimestamp: Date = mainUseCase.getValue(key: UserDefaults.GenericKey.hideWalletTimestamp.rawValue) else { + showWalletWarning = true + return + } + + showWalletWarning = Date.now.timeIntervalSince(lastTimestamp) > showWarningWalletInterval + } + + private func updateIsWalletMissing() { + Task { @MainActor in + guard let userInfo else { + isWalletMissing = false + return + } + let hasOwnedDevices = await meUseCase.hasOwnedDevices() + isWalletMissing = hasOwnedDevices && ((userInfo.wallet?.address.isNilOrEmpty) ?? true) + } + } + + // MARK: Firmware update versions + + func firmwareUpdated(for deviceId: String, version: String) { + let versionsData: Data = mainUseCase.getValue(key: UserDefaults.GenericKey.firmwareUpdateVersions.rawValue) ?? Data() + var versions: [String: FirmwareVersion] = (try? JSONDecoder().decode([String: FirmwareVersion].self, from: versionsData)) ?? [:] + versions[deviceId] = FirmwareVersion(installDate: Date.now, version: version) + + if let data = try? JSONEncoder().encode(versions) { + mainUseCase.saveValue(key: UserDefaults.GenericKey.firmwareUpdateVersions.rawValue, value: data) + } + } + + func getInstalledFirmwareVersion(for deviceId: String) -> FirmwareVersion? { + let versionsData: Data = mainUseCase.getValue(key: UserDefaults.GenericKey.firmwareUpdateVersions.rawValue) ?? Data() + let versions: [String: FirmwareVersion]? = (try? JSONDecoder().decode([String: FirmwareVersion].self, from: versionsData)) + + return versions?[deviceId] + } + + // MARK: Analytics opt in/out + + private func checkIfShouldShowAnalyticsPrompt(settingsUseCase: SettingsUseCase) { + guard isUserLoggedIn else { + return + } + + showAnalyticsPrompt = !settingsUseCase.isAnalyticsOptSet + } + + private func cleanupAnalyticsUserIdIfNeeded() { + guard isUserLoggedIn else { + return + } + Logger.shared.setUserId(nil) + } + + // MARK: - App Update +} diff --git a/PresentationLayer/UI Components/Screens/Multiple Alerts/AlertsViewModel.swift b/PresentationLayer/UI Components/Screens/Multiple Alerts/AlertsViewModel.swift new file mode 100644 index 00000000..b78e6eec --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Multiple Alerts/AlertsViewModel.swift @@ -0,0 +1,82 @@ +// +// AlertsViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 26/5/23. +// + +import Foundation +import Combine +import DomainLayer +import Toolkit + +class AlertsViewModel: ObservableObject { + + @Published private(set) var alerts: [MultipleAlertsView.Alert] = [] + let device: DeviceDetails + let mainVM: MainScreenViewModel + let followState: UserDeviceFollowState? + + init(device: DeviceDetails, mainVM: MainScreenViewModel, followState: UserDeviceFollowState?) { + self.device = device + self.mainVM = mainVM + self.followState = followState + generateAlerts() + } +} + +extension AlertsViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + hasher.combine(device.id) + } +} + +private extension AlertsViewModel { + func generateAlerts() { + var alerts: [MultipleAlertsView.Alert] = [] + + if !device.isActive { + let alert = MultipleAlertsView.Alert(type: .error, + title: LocalizableString.alertsStationOfflineTitle.localized, + message: LocalizableString.alertsStationOfflineDescription.localized, + buttonTitle: LocalizableString.contactSupport.localized, + buttonAction: handleContactSupportTap, + appearAction: nil) + alerts.append(alert) + } + + if device.needsUpdate(mainVM: mainVM, followState: followState) { + let alert = MultipleAlertsView.Alert(type: .warning, + title: LocalizableString.stationWarningUpdateTitle.localized, + message: LocalizableString.stationWarningUpdateDescription.localized, + buttonTitle: LocalizableString.stationWarningUpdateButtonTitle.localized, + buttonAction: handleFirmwareUpdateTap, + appearAction: { [weak self] in self?.trackPromptEvent(action: .viewAction) }) + alerts.append(alert) + } + + self.alerts = alerts + } + + func handleContactSupportTap() { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .contactSupport, + .source: .deviceAlertsSource]) + + HelperFunctions().openContactSupport(successFailureEnum: .stationOffline, + email: mainVM.userInfo?.email, + serialNumber: device.label, + trackSelectContentEvent: false) + } + + func handleFirmwareUpdateTap() { + trackPromptEvent(action: .action) + mainVM.showFirmwareUpdate(device: device) + } + + func trackPromptEvent(action: ParameterValue) { + Logger.shared.trackEvent(.prompt, parameters: [.promptName: .OTAAvailable, + .promptType: .warnPromptType, + .action: action]) + } + +} diff --git a/PresentationLayer/UI Components/Screens/Multiple Alerts/MultipleAlertsView.swift b/PresentationLayer/UI Components/Screens/Multiple Alerts/MultipleAlertsView.swift new file mode 100644 index 00000000..69ca84f7 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Multiple Alerts/MultipleAlertsView.swift @@ -0,0 +1,65 @@ +// +// MultipleAlertsView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 26/5/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct MultipleAlertsView: View { + @EnvironmentObject var navigationObject: NavigationObject + @StateObject var viewModel: AlertsViewModel + + var body: some View { + ZStack { + Color(colorEnum: .top) + + ScrollView { + VStack(spacing: CGFloat(.smallSpacing)) { + ForEach(viewModel.alerts, id: \.message) { alert in + CardWarningView(type: alert.type, + title: alert.title, + message: alert.message, + showContentFullWidth: true, + closeAction: nil) { + Button(action: alert.buttonAction) { + Text(alert.buttonTitle) + } + .buttonStyle(WXMButtonStyle()) + .padding(.top, CGFloat(.smallSidePadding)) + } + .onAppear(perform: alert.appearAction) + } + } + .padding(CGFloat(.defaultSidePadding)) + } + .onAppear { + navigationObject.title = LocalizableString.alerts.localized + navigationObject.subtitle = viewModel.device.displayName + } + } + } +} + +extension MultipleAlertsView { + struct Alert { + let type: CardWarningType + let title: String + let message: String + let buttonTitle: String + let buttonAction: VoidCallback + let appearAction: VoidCallback? + } +} + +struct MultipleAlertsView_Previews: PreviewProvider { + static var previews: some View { + let mainVM = MainScreenViewModel.shared + NavigationContainerView { + MultipleAlertsView(viewModel: AlertsViewModel(device: .emptyDeviceDetails, mainVM: mainVM, followState: .init(deviceId: "123", relation: .owned))) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStats+Content.swift b/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStats+Content.swift new file mode 100644 index 00000000..3364157a --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStats+Content.swift @@ -0,0 +1,454 @@ +// +// NetworkStats+Content.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 12/6/23. +// + +import Foundation +import SwiftUI +import Toolkit + +extension NetworkStatsView { + + enum State { + case empty + case loading + case content + case fail + } + + typealias InfoTuple = (title: String?, text: String) + typealias XAxisTuple = (leading: String, trailing: String) + + struct Statistics { + let title: String + let description: AttributedString? + let showExternalLinkIcon: Bool + let externalLinkTapAction: VoidCallback? + let mainText: String? + let info: InfoTuple? + let dateString: String? + let chartModel: NetStatsChartViewModel? + let xAxisTuple: XAxisTuple? + var additionalStats: [AdditionalStats]? + let analyticsItemId: ParameterValue? + } + + struct AdditionalStats { + let title: String + let value: String + var color: ColorEnum = .text + var info: InfoTuple? + let analyticsItemId: ParameterValue? + } + + struct StationStatistics: Identifiable { + var id: String { + "\(title.hashValue)-\(total.hashValue)-\(info?.title ?? "")-\(info?.text ?? "")-\(details.hashValue)" + } + + let title: String + let total: String + var info: InfoTuple? + let details: [StationDetails] + let analyticsItemId: ParameterValue + } + + struct StationDetails: Hashable { + let title: String + let value: String + let percentage: Float + let color: ColorEnum + let url: String? + } + + struct StatisticsCTA { + let title: String? + let description: String + let info: InfoTuple? + let analyticsItemId: ParameterValue? + let buttonTitle: String + let buttonAction: VoidCallback + } +} + +// MARK: - View builders + +extension NetworkStatsView { + @ViewBuilder + var dataDaysView: some View { + if let dataDays = viewModel.dataDays { + generateStatsView(stats: dataDays) + } else { + EmptyView() + } + } + + @ViewBuilder + var rewardsView: some View { + if let rewards = viewModel.rewards { + generateStatsView(stats: rewards) + } else { + EmptyView() + } + } + + @ViewBuilder + var tokenView: some View { + if let token = viewModel.token { + generateStatsView(stats: token) + } else { + EmptyView() + } + } + + @ViewBuilder + var buyStationView: some View { + if let buyStationCTA = viewModel.buyStationCTA { + ctaView(buyStationCTA) + } else { + EmptyView() + } + } + + @ViewBuilder + var manufacturerView: some View { + if let cta = viewModel.manufacturerCTA { + ctaView(cta) + } else { + EmptyView() + } + } + + @ViewBuilder + var lastUpdatedView: some View { + if let lastUpdated = viewModel.lastUpdatedText { + HStack { + Spacer() + + Text(lastUpdated) + .font(.system(size: CGFloat(.normalFontSize), weight: .thin)) + .foregroundColor(Color(colorEnum: .text)) + + } + } else { + EmptyView() + } + } + @ViewBuilder + var weatherStationsView: some View { + if let stationStats = viewModel.stationStats { + VStack(spacing: CGFloat(.mediumSpacing)) { + HStack { + Text(LocalizableString.NetStats.weatherStations.localized) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + Spacer() + } + .padding(.horizontal, 24.0) + + VStack(spacing: CGFloat(.mediumSpacing)) { + ForEach(stationStats, id: \.title) { stats in + stationStatsView(statistics: stats) + } + } + .padding(.horizontal, CGFloat(.smallToMediumSidePadding)) + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .top), + insideHorizontalPadding: 0.0, + insideVerticalPadding: CGFloat(.smallToMediumSidePadding)) + .wxmShadow() + + } else { + EmptyView() + } + } + + @ViewBuilder + func generateStatsView(stats: Statistics) -> some View { + VStack(spacing: CGFloat(.smallToMediumSpacing)) { + VStack(spacing: CGFloat(.minimumSpacing)) { + HStack { + Text(stats.title) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + + if let info = stats.info { + Button { + viewModel.showInfo(title: info.title, + description: info.text, + analyticsItemId: stats.analyticsItemId) + } label: { + Text(FontIcon.infoCircle.rawValue) + .font(.fontAwesome(font: .FAProLight, size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + .frame(width: 30.0, height: 30.0) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + if let description = stats.description { + let mainText = Text(description) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .darkestBlue)) + + HStack { + + if stats.showExternalLinkIcon { + Group { + mainText + + Text(" ") + + Text(FontIcon.externalLink.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .primary)) + } + .tint(Color(colorEnum: .primary)) + .simultaneousGesture(TapGesture().onEnded { + stats.externalLinkTapAction?() + }) + } else { + mainText + .tint(Color(colorEnum: .primary)) + } + + Spacer() + } + } + } + .padding(.leading, 22.0) + .padding(.trailing, CGFloat(.smallToMediumSidePadding)) + .padding(.top, CGFloat(.mediumSidePadding)) + + if let chartModel = stats.chartModel { + HStack(spacing: CGFloat(.defaultSidePadding)) { + VStack(spacing: CGFloat(.smallToMediumSpacing)) { + StatisticsChart(chartDataModel: chartModel) + .frame(height: 45.0) + .aspectRatio(4, contentMode: .fill) + .padding(.horizontal, CGFloat(.smallToMediumSidePadding)) + + if let xAxis = stats.xAxisTuple { + HStack { + Text(xAxis.leading.uppercased()) + Spacer() + Text(xAxis.trailing.uppercased()) + } + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .darkGrey)) + } + } + + Spacer() + + VStack(spacing: 0.0) { + if let mainText = stats.mainText { + Text(mainText) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + .multilineTextAlignment(.center) + } + + if let dateString = stats.dateString { + Text(dateString) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .darkGrey)) + .multilineTextAlignment(.center) + } + } + } + .padding(.leading, 24.0) + .padding(.trailing, 28.0) + } + + if let additionalStats = stats.additionalStats { + additionalStatsView(statistics: additionalStats) + .padding(.horizontal, CGFloat(.smallToMediumSidePadding)) + .padding(.bottom, CGFloat(.smallToMediumSidePadding)) + } + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .top), + insideHorizontalPadding: 0.0, + insideVerticalPadding: 0.0) + .wxmShadow() + } + + @ViewBuilder + func additionalStatsView(statistics: [AdditionalStats]) -> some View { + LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: CGFloat(.smallSpacing)) { + ForEach(statistics, id: \.title) { stats in + VStack(spacing: CGFloat(.smallSpacing)) { + HStack { + Text(stats.title.uppercased()) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + + if let info = stats.info { + Button { + viewModel.showInfo(title: info.title, + description: info.text, + analyticsItemId: stats.analyticsItemId) + } label: { + Text(FontIcon.infoCircle.rawValue) + .font(.fontAwesome(font: .FAProLight, size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + } + .buttonStyle(.plain) + .frame(width: 30.0, height: 30.0) + } + } + + HStack { + Text(stats.value) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: stats.color)) + .lineLimit(1) + + Spacer() + } + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .layer1), + insideHorizontalPadding: CGFloat(.mediumSidePadding), + insideVerticalPadding: CGFloat(.smallSidePadding), + cornerRadius: CGFloat(.buttonCornerRadius)) + } + } + } + + @ViewBuilder + func stationStatsView(statistics: StationStatistics) -> some View { + VStack(spacing: CGFloat(.smallSpacing)) { + HStack { + Text(statistics.title.uppercased()) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + + if let info = statistics.info { + Button { + viewModel.showInfo(title: info.title, + description: info.text, + analyticsItemId: statistics.analyticsItemId) + } label: { + Text(FontIcon.infoCircle.rawValue) + .font(.fontAwesome(font: .FAProLight, size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + .frame(width: 30.0, height: 30.0) + .contentShape(Rectangle()) + + } + .buttonStyle(.plain) + } + } + + HStack(spacing: CGFloat(.mediumSpacing)) { + StationDetailsGridView(statistics: statistics) { details in + viewModel.handleDetailsActionTap(statistics: statistics, details: details) + } + .frame(maxWidth: .infinity) + .id(statistics.id) + + Text(statistics.total) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + .lineLimit(1) + } + .padding(.trailing, CGFloat(.smallSidePadding)) + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .layer1), + insideHorizontalPadding: 12.0, + insideVerticalPadding: 12.0, + cornerRadius: CGFloat(.buttonCornerRadius)) + } + + @ViewBuilder + func stationDetailsRowView(details: StationDetails) -> some View { + Text(details.title) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + .lineLimit(1) + .fixedSize() + + ZStack { + ProgressView(value: details.percentage, total: 1.0) + .progressViewStyle(ProgressBarStyle(bgColor: Color(colorEnum: .top), + progressColor: Color(colorEnum: details.color))) + + Text(LocalizableString.percentage(details.percentage * 100.0).localized) + .font(.system(size: CGFloat(.normalFontSize), weight: .semibold)) + .foregroundColor(Color(colorEnum: .text)) + } + .frame(width: .infinity, height: 18.0) + + Text(details.value) + .font(.system(size: CGFloat(.caption), weight: .semibold)) + .foregroundColor(Color(colorEnum: .text)) + .fixedSize() + } + + @ViewBuilder + func ctaView(_ cta: StatisticsCTA) -> some View { + HStack(spacing: 0.0) { + VStack(spacing: CGFloat(.minimumSpacing)) { + if let title = cta.title { + HStack(alignment: .center, spacing: CGFloat(.minimumSpacing)) { + Text(title) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + + Spacer(minLength: 0.0) + } + } + + HStack { + let mainText = Text(cta.description) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .darkestBlue)) + + if let info = cta.info { + Button { + viewModel.showInfo(title: info.title, + description: info.text, + analyticsItemId: cta.analyticsItemId) + } label: { + mainText + + Text(" ") + + Text(FontIcon.infoCircle.rawValue) + .font(.fontAwesome(font: .FAProLight, size: CGFloat(.littleCaption))) + .fontWeight(.bold) + .foregroundColor(Color(colorEnum: .darkestBlue)) + } + .buttonStyle(.plain) + } else { + mainText + } + + Spacer(minLength: 0.0) + } + } + + Button { + cta.buttonAction() + } label: { + Text(cta.buttonTitle) + .padding(.horizontal, CGFloat(.mediumSidePadding)) + } + .buttonStyle(WXMButtonStyle.filled()) + .fixedSize() + } + .padding(.trailing, CGFloat(.smallToMediumSidePadding)) + .padding(.leading, CGFloat(.mediumToLargeSidePadding)) + .padding(.vertical, CGFloat(.smallToMediumSidePadding)) + .WXMCardStyle(backgroundColor: Color(colorEnum: .blueTint), + insideHorizontalPadding: 0.0, + insideVerticalPadding: 0.0) + .wxmShadow() + } +} diff --git a/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsView.swift b/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsView.swift new file mode 100644 index 00000000..ad4a8fe5 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsView.swift @@ -0,0 +1,79 @@ +// +// NetworkStatsView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 9/6/23. +// + +import SwiftUI +import Toolkit + +struct NetworkStatsView: View { + + @StateObject var viewModel: NetworkStatsViewModel + @EnvironmentObject var navigationObject: NavigationObject + @StateObject private var scrollObject = TrackableScrollOffsetObject() + + var body: some View { + ZStack { + Color(colorEnum: .bg) + .ignoresSafeArea() + + contentView + .spinningLoader(show: Binding(get: { viewModel.state == .loading }, set: { _ in }), hideContent: true) + .wxmEmptyView(show: Binding(get: { viewModel.state == .empty }, set: { _ in }), + configuration: .init(title: LocalizableString.NetStats.emptyTitle.localized.uppercased(), + description: LocalizableString.NetStats.emptyDescription.localized.attributedMarkdown ?? "", + buttonTitle: LocalizableString.reload.localized) { viewModel.handleRetryButtonTap() }) + .fail(show: Binding(get: { viewModel.state == .fail}, set: { _ in }), obj: viewModel.failObj) + } + .transition(.opacity) + .onAppear { + navigationObject.navigationBarColor = Color(colorEnum: .bg) + navigationObject.title = LocalizableString.NetStats.networkStatistics.localized + + Logger.shared.trackScreen(.networkStats) + } + .bottomSheet(show: $viewModel.showInfo, fitContent: true) { + bottomInfoView(info: viewModel.info) + } + } +} + +private extension NetworkStatsView { + @ViewBuilder + var contentView: some View { + TrackableScrollView(offsetObject: scrollObject) { completion in + viewModel.refresh(completion: completion) + } content: { + VStack(spacing: CGFloat(.mediumSpacing)) { + dataDaysView + rewardsView + buyStationView + tokenView + weatherStationsView + manufacturerView + lastUpdatedView + } + .padding(CGFloat(.defaultSidePadding)) + } + } +} + +struct NetworkStatsView_Previews: PreviewProvider { + static var previews: some View { + NavigationContainerView { + NetworkStatsView(viewModel: ViewModelsFactory.getNetworkStatsViewModel()) + } + } +} + +struct NetworkStatsViewEmpty_Previews: PreviewProvider { + static var previews: some View { + let viewModel = NetworkStatsViewModel() + viewModel.state = .empty + return NavigationContainerView { + NetworkStatsView(viewModel: viewModel) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsViewModel+Factory.swift b/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsViewModel+Factory.swift new file mode 100644 index 00000000..058a006f --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsViewModel+Factory.swift @@ -0,0 +1,219 @@ +// +// NetworkStatsViewModel+Factory.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 10/7/23. +// + +import Foundation +import DomainLayer +import Charts +import Toolkit +import SwiftUI + +extension NetworkStatsViewModel { + func getDataDaysStatistics(response: NetworkStatsResponse?) -> NetworkStatsView.Statistics? { + guard let dataDays = fixedTimeSeries(timeSeries: response?.dataDays) else { + return nil + } + let total = NetworkStatsView.AdditionalStats(title: LocalizableString.total(nil).localized, + value: dataDays.last?.value?.toCompactDecimaFormat ?? "", + color: .text, + info: nil, + analyticsItemId: nil) + + let count = dataDays.count + let lastDataDayValue = dataDays.last?.value ?? 0.0 + let preLastDataDayValue = dataDays[safe: count - 2]?.value ?? 0.0 + let value = (lastDataDayValue - preLastDataDayValue).toCompactDecimaFormat ?? "" + let preLastDay = NetworkStatsView.AdditionalStats(title: LocalizableString.NetStats.lastRun.localized, + value: "+\(value)", + color: .reward_score_very_high, + info: nil, + analyticsItemId: nil) + + return getStatistics(from: dataDays, + title: LocalizableString.NetStats.weatherStationDays.localized, + info: (LocalizableString.NetStats.weatherStationDays.localized, LocalizableString.NetStats.dataDaysInfoText.localized), + additionalStats: [total, preLastDay], + analyticsItemId: .dataDays) + } + + func getRewardsStatistics(response: NetworkStatsResponse?) -> NetworkStatsView.Statistics? { + guard let tokens = response?.tokens, + let allocatedPerDay = fixedTimeSeries(timeSeries: tokens.allocatedPerDay) else { + return nil + } + let totalValue = allocatedPerDay.last?.value + let total = NetworkStatsView.AdditionalStats(title: LocalizableString.total(nil).localized, + value: totalValue?.toCompactDecimaFormat ?? "", + info: nil, + analyticsItemId: nil) + + let count = allocatedPerDay.count + let lastTokenValue = allocatedPerDay.last?.value ?? 0.0 + let preLastTokenValue = allocatedPerDay[safe: count - 2]?.value ?? 0.0 + let value = (lastTokenValue - preLastTokenValue).toCompactDecimaFormat ?? "" + let lastDay = NetworkStatsView.AdditionalStats(title: LocalizableString.NetStats.lastRun.localized, + value: "+\(value)", + color: .reward_score_very_high, + info: nil, + analyticsItemId: nil) + + return getStatistics(from: allocatedPerDay, + title: LocalizableString.NetStats.wxmRewardsTitle.localized, + info: (LocalizableString.NetStats.wxmRewardsTitle.localized, LocalizableString.NetStats.totalAllocatedInfoText.localized), + additionalStats: [total, lastDay], + analyticsItemId: .allocatedRewards) + + } + + func getTokenStatistics(response: NetworkStatsResponse?) -> NetworkStatsView.Statistics? { + guard let tokens = response?.tokens else { + return nil + } + + let totalSupplyValue = tokens.totalSupply?.toCompactDecimaFormat ?? "" + let totalSupply = NetworkStatsView.AdditionalStats(title: LocalizableString.NetStats.totalSupply.localized, + value: totalSupplyValue, + info: nil, + analyticsItemId: nil) + + let dailyMintedValue = tokens.dailyMinted?.toCompactDecimaFormat ?? "" + let dailyMinted = NetworkStatsView.AdditionalStats(title: LocalizableString.NetStats.dailyMinted.localized, + value: "+\(dailyMintedValue)", + color: .reward_score_very_high, + info: nil, + analyticsItemId: nil) + + let tokenDescription = LocalizableString.NetStats.wxmTokenDescriptionMarkdown(DisplayedLinks.tokenomics.linkURL).localized.attributedMarkdown + return getStatistics(from: nil, + title: LocalizableString.NetStats.wxmTokenTitle.localized, + description: tokenDescription, + showExternalLinkIcon: true, + externalLinkTapAction: { Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .tokenomics]) }, + info: nil, + additionalStats: [totalSupply, dailyMinted], + analyticsItemId: nil) + } + + func getStationStats(response: NetworkStatsResponse?) -> [NetworkStatsView.StationStatistics]? { + let sections = [(LocalizableString.total(nil).localized, + response?.weatherStations?.onboarded, + (LocalizableString.NetStats.totalWeatherStationsInfoTitle.localized, LocalizableString.NetStats.totalWeatherStationsInfoText.localized), + ParameterValue.total), + (LocalizableString.NetStats.claimed.localized, + response?.weatherStations?.claimed, + (LocalizableString.NetStats.claimedWeatherStationsInfoTitle.localized, LocalizableString.NetStats.claimedWeatherStationsInfoText.localized), + ParameterValue.claimed), + (LocalizableString.NetStats.active.localized, + response?.weatherStations?.active, + (LocalizableString.NetStats.activeWeatherStationsInfoTitle.localized, LocalizableString.NetStats.activeWeatherStationsInfoText.localized), + ParameterValue.active)] + + return sections.compactMap { title, stats, info, analyticsItemId in + guard let stats else { + return nil + } + + return NetworkStatsView.StationStatistics(title: title, + total: stats.total?.localizedFormatted ?? "", + info: info, + details: stats.details?.map { + NetworkStatsView.StationDetails(title: $0.model ?? "", + value: $0.amount?.localizedFormatted ?? "", + percentage: $0.percentage ?? 0.0, + color: .chartSecondary, + url: $0.url)} ?? [], + analyticsItemId: analyticsItemId) + } + } + + func getBuyStationCTA(response: NetworkStatsResponse?) -> NetworkStatsView.StatisticsCTA? { + let description = LocalizableString.NetStats.buyStationCardDescription(Float(response?.tokens?.averageMonthly ?? 0.0)).localized + let buyStationCTA = NetworkStatsView.StatisticsCTA(title: LocalizableString.NetStats.buyStationCardTitle.localized, + description: description, + info: (nil, LocalizableString.NetStats.buyStationCardInfoDescription.localized), + analyticsItemId: .buyStation, + buttonTitle: LocalizableString.NetStats.buyStationCardButtonTitle.localized, + buttonAction: { [weak self] in self?.handleBuyStationTap() }) + + return buyStationCTA + } + + func getManufacturerCTA(response: NetworkStatsResponse?) -> NetworkStatsView.StatisticsCTA? { + let manufacturerCTA = NetworkStatsView.StatisticsCTA(title: LocalizableString.NetStats.manufacturerCTATitle.localized, + description: LocalizableString.NetStats.manufacturerCTADescription.localized, + info: nil, + analyticsItemId: nil, + buttonTitle: LocalizableString.NetStats.manufacturerCTAButtonTitle.localized, + buttonAction: { + if let url = URL(string: DisplayedLinks.contactLink.linkURL) { + UIApplication.shared.open(url) + } + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .openManufacturerContact]) + }) + + return manufacturerCTA + } +} + +private extension NetworkStatsViewModel { + func getStatistics(from days: [NetworkStatsTimeSeries]?, + title: String, + description: AttributedString? = nil, + showExternalLinkIcon: Bool = false, + externalLinkTapAction: VoidCallback? = nil, + info: NetworkStatsView.InfoTuple?, + additionalStats: [NetworkStatsView.AdditionalStats]?, + analyticsItemId: ParameterValue?) -> NetworkStatsView.Statistics { + var chartModel: NetStatsChartViewModel? + var xAxisTuple: NetworkStatsView.XAxisTuple? + var mainText: String? + var dateString: String? + + if let days { + let count = days.count + let lastVal = days.last?.value ?? 0 + let firstVal = days.first?.value ?? 0 + let diff = lastVal - firstVal + mainText = diff.toCompactDecimaFormat ?? "\(diff)" + chartModel = NetStatsChartViewModel(entries: days.enumerated().map { ChartDataEntry(x: Double($0), y: Double($1.value ?? 0)) }) + xAxisTuple = (days.first?.ts?.getFormattedDate(format: .monthLiteralDay) ?? "", days.last?.ts?.getFormattedDate(format: .monthLiteralDay) ?? "") + + if let firstDate = days.first?.ts { + let daysCount = Date.now.days(from: firstDate) + dateString = LocalizableString.NetStats.lastDays(daysCount).localized + } + } + + return NetworkStatsView.Statistics(title: title, + description: description, + showExternalLinkIcon: showExternalLinkIcon, + externalLinkTapAction: externalLinkTapAction, + mainText: mainText, + info: info, + dateString: dateString, + chartModel: chartModel, + xAxisTuple: xAxisTuple, + additionalStats: additionalStats, + analyticsItemId: analyticsItemId) + } + + func fixedTimeSeries(timeSeries: [NetworkStatsTimeSeries]?) -> [NetworkStatsTimeSeries]? { + guard let timeSeries, let lastItem = timeSeries.last else { + return nil + } + + var dropCount = -1 + for item in timeSeries.reversed() { + if lastItem.value == item.value { + dropCount += 1 + } else { + break + } + } + + return Array(timeSeries.dropLast(dropCount)) + } +} diff --git a/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsViewModel.swift b/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsViewModel.swift new file mode 100644 index 00000000..bd2bb465 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Network Statistics/NetworkStatsViewModel.swift @@ -0,0 +1,186 @@ +// +// NetworkStatsViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 9/6/23. +// + +import Foundation +import DomainLayer +import Combine +import Charts +import Toolkit +import SwiftUI + +class NetworkStatsViewModel: ObservableObject { + + @Published var dataDays: NetworkStatsView.Statistics? + @Published var rewards: NetworkStatsView.Statistics? + @Published var token: NetworkStatsView.Statistics? + @Published var stationStats: [NetworkStatsView.StationStatistics]? + @Published var buyStationCTA: NetworkStatsView.StatisticsCTA? + @Published var manufacturerCTA: NetworkStatsView.StatisticsCTA? + @Published var lastUpdatedText: String? + @Published var showInfo: Bool = false + @Published var state: NetworkStatsView.State = .loading + private(set) var failObj: FailSuccessStateObject? + + private(set) var info: (title: String?, description: String)? + + private var isEmpty: Bool { + dataDays == nil && + rewards == nil && + stationStats == nil + } + + private let useCase: NetworkUseCase? + private var cancellables: Set = [] + + init(useCase: NetworkUseCase? = nil) { + self.useCase = useCase + refresh { } + } + + func refresh(completion: @escaping VoidCallback) { + fetchStats(completion: completion) + } + + func handleBuyStationTap() { + HelperFunctions().openUrl(DisplayedLinks.shopLink.linkURL) + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .openShop]) + } + + func handleDetailsActionTap(statistics: NetworkStatsView.StationStatistics, details: NetworkStatsView.StationDetails) { + if let url = details.url { + HelperFunctions().openUrl(url) + } + + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .openStationShop, + .itemId: statistics.analyticsItemId, + .itemListId: .custom(details.title)]) + } + + func handleRetryButtonTap() { + state = .loading + refresh { } + } + + func showInfo(title: String?, description: String, analyticsItemId: ParameterValue?) { + info = (title, description) + showInfo = true + + if let analyticsItemId { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .learnMore, + .itemId: analyticsItemId]) + } + } +} + +extension NetworkStatsViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + + } +} + +private extension NetworkStatsViewModel { + func fetchStats(completion: @escaping VoidCallback) { + do { + try useCase?.getNetworkStats().sink { [weak self] response in + guard let self else { + return + } + if let error = response.error { + let info = error.uiInfo + self.failObj = info.defaultFailObject(type: .networkStats, retryAction: self.handleRetryButtonTap) + state = .fail + } else { + self.updateStats(from: response.value) + self.state = self.isEmpty ? .empty : .content + } + + completion() + } + .store(in: &cancellables) + } catch { + + } + } + + func updateStats(from response: NetworkStatsResponse?) { + self.dataDays = getDataDaysStatistics(response: response) + self.rewards = getRewardsStatistics(response: response) + self.token = getTokenStatistics(response: response) + self.stationStats = getStationStats(response: response) + self.buyStationCTA = getBuyStationCTA(response: response) + self.manufacturerCTA = getManufacturerCTA(response: response) + + if let lastUpdated = response?.lastUpdated { + lastUpdatedText = LocalizableString.lastUpdated(lastUpdated.localizedDateString()).localized + } + } +} + +// MARK: - Mock + +extension NetworkStatsViewModel { + static var mock: NetworkStatsViewModel { + let viewModel = NetworkStatsViewModel() + viewModel.dataDays = NetworkStatsView.Statistics(title: LocalizableString.NetStats.weatherStationDays.localized, + description: nil, + showExternalLinkIcon: false, + externalLinkTapAction: nil, + mainText: "90,2K", + info: (LocalizableString.NetStats.weatherStationDays.localized, "This is info"), + dateString: "Yesterday", + chartModel: .mock(), + xAxisTuple: nil, + analyticsItemId: .dataDays) + + let addtional: [NetworkStatsView.AdditionalStats] = [.init(title: "Total supply", value: "100,000,000", info: ("title", "info"), analyticsItemId: nil), + .init(title: "Daily Minted", value: "20,020", analyticsItemId: nil)] + viewModel.rewards = NetworkStatsView.Statistics(title: LocalizableString.NetStats.wxmRewardsTitle.localized, + description: nil, + showExternalLinkIcon: false, + externalLinkTapAction: nil, + mainText: "90,2K", + info: (LocalizableString.NetStats.wxmRewardsTitle.localized, "This is info"), + dateString: "Yesterday", + chartModel: .mock(), + xAxisTuple: nil, + additionalStats: addtional, + analyticsItemId: .allocatedRewards) + + let stationStats = NetworkStatsView.StationStatistics(title: "Total", + total: "7,823", + details: [.init(title: "WS1000", + value: "5,642", + percentage: 0.2, + color: .crypto, + url: nil), + .init(title: "WS2000", + value: "8,642", + percentage: 1.0, + color: .primary, + url: nil)], + analyticsItemId: .total) + + let stationStats1 = NetworkStatsView.StationStatistics(title: "Claimed", + total: "7,823", + info: ("Claimed", "This is info"), + details: [.init(title: "WS1000", + value: "5,642", + percentage: 0.2, + color: .crypto, + url: nil), + .init(title: "WS2000", + value: "8,642", + percentage: 0.8, + color: .primary, + url: nil)], + analyticsItemId: .claimed) + + viewModel.stationStats = [stationStats, stationStats1] + + return viewModel + } +} diff --git a/PresentationLayer/UI Components/Screens/Network Statistics/StationDetailsGridView.swift b/PresentationLayer/UI Components/Screens/Network Statistics/StationDetailsGridView.swift new file mode 100644 index 00000000..42ad205f --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Network Statistics/StationDetailsGridView.swift @@ -0,0 +1,89 @@ +// +// StationDetailsGridView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 14/6/23. +// + +import SwiftUI +import Toolkit + +struct StationDetailsGridView: View { + let statistics: NetworkStatsView.StationStatistics + var tapDetailsAction: GenericCallback? + + @State private var firstColumnSizes: [SizeWrapper] + @State private var lastColumnSizes: [SizeWrapper] + @State private var totalSize: CGSize = .zero + + private var columns: [GridItem] { + [GridItem(.fixed((firstColumnSizes.max { $0.size.width < $1.size.width }?.size.width)!), + spacing: CGFloat(.smallSpacing), alignment: .leading), + GridItem(.flexible(), + spacing: CGFloat(.smallSpacing), alignment: .leading), + GridItem(.fixed((lastColumnSizes.max { $0.size.width < $1.size.width }?.size.width)!), + alignment: .leading)] + } + + init(statistics: NetworkStatsView.StationStatistics, + tapDetailsAction: GenericCallback? = nil) { + self.statistics = statistics + self.tapDetailsAction = tapDetailsAction + self.firstColumnSizes = (0.. some View { + Group { + HStack(spacing: CGFloat(.minimumSpacing)) { + Text(details.title) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + .lineLimit(1) + .fixedSize() + + Image(asset: .openExternalIcon) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(Color(colorEnum: .text)) + .frame(width: 12.0) + } + .fixedSize() + .sizeObserver(size: $firstColumnSizes[index].size) + + ZStack { + ProgressView(value: max(details.percentage, 0.0), total: 1.0) + .progressViewStyle(ProgressBarStyle(bgColor: Color(colorEnum: .top), + progressColor: Color(colorEnum: details.color))) + + Text(LocalizableString.percentage((details.percentage * 100.0).rounded()).localized) + .font(.system(size: CGFloat(.caption), weight: .semibold)) + .foregroundColor(Color(colorEnum: .text)) + } + .frame(height: 18.0) + + Text(details.value) + .font(.system(size: CGFloat(.caption), weight: .semibold)) + .foregroundColor(Color(colorEnum: .text)) + .fixedSize() + .sizeObserver(size: $lastColumnSizes[index].size) + } + .onTapGesture { + print("tapped \(details)") + tapDetailsAction?(details) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Network Statistics/StatisticsChart.swift b/PresentationLayer/UI Components/Screens/Network Statistics/StatisticsChart.swift new file mode 100644 index 00000000..b99e2007 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Network Statistics/StatisticsChart.swift @@ -0,0 +1,72 @@ +// +// StatisticsChart.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 9/6/23. +// + +import Foundation +import Charts +import SwiftUI + +struct StatisticsChart: UIViewRepresentable { + let chartDataModel: NetStatsChartViewModel + + func makeUIView(context _: Context) -> StatisticsChartView { + let chartView = StatisticsChartView() + chartView.initializeChart(dataModel: chartDataModel) + + return chartView + } + + func updateUIView(_ uiView: StatisticsChartView, context _: Context) { + } +} + +class StatisticsChartView: LineChartView { + func initializeChart(dataModel: NetStatsChartViewModel) { + configureDefault() + let dataSet = LineChartDataSet(entries: dataModel.entries) + + dataSet.circleRadius = 1.0 + dataSet.setCircleColor(UIColor(colorEnum: .chartPrimary)) + dataSet.drawCircleHoleEnabled = false + dataSet.lineWidth = 2.0 + dataSet.setColor(UIColor(colorEnum: .chartPrimary)) + dataSet.mode = .cubicBezier + dataSet.highlightEnabled = false + + let lineData = LineChartData(dataSets: [dataSet]) + lineData.setDrawValues(false) + data = lineData + notifyDataSetChanged() + + animate(xAxisDuration: 0.5, yAxisDuration: 0.5) + } +} + +extension StatisticsChartView { + func configureDefault() { + legend.enabled = false + scaleYEnabled = false + scaleXEnabled = false + + minOffset = 2.0 + + leftAxis.granularityEnabled = true + leftAxis.enabled = false + + rightAxis.enabled = false + + xAxis.enabled = false + + dragYEnabled = false + } +} + +struct Previews_StatisticsChart_Previews: PreviewProvider { + static var previews: some View { + StatisticsChart(chartDataModel: .mock()) + .padding() + } +} diff --git a/PresentationLayer/UI Components/Screens/Profile/ProfileTypes.swift b/PresentationLayer/UI Components/Screens/Profile/ProfileTypes.swift new file mode 100644 index 00000000..1d6ff524 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Profile/ProfileTypes.swift @@ -0,0 +1,36 @@ +// +// ProfileTypes.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 29/11/23. +// + +import Foundation + +enum ProfileField: CaseIterable { + case rewards + case wallet + case settings + + var icon: FontIcon { + switch self { + case .rewards: + .coins + case .wallet: + .wallet + case .settings: + .cog + } + } + + var title: String { + switch self { + case .rewards: + return LocalizableString.Profile.allocatedRewards.localized + case .wallet: + return LocalizableString.Profile.myWallet.localized + case .settings: + return LocalizableString.Profile.prefsSettings.localized + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Profile/ProfileView.swift b/PresentationLayer/UI Components/Screens/Profile/ProfileView.swift new file mode 100644 index 00000000..560c933a --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Profile/ProfileView.swift @@ -0,0 +1,270 @@ +// +// ProfileView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 6/6/22. +// + +import DomainLayer +import SwiftUI +import Toolkit + +struct ProfileView: View { + @StateObject var viewModel: ProfileViewModel + @Binding var isTabBarShowing: Bool + @Binding var tabBarItemsSize: CGSize + + var body: some View { + NavigationContainerView(showBackButton: false) { + ContentView(viewModel: viewModel, tabBarItemsSize: $tabBarItemsSize, isTabBarShowing: $isTabBarShowing) + } + } +} + +private struct ContentView: View { + @StateObject var viewModel: ProfileViewModel + @Binding var tabBarItemsSize: CGSize + @Binding var isTabBarShowing: Bool + @EnvironmentObject var navigationObject: NavigationObject + + var body: some View { + VStack(spacing: 0.0) { + titleView + + TrackableScrollView(offsetObject: viewModel.scrollOffsetObject) { completion in + viewModel.refresh(completion: completion) + } content: { + fieldsView + .padding(.bottom, tabBarItemsSize.height) + .fail(show: $viewModel.isFailed, obj: viewModel.failObj) + } + } + .spinningLoader(show: $viewModel.isLoading, hideContent: true) + .bottomSheet(show: $viewModel.showInfo, fitContent: true) { + bottomInfoView(info: viewModel.info) + } + .onAppear { + navigationObject.title = LocalizableString.Profile.title.localized + navigationObject.subtitle = viewModel.userInfoResponse.email ?? LocalizableString.noEmail.localized + } + .onChange(of: viewModel.userInfoResponse.email) { _ in + navigationObject.subtitle = viewModel.userInfoResponse.email ?? LocalizableString.noEmail.localized + } + .onChange(of: viewModel.isTabBarVisible) { isVisible in + withAnimation { + isTabBarShowing = isVisible + } + } + } + + var fieldsView: some View { + VStack(spacing: CGFloat(.mediumSpacing)) { + ForEach(ProfileField.allCases, id: \.self) { field in + switch field { + case .rewards: + rewardsView + case .wallet: + walletAddressView + case .settings: + settingsView + } + } + } + .padding(CGFloat(.defaultSidePadding)) + } + + var rewardsView: some View { + HStack(spacing: CGFloat(.smallToMediumSpacing)) { + Text(ProfileField.rewards.icon.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.smallTitleFontSize))) + .foregroundColor(Color(colorEnum: .text)) + + VStack(alignment: .leading, spacing: CGFloat(.minimumSpacing)) { + Text(ProfileField.rewards.title) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .primary)) + + Text(viewModel.allocatedRewards) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + + Spacer(minLength: 0.0) + + if viewModel.isClaimAvailable { + Button { + viewModel.handleClaimButtonTap() + } label: { + Text(LocalizableString.Profile.claimButtonTitle.localized) + .padding(.horizontal, CGFloat(.mediumToLargeSidePadding)) + .padding(.vertical, CGFloat(.smallToMediumSidePadding)) + } + .buttonStyle(WXMButtonStyle(fillColor: .blueTint, strokeColor: .clear, fixedSize: true)) + } + } + .WXMCardStyle() + .indication(show: $viewModel.showBuyStation, + borderColor: Color(colorEnum: .primary), + bgColor: Color(colorEnum: .blueTint)) { + CardWarningView(type: .info, + showIcon: false, + title: LocalizableString.Profile.noRewardsWarningTitle.localized, + message: LocalizableString.Profile.noRewardsWarningDescription.localized, + showContentFullWidth: true, + closeAction: nil) { + Button { + viewModel.handleBuyStationTap() + } label: { + ZStack { + HStack { + Text(FontIcon.cart.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.mediumFontSize))) + + Spacer() + } + + Text(LocalizableString.Profile.noRewardsWarningButtonTitle.localized) + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + } + .buttonStyle(WXMButtonStyle.filled()) + } + } + .wxmShadow() + } + + var walletAddressView: some View { + Button { + Router.shared.navigateTo(.wallet(ViewModelsFactory.getMyWalletViewModel())) + } label: { + HStack(spacing: CGFloat(.smallToMediumSpacing)) { + Text(ProfileField.wallet.icon.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.smallTitleFontSize))) + .foregroundColor(Color(colorEnum: .text)) + + VStack(alignment: .leading, spacing: CGFloat(.minimumSpacing)) { + Text(ProfileField.wallet.title) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .primary)) + + if let walletAddress = viewModel.userInfoResponse.wallet?.address?.walletAddressMaskString, !walletAddress.isEmpty { + Text(walletAddress) + .font(.system(size: CGFloat(.normalFontSize), weight: .medium)) + .WXMCardStyle(backgroundColor: Color(colorEnum: .blueTint), + foregroundColor: Color(colorEnum: .text), + insideHorizontalPadding: CGFloat(.mediumSidePadding), + insideVerticalPadding: CGFloat(.smallSidePadding), + cornerRadius: CGFloat(.buttonCornerRadius)) + } else { + Text(LocalizableString.Profile.noWalletAddressDescription.localized) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + } + + Spacer() + } + .WXMCardStyle() + .indication(show: $viewModel.showMissingWalletError, + borderColor: Color(colorEnum: .error), + bgColor: Color(colorEnum: .errorTint)) { + CardWarningView(type: .error, + title: LocalizableString.Profile.noWalletAddressErrorTitle.localized, + message: LocalizableString.Profile.noWalletAddressErrorDescription.localized, + closeAction: nil, + content: { EmptyView() }) + } + .wxmShadow() + } + .buttonStyle(.plain) + } + + var settingsView: some View { + Button { + Router.shared.navigateTo(.settings(ViewModelsFactory.getSettingsViewModel(userId: viewModel.userInfoResponse.id ?? ""))) + } label: { + HStack(spacing: CGFloat(.smallToMediumSpacing)) { + Text(ProfileField.settings.icon.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.smallTitleFontSize))) + .foregroundColor(Color(colorEnum: .text)) + + VStack(alignment: .leading, spacing: CGFloat(.minimumSpacing)) { + Text(ProfileField.settings.title) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .primary)) + + Text(LocalizableString.Profile.prefsSettingsDescription.localized) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + + Spacer() + } + .WXMCardStyle() + .wxmShadow() + } + .buttonStyle(.plain) + } + + var titleView: some View { + VStack { + PercentageGridLayoutView(firstColumnPercentage: 0.5) { + Group { + tokenView(title: LocalizableString.Profile.totalEarned.localized, + value: viewModel.totalEarned) { + viewModel.handleTotalEarnedInfoTap() + } + .padding(.trailing, CGFloat(.smallToMediumSpacing)/2.0) + + tokenView(title: LocalizableString.Profile.totalClaimed.localized, + value: viewModel.totalClaimed) { + viewModel.handleTotalClaimedInfoTap() + } + .padding(.leading, CGFloat(.smallToMediumSpacing)/2.0) + } + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.bottom, CGFloat(.defaultSidePadding)) + .background { + Color(colorEnum: .top) + } + .cornerRadius(CGFloat(.cardCornerRadius), + corners: [.bottomLeft, .bottomRight]) + .wxmShadow() + .animation(.easeIn, value: viewModel.totalEarned) + } + } + + func tokenView(title: String, value: String, infoAction: @escaping VoidCallback) -> some View { + VStack(spacing: CGFloat(.minimumSpacing)) { + HStack { + Text(title) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + Spacer() + + Button(action: infoAction) { + Text(FontIcon.infoCircle.rawValue) + .font(.fontAwesome(font: .FAPro, size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + } + + } + + HStack { + Text(value) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .blueTint)) + } +} + +struct Previews_ProfileView_Previews: PreviewProvider { + static var previews: some View { + ProfileView(viewModel: ViewModelsFactory.getProfileViewModel(), isTabBarShowing: .constant(true), tabBarItemsSize: .constant(.zero)) + } +} diff --git a/PresentationLayer/UI Components/Screens/Profile/ProfileViewModel.swift b/PresentationLayer/UI Components/Screens/Profile/ProfileViewModel.swift new file mode 100644 index 00000000..f62d167f --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Profile/ProfileViewModel.swift @@ -0,0 +1,190 @@ +// +// ProfileViewModel.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 7/6/22. +// + +import Combine +import DomainLayer +import SwiftUI +import Toolkit + +class ProfileViewModel: ObservableObject { + private final let meUseCase: MeUseCase + private var cancellableSet: Set = [] + private let tabBarVisibilityHandler: TabBarVisibilityHandler + + let scrollOffsetObject: TrackableScrollOffsetObject + private var userRewardsResponse: NetworkUserRewardsResponse? { + didSet { + updateRewards() + } + } + @Published var showInfo: Bool = false + private(set) var info: (title: String?, description: String)? + @Published var userInfoResponse = NetworkUserInfoResponse() { + didSet { + updateUserInfoValues() + } + } + @Published var showBuyStation: Bool = true + @Published var showMissingWalletError: Bool = false + @Published var isTabBarVisible: Bool = true + @Published var totalEarned: String = 0.0.toWXMTokenPrecisionString + @Published var totalClaimed: String = 0.0.toWXMTokenPrecisionString + @Published var allocatedRewards: String = LocalizableString.Profile.noRewardsDescription.localized + @Published var isClaimAvailable: Bool = false + @Published var isLoading: Bool = true + @Published var isFailed: Bool = false + var failObj: FailSuccessStateObject? + + public init(meUseCase: MeUseCase) { + self.meUseCase = meUseCase + scrollOffsetObject = .init() + tabBarVisibilityHandler = TabBarVisibilityHandler(scrollOffsetObject: self.scrollOffsetObject) + tabBarVisibilityHandler.$isTabBarShowing.assign(to: &$isTabBarVisible) + + updateRewards() + + MainScreenViewModel.shared.$isWalletMissing.assign(to: &$showMissingWalletError) + + self.refresh { } + } + + func refresh(completion: @escaping VoidCallback) { + Task { @MainActor [weak self] in + defer { + self?.isLoading = false + completion() + } + + if let userInfoError = await self?.getUserInfo() { + self?.failObj = userInfoError.uiInfo.defaultFailObject(type: .profile) { + self?.isFailed = false + self?.isLoading = true + self?.refresh { } + } + self?.isFailed = true + + return + } + + if let rewardsError = await self?.fetchUserRewards(), + rewardsError.backendError?.code != FailAPICodeEnum.walletAddressNotFound.rawValue, + case let info = rewardsError.uiInfo, + let message = info.description?.attributedMarkdown { + Toast.shared.show(text: message) + + return + } + } + } + + func handleBuyStationTap() { + HelperFunctions().openUrl(DisplayedLinks.shopLink.linkURL) + } + + func handleTotalEarnedInfoTap() { + info = (LocalizableString.Profile.totalEarnedInfoTitle.localized, LocalizableString.Profile.totalEarnedInfoDescription.localized) + showInfo = true + } + + func handleTotalClaimedInfoTap() { + info = (LocalizableString.Profile.totalClaimedInfoTitle.localized, LocalizableString.Profile.totalClaimedInfoDescription.localized) + showInfo = true + } + + func handleClaimButtonTap() { + let url = DisplayedLinks.claimToken.linkURL + var params: [DisplayLinkParams: String] = [:] + if let activeTheme = MainScreenViewModel.shared.deviceActiveTheme { + params += [.theme: activeTheme.rawValue] + } + if let amount = userRewardsResponse?.available { + params += [.amount: amount] + } + + if let walletAddress = userInfoResponse.wallet?.address { + params += [.wallet: walletAddress] + } + + if let urlScheme = Bundle.main.urlScheme { + params += [.redirectUrl: "\(urlScheme)://\(DeepLinkHandler.tokenClaim)"] + } + + let callback: DeepLinkHandler.QueryParamsCallBack = { [weak self] params in + if let amount = params?[DisplayLinkParams.claimedAmount.rawValue] { + self?.updateRewards(additionalClaimed: amount) + } + } + Router.shared.navigateTo(.webView(LocalizableString.Profile.claimFlowTitle.localized, url, params, callback)) + } + + @MainActor + func getUserInfo() async -> NetworkErrorResponse? { + do { + let userInfoResponse = try await meUseCase.getUserInfo().toAsync() + if let error = userInfoResponse.error { + return error + } + + if let value = userInfoResponse.value { + self.userInfoResponse = value + } + + return nil + } catch { + print(error) + return nil + } + } +} + +private extension ProfileViewModel { + @MainActor + func fetchUserRewards() async -> NetworkErrorResponse? { + guard let address = userInfoResponse.wallet?.address else { + return nil + } + + do { + let userRewardsResponse = try await meUseCase.getUserRewards(wallet: address).toAsync() + if let error = userRewardsResponse.error { + return error + } + + if let value = userRewardsResponse.value { + self.userRewardsResponse = value + } + + return nil + } catch { + print(error) + return nil + } + } + + func updateRewards(additionalClaimed: String? = nil) { + let cumulative = userRewardsResponse?.cumulativeAmount?.toEthDouble ?? 0.0 + totalEarned = cumulative.toWXMTokenPrecisionString + " " + StringConstants.wxmCurrency + + let claimed = (userRewardsResponse?.totalClaimed?.toEthDouble ?? 0.0) + (additionalClaimed?.toEthDouble ?? 0.0) + totalClaimed = claimed.toWXMTokenPrecisionString + " " + StringConstants.wxmCurrency + + let allocated = (userRewardsResponse?.available?.toEthDouble ?? 0.0) - (additionalClaimed?.toEthDouble ?? 0.0) + isClaimAvailable = allocated > 0.0 + let noRewardsString = LocalizableString.Profile.noRewardsDescription.localized + let valueString = allocated.toWXMTokenPrecisionString + " " + StringConstants.wxmCurrency + allocatedRewards = allocated == 0.0 ? noRewardsString : valueString + } + + func updateUserInfoValues() { + Task { @MainActor [weak self] in + guard let self = self else { + return + } + self.showBuyStation = await !self.meUseCase.hasOwnedDevices() + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Reboot Station/RebootStationView.swift b/PresentationLayer/UI Components/Screens/Reboot Station/RebootStationView.swift new file mode 100644 index 00000000..d3dcff5c --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Reboot Station/RebootStationView.swift @@ -0,0 +1,74 @@ +// +// RebootStationView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 9/3/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct RebootStationView: View { + @StateObject var viewModel: RebootStationViewModel + @EnvironmentObject var navigationObject: NavigationObject + @Environment(\.dismiss) private var dismiss + private let mainVM: MainScreenViewModel = .shared + + var body: some View { + ZStack { + Color(colorEnum: .top) + .ignoresSafeArea() + HStack { + Spacer() + + VStack { + Spacer() + + switch viewModel.state { + case .reboot: + DeviceUpdatesLoadingView(title: LocalizableString.rebootingStation.localized, + subtitle: nil, + steps: viewModel.steps, + currentStepIndex: $viewModel.currentStepIndex, + progress: .constant(nil)) + case let .failed(obj): + FailView(obj: obj) + case let .success(obj): + SuccessView(obj: obj) + } + + Spacer() + } + .animation(.easeIn, value: viewModel.state) + + Spacer() + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + } + .onAppear { + navigationObject.willDismissAction = { [weak viewModel] in + viewModel?.navigationBackButtonTapped() + } + viewModel.mainVM = mainVM + navigationObject.title = LocalizableString.deviceInfoStationReboot.localized + + Logger.shared.trackScreen(.rebootStation, + parameters: [.itemId: .custom(viewModel.device.id ?? "")]) + } + .onChange(of: viewModel.dismissToggle) { _ in + dismiss() + } + } +} + +struct RebootStationView_Previews: PreviewProvider { + static var previews: some View { + var device = DeviceDetails.emptyDeviceDetails + device.profile = .helium + + return NavigationContainerView { + RebootStationView(viewModel: RebootStationViewModel(device: device, useCase: nil)) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Reboot Station/RebootStationViewModel.swift b/PresentationLayer/UI Components/Screens/Reboot Station/RebootStationViewModel.swift new file mode 100644 index 00000000..a38c4b54 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Reboot Station/RebootStationViewModel.swift @@ -0,0 +1,165 @@ +// +// RebootStationViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 9/3/23. +// + +import Foundation +import DomainLayer +import Combine + +class RebootStationViewModel: ObservableObject { + @Published var state: State = .reboot + @Published private(set) var steps: [StepsView.Step] = Step.allCases.map { StepsView.Step(text: $0.description, isCompleted: false) } + @Published var currentStepIndex: Int? + @Published private(set) var dismissToggle: Bool = false + + var mainVM: MainScreenViewModel? + + private let useCase: DeviceInfoUseCase? + let device: DeviceDetails + private var cancellables: Set = [] + + init(device: DeviceDetails, useCase: DeviceInfoUseCase?) { + self.device = device + self.useCase = useCase + startReboot() + } + + func startReboot() { + useCase?.rebootStation(device: device).sink { [weak self] state in + guard let self else { + return + } + DispatchQueue.main.async { + switch state { + case .connect: + self.state = .reboot + self.currentStepIndex = 0 + case .rebooting: + self.state = .reboot + self.currentStepIndex = 1 + case .failed(let rebootError): + self.handleRebootError(rebootError) + case .finished: + let obj = FailSuccessStateObject(type: .rebootStation, + title: LocalizableString.deviceInfoStationRebooted.localized, + subtitle: LocalizableString.deviceInfoStationRebootedDescription.localized.attributedMarkdown, + cancelTitle: nil, + retryTitle: LocalizableString.deviceInfoStationBackToSettings.localized, + contactSupportAction: nil, + cancelAction: nil, + retryAction: { [weak self] in self?.dismissToggle.toggle() }) + self.state = .success(obj) + } + self.updateSteps() + + print("Reboot State \(state)") + } + }.store(in: &cancellables) + } + + func navigationBackButtonTapped() { + + } +} + +extension RebootStationViewModel { + enum State: Equatable { + static func == (lhs: RebootStationViewModel.State, rhs: RebootStationViewModel.State) -> Bool { + switch (lhs, rhs) { + case (.reboot, .reboot): + return true + case (.failed, .failed): + return true + case (.success, .success): + return true + default: return false + } + } + + case reboot + case failed(FailSuccessStateObject) + case success(FailSuccessStateObject) + } +} + +private extension RebootStationViewModel { + enum Step: Int, CaseIterable, CustomStringConvertible { + case connect = 0 + case reboot + + var description: String { + switch self { + case .connect: + return LocalizableString.connectToStation.localized + case .reboot: + return LocalizableString.rebootingStation.localized + } + } + } + + func updateSteps() { + guard let currentStepIndex else { + (0 ..< steps.count).forEach { index in + steps[index].setCompleted(false) + } + return + } + + (0 ..< currentStepIndex).forEach { index in + steps[index].setCompleted(index < currentStepIndex) + } + } + + func handleRebootError(_ rebootError: RebootError) { + let title: String = LocalizableString.deviceInfoStationRebootFailed.localized + let subtitle: String + let cancelTitle = LocalizableString.cancel.localized + let retryTitle = LocalizableString.retry.localized + let contactSupportAction: () -> Void + let cancelAction: () -> Void = { [weak self] in self?.dismissToggle.toggle()} + let retryAction: () -> Void = { [weak self] in self?.startReboot() } + + switch rebootError { + case .bluetooth(let bluetoothState): + subtitle = bluetoothState.errorDescription ?? "" + contactSupportAction = { [weak self] in + HelperFunctions().openContactSupport(successFailureEnum: .rebootStation, + email: self?.mainVM?.userInfo?.email, + serialNumber: self?.device.label, + errorString: subtitle) + } + case .notInRange: + subtitle = LocalizableString.FirmwareUpdate.stationNotInRangeDescription.localized + contactSupportAction = { [weak self] in + HelperFunctions().openContactSupport(successFailureEnum: .rebootStation, + email: self?.mainVM?.userInfo?.email, + serialNumber: self?.device.label, + errorString: LocalizableString.FirmwareUpdate.stationNotInRangeTitle.localized) + } + case .connect: + let linkString = "[\(LocalizableString.ClaimDevice.failedTroubleshootingTextLinkTitle.localized)](\(DisplayedLinks.heliumTroubleshooting.linkURL))" + subtitle = LocalizableString.FirmwareUpdate.failedStationConnectionDescription(linkString, LocalizableString.ClaimDevice.failedTextLinkTitle.localized).localized + contactSupportAction = { [weak self] in + HelperFunctions().openContactSupport(successFailureEnum: .rebootStation, + email: self?.mainVM?.userInfo?.email, + serialNumber: self?.device.label, + errorString: LocalizableString.FirmwareUpdate.failedToConnectError.localized) + } + case .unknown: + return + } + + let obj = FailSuccessStateObject(type: .rebootStation, + title: title, + subtitle: subtitle.attributedMarkdown, + cancelTitle: cancelTitle, + retryTitle: retryTitle, + contactSupportAction: contactSupportAction, + cancelAction: cancelAction, + retryAction: retryAction) + self.state = .failed(obj) + } +} diff --git a/PresentationLayer/UI Components/Screens/Select Station Location/SelectStationLocationView.swift b/PresentationLayer/UI Components/Screens/Select Station Location/SelectStationLocationView.swift new file mode 100644 index 00000000..c412272c --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Select Station Location/SelectStationLocationView.swift @@ -0,0 +1,205 @@ +// +// SelectStationLocationView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 14/12/23. +// + +import SwiftUI +import DomainLayer + +struct SelectStationLocationView: View { + @StateObject var viewModel: SelectStationLocationViewModel + @EnvironmentObject var navigationObject: NavigationObject + @State private var annotationSize: CGSize = .zero + @State private var showSearchResults: Bool = false + + var body: some View { + ZStack { + Color(colorEnum: .bg) + .ignoresSafeArea() + + VStack(spacing: 0.0) { + GeometryReader { proxy in + ZStack { + MapBoxClaimDeviceView(location: $viewModel.selectedCoordinate, + annotationTitle: Binding(get: { viewModel.selectedDeviceLocation?.name }, set: { _ in }), + areLocationServicesAvailable: false, + geometryProxyForFrameOfMapView: proxy.frame(in: .local)) + .cornerRadius(CGFloat(.cardCornerRadius), corners: [.topLeft, .topRight]) + + searchArea + } + } + + VStack(spacing: CGFloat(.defaultSpacing)) { + acknowledgementView + + CardWarningView(message: LocalizableString.SelectStationLocation.warningText(DisplayedLinks.polAlgorithm.linkURL).localized, + closeAction: nil, + content: { EmptyView() }) + + Button { + viewModel.handleConfirmTap() + } label: { + Text(LocalizableString.SelectStationLocation.buttonTitle.localized) + } + .buttonStyle(WXMButtonStyle.filled()) + .disabled(!viewModel.termsAccepted) + } + .WXMCardStyle(cornerRadius: 0.0) + .cornerRadius(CGFloat(.cardCornerRadius), corners: [.bottomLeft, .bottomRight]) + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.bottom, CGFloat(.defaultSidePadding)) + .cornerRadius(CGFloat(.cardCornerRadius), corners: [.topLeft, .topRight]) + .wxmShadow() + .onTapGesture { + hideKeyboard() + } + .ignoresSafeArea(.keyboard, edges: .bottom) + } + .onAppear { + navigationObject.title = LocalizableString.SelectStationLocation.title.localized + navigationObject.subtitle = viewModel.device.displayName + navigationObject.navigationBarColor = Color(colorEnum: .bg) + } + } +} + +private extension SelectStationLocationView { + @ViewBuilder + var acknowledgementView: some View { + HStack(alignment: .top, spacing: CGFloat(.smallSpacing)) { + Toggle(LocalizableString.SelectStationLocation.termsText.localized, + isOn: $viewModel.termsAccepted) + .labelsHidden() + .toggleStyle(WXMToggleStyle.Default) + + Text(LocalizableString.SelectStationLocation.termsText.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + var markerAnnotation: some View { + HStack(spacing: CGFloat(.mediumSpacing)) { + Image(asset: .globe) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + + Text(viewModel.selectedDeviceLocation?.name ?? "") + .font(.system(size: CGFloat(.normalFontSize))) + } + .disabled(true) + .WXMCardStyle() + .wxmShadow() + } + + @ViewBuilder + var searchArea: some View { + VStack(spacing: 0.0) { + HStack { + searchField + + Button { + viewModel.moveToUserLocation() + } label: { + Image(asset: .detectLocation) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + } + .frame(width: 50, height: 50) + .background( + RoundedRectangle(cornerRadius: 10) + .style(withStroke: Color(colorEnum: .midGrey), lineWidth: 1, fill: Color(colorEnum: .top)) + ) + + } + Spacer(minLength: 0.0) + if showSearchResults, !viewModel.searchResults.isEmpty { + searchResults + } + } + .padding(CGFloat(.defaultSidePadding)) + } + + @ViewBuilder + var searchField: some View { + HStack { + UberTextField( + text: $viewModel.searchTerm, + hint: .constant(LocalizableString.SelectStationLocation.searchPlaceholder.localized), + onEditingChanged: { _, isFocused in showSearchResults = isFocused }, + configuration: { + $0.font = UIFont.systemFont(ofSize: FontSizeEnum.normalFontSize.sizeValue) + $0.horizontalPadding = CGFloat(.mediumSidePadding) + $0.textColor = UIColor(colorEnum: .text) + } + ) + + WXMDivider() + .padding(.vertical, 4) + + Image(asset: .search) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .text)) + .padding(.trailing, 9) + } + .frame(height: 50) + .background( + RoundedRectangle(cornerRadius: CGFloat(.buttonCornerRadius)) + .style(withStroke: Color(colorEnum: .midGrey), lineWidth: 1.0, fill: Color(colorEnum: .top)) + ) + .cornerRadius(CGFloat(.buttonCornerRadius)) + } + + @ViewBuilder + var searchResults: some View { + ScrollViewReader { proxy in + List(viewModel.searchResults, id: \.self) { searchResult in + Button { + viewModel.handleSearchResultTap(result: searchResult) + hideKeyboard() + showSearchResults = false + } label: { + AttributedLabel(attributedText: .constant( + searchResult.attributedDescriptionForQuery(viewModel.searchTerm) + )) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .padding(.vertical, 5) + } + .buttonStyle(.borderless) // This modifier is necessary for iOS 15 builds. In general buttons inside lists are buggy. SwiftUI 🤌! + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .listRowBackground(Color(colorEnum: .top)) + } + .onChange(of: viewModel.searchResults) { newValue in + if let firstLocation = newValue.first { + proxy.scrollTo(firstLocation, anchor: .top) + } + } + .environment(\.defaultMinListRowHeight, 0) + .listStyle(.plain) + .cornerRadius(10) + .background( + RoundedRectangle(cornerRadius: 10) + .style(withStroke: Color(colorEnum: .midGrey), lineWidth: 1, fill: Color(colorEnum: .top)) + ) + .animation(nil) + } + } + +} + +#Preview { + let device = DeviceDetails.mockDevice + let viewModel = ViewModelsFactory.getSelectLocationViewModel(device: device, + followState: .init(deviceId: device.id!, relation: .owned), + delegate: nil) + return NavigationContainerView { + SelectStationLocationView(viewModel: viewModel) + } +} diff --git a/PresentationLayer/UI Components/Screens/Select Station Location/SelectStationLocationViewModel.swift b/PresentationLayer/UI Components/Screens/Select Station Location/SelectStationLocationViewModel.swift new file mode 100644 index 00000000..6a109d96 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Select Station Location/SelectStationLocationViewModel.swift @@ -0,0 +1,142 @@ +// +// SelectStationLocationViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 14/12/23. +// + +import Foundation +import DomainLayer +import CoreLocation +import Combine + +protocol SelectStationLocationViewModelDelegate: AnyObject { + func locationUpdated(with device: DeviceDetails) +} + +class SelectStationLocationViewModel: ObservableObject { + + let device: DeviceDetails + let deviceLocationUseCase: DeviceLocationUseCase + let meUseCase: MeUseCase + @Published var termsAccepted: Bool = false + @Published var selectedCoordinate: CLLocationCoordinate2D + @Published var searchTerm: String = "" + @Published private(set) var selectedDeviceLocation: DeviceLocation? + @Published private(set) var searchResults: [DeviceLocationSearchResult] = [] + + private var latestTask: Cancellable? + private var cancellableSet: Set = .init() + private weak var delegate: SelectStationLocationViewModelDelegate? + + init(device: DeviceDetails, + deviceLocationUseCase: DeviceLocationUseCase, + meUseCase: MeUseCase, + delegate: SelectStationLocationViewModelDelegate?) { + self.device = device + self.deviceLocationUseCase = deviceLocationUseCase + self.meUseCase = meUseCase + self.selectedCoordinate = device.location?.toCLLocationCoordinate2D() ?? .init() + self.delegate = delegate + + $selectedCoordinate + .debounce(for: 1.0, scheduler: DispatchQueue.main) + .sink { [weak self] _ in + self?.getLocationFromCoordinate() + } + .store(in: &cancellableSet) + + $searchTerm + .debounce(for: 1.0, scheduler: DispatchQueue.main) + .sink { [weak self] newValue in + self?.deviceLocationUseCase.searchFor(newValue) + } + .store(in: &cancellableSet) + + deviceLocationUseCase.searchResults.sink { [weak self] results in + self?.searchResults = results + }.store(in: &cancellableSet) + } + + func handleConfirmTap() { + let isValid = deviceLocationUseCase.areLocationCoordinatesValid(LocationCoordinates.fromCLLocationCoordinate2D(selectedCoordinate)) + guard isValid else { + Toast.shared.show(text: LocalizableString.invalidLocationErrorText.localized.attributedMarkdown ?? "") + return + } + + setLocation() + } + + func handleSearchResultTap(result: DeviceLocationSearchResult) { + latestTask?.cancel() + latestTask = deviceLocationUseCase.locationFromSearchResult(result).sink { [weak self] location in + self?.selectedCoordinate = location.coordinates.toCLLocationCoordinate2D() + } + } + + func moveToUserLocation() { + Task { + let result = await deviceLocationUseCase.getUserLocation() + DispatchQueue.main.async { + switch result { + case .success(let coordinates): + self.selectedCoordinate = coordinates + case .failure(let error): + switch error { + case .locationNotFound: + Toast.shared.show(text: error.description.attributedMarkdown ?? "") + case .permissionDenied: + let title = LocalizableString.ClaimDevice.confirmLocationNoAccessToServicesTitle.localized + let message = LocalizableString.ClaimDevice.confirmLocationNoAccessToServicesText.localized + let alertObj = AlertHelper.AlertObject.getNavigateToSettingsAlert(title: title, + message: message) + AlertHelper().showAlert(alertObj) + } + } + } + } + } + +} + +private extension SelectStationLocationViewModel { + func getLocationFromCoordinate() { + latestTask?.cancel() + latestTask = deviceLocationUseCase.locationFromCoordinates(LocationCoordinates.fromCLLocationCoordinate2D(selectedCoordinate)).sink { [weak self] location in + self?.selectedDeviceLocation = location + } + } + + func setLocation() { + guard let deviceId = device.id else { + return + } + + LoaderView.shared.show() + do { + try meUseCase.setDeviceLocationById(deviceId: deviceId, lat: selectedCoordinate.latitude, lon: selectedCoordinate.longitude).sink { [weak self] reslut in + LoaderView.shared.dismiss { + switch reslut { + case .success(let device): + self?.delegate?.locationUpdated(with: device) + Router.shared.pop() + case .failure(let error): + let info = error.uiInfo + if let message = info.description?.attributedMarkdown { + Toast.shared.show(text: message) + } + } + } + }.store(in: &cancellableSet) + } catch { + print(error) + } + } +} + +extension SelectStationLocationViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + hasher.combine("\(device.id)") + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/Components/DeleteAccountModalView.swift b/PresentationLayer/UI Components/Screens/Settings/Components/DeleteAccountModalView.swift new file mode 100644 index 00000000..215a68ab --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/Components/DeleteAccountModalView.swift @@ -0,0 +1,83 @@ +// +// DeleteAccountModalView.swift +// PresentationLayer +// +// Created by Panagiotis Palamidas on 21/10/22. +// + +import SwiftUI + +struct DeleteAccountModalView: View { + @EnvironmentObject var viewModel: DeleteAccountViewModel + + var body: some View { + ZStack { + VStack { + passwordInput + installLaterToggle + deleteButton + } + .padding() + .fixedSize(horizontal: false, vertical: true) + } + .background { + RoundedRectangle(cornerRadius: CGFloat(.buttonCornerRadius)) + .foregroundColor(Color(colorEnum: .top)) + .ignoresSafeArea() + .wxmShadow() + } + + } + + var passwordInput: some View { + VStack { + BaseTextField(input: $viewModel.password, + textFieldStyle: .password, + error: viewModel.passwordHasError ? TextFieldError.invalidPassword : nil, + isInputForDeleteAccount: true) + .padding(.bottom, CGFloat(.defaultSidePadding)) + } + } + + var installLaterToggle: some View { + HStack(alignment: .top) { + Toggle( + LocalizableString.DeleteAccount.understandDeletionTerms.localized, + isOn: $viewModel.isToggleOn + ) + .labelsHidden() + .toggleStyle( + WXMToggleStyle.Default + ) + termsText + Spacer() + } + .padding(.bottom, 10) + } + + var termsText: some View { + VStack(alignment: .leading) { + Text(LocalizableString.DeleteAccount.understandDeletionTerms.localized) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + } + + var deleteButton: some View { + Button { + viewModel.tryLoginAndDeleteAccount() + } label: { + Text(LocalizableString.DeleteAccount.deleteAccount.localized) + } + .buttonStyle(WXMButtonStyle(textColor: .top, + textColorDisabled: .midGrey, + fillColor: .error, + fillColorDisabled: .errorTint, + strokeColor: .error, + strokeColorDisabled: .errorTint)) + .disabled(!viewModel.isToggleOn || + viewModel.password.isEmpty || + viewModel.isValidatingPassword) + + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/Components/FailedDeleteView.swift b/PresentationLayer/UI Components/Screens/Settings/Components/FailedDeleteView.swift new file mode 100644 index 00000000..6d9e4483 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/Components/FailedDeleteView.swift @@ -0,0 +1,122 @@ +// +// FailedDeleteView.swift +// PresentationLayer +// +// Created by Panagiotis Palamidas on 25/10/22. +// + +import SwiftUI + +struct FailedDeleteView: View { + @EnvironmentObject var viewModel: DeleteAccountViewModel + @Environment(\.presentationMode) var presentationMode + + var body: some View { + ZStack { + VStack { + Image("DeleteFailureIcon") + failureInfo + constactSupport + } + .navigationBarBackButtonHidden(true) + navigationButtons + } + .padding(.horizontal) + } + + @ViewBuilder + var failureInfo: some View { + failureTitle + failureText + } + + var failureTitle: some View { + Text(LocalizableString.DeleteAccount.failureTitle.localized) + .font(.system(size: CGFloat(.largeTitleFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .bold() + .padding(.top, CGFloat(.defaultSidePadding)) + } + + var failureText: some View { + Text(LocalizableString.DeleteAccount.failedDescription.localized) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .multilineTextAlignment(.center) + .padding(.top, CGFloat(.defaultSidePadding)) + .padding(.horizontal, CGFloat(.XLSidePadding)) + } + + @ViewBuilder + var constactSupport: some View { + contactSupportText + contactSupportButton + } + + var contactSupportText: some View { + Text(LocalizableString.DeleteAccount.failedContactSupport(viewModel.deleteFailureErrorMessage).localized) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .multilineTextAlignment(.center) + .padding(.top, CGFloat(.defaultSidePadding)) + .padding(.horizontal, CGFloat(.XLSidePadding)) + } + + var contactSupportButton: some View { + Button { + viewModel.contactSupport() + } label: { + Text(LocalizableString.contactSupport.localized) + } + .buttonStyle(WXMButtonStyle()) + .padding(.top, CGFloat(.defaultSidePadding)) + } + + var navigationButtons: some View { + VStack { + Spacer() + HStack { + Spacer() + cancelDeletionButton + Spacer() + retryDeletionButton + Spacer() + } + .padding(.bottom, CGFloat(.defaultSidePadding)) + } + } + + var cancelDeletionButton: some View { + Button { + presentationMode.wrappedValue.dismiss() + } label: { + cancelDeletionButtonStyle + } + .buttonStyle(WXMButtonStyle()) + } + + var cancelDeletionButtonStyle: some View { + Text(LocalizableString.DeleteAccount.cancelDeletion.localized) + } + + var retryDeletionButton: some View { + Button { + viewModel.tryDeleteAccount() + } label: { + retryDeletionButtonStyle + } + .buttonStyle(WXMButtonStyle.filled()) + } + + var retryDeletionButtonStyle: some View { + Text(LocalizableString.DeleteAccount.retryDeletion.localized) + } +} + +struct FailedDeleteView_Previews: PreviewProvider { + static var previews: some View { + FailedDeleteView() + .environmentObject(SwinjectHelper.shared.getContainerForSwinject().resolve(DeleteAccountViewModel.self)!) + + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/Components/SettingsButtonView.swift b/PresentationLayer/UI Components/Screens/Settings/Components/SettingsButtonView.swift new file mode 100644 index 00000000..21086911 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/Components/SettingsButtonView.swift @@ -0,0 +1,75 @@ +// +// SettingsButtonView.swift +// PresentationLayer +// +// Created by danaekikue on 17/6/22. +// + +import SwiftUI + +struct SettingsButtonView: View { + let settingsCase: SettingsEnum + let settingCaption: String + @Binding var unitCaseModal: SettingsEnum + @Binding var isShowingUnitsOverlay: Bool + @Binding var switchValue: Bool? + var action: () -> Void + + init(settingsCase: SettingsEnum, settingCaption: String, + unitCaseModal: Binding = .constant(.temperature), + isShowingUnitsOverlay: Binding = .constant(false), + switchValue: Binding = .constant(nil), + action: @escaping () -> Void = {}) { + self.settingsCase = settingsCase + self.settingCaption = settingCaption + _unitCaseModal = unitCaseModal + _isShowingUnitsOverlay = isShowingUnitsOverlay + self.action = action + _switchValue = switchValue + } + + var body: some View { + Button { + action() + isShowingUnitsOverlay = true + unitCaseModal = settingsCase + } label: { + settingsButton + } + } + + var settingsButton: some View { + HStack { + VStack(alignment: .leading) { + title + if !settingCaption.isEmpty { + caption + } + } + Spacer() + + if let switchValue { + Toggle("", isOn: Binding(get: { switchValue }, set: { self.switchValue = $0 })) + .labelsHidden() + .toggleStyle( + WXMToggleStyle.Default + ) + } + } + .contentShape(Rectangle()) + } + + var title: some View { + Text(settingsCase.settingsTitle) + .font(.system(size: CGFloat(.largeTitleFontSize))) + .foregroundColor(Color(colorEnum: .text)) + .multilineTextAlignment(.leading) + } + + var caption: some View { + Text(settingCaption) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .darkGrey)) + .multilineTextAlignment(.leading) + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/Components/SettingsSectionTitle.swift b/PresentationLayer/UI Components/Screens/Settings/Components/SettingsSectionTitle.swift new file mode 100644 index 00000000..d138f7ed --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/Components/SettingsSectionTitle.swift @@ -0,0 +1,18 @@ +// +// SettingsSectionTitle.swift +// PresentationLayer +// +// Created by danaekikue on 17/6/22. +// + +import SwiftUI + +struct SettingsSectionTitle: View { + let title: SettingsEnum + + var body: some View { + Text(title.sectionTitle) + .foregroundColor(Color(colorEnum: .primary)) + .font(.system(size: CGFloat(.mediumFontSize))) + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/Components/SuccessfulDeleteView.swift b/PresentationLayer/UI Components/Screens/Settings/Components/SuccessfulDeleteView.swift new file mode 100644 index 00000000..35807480 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/Components/SuccessfulDeleteView.swift @@ -0,0 +1,99 @@ +// +// SuccessfulDeleteView.swift +// PresentationLayer +// +// Created by Panagiotis Palamidas on 25/10/22. +// + +import SwiftUI + +struct SuccessfulDeleteView: View { + @EnvironmentObject var viewModel: DeleteAccountViewModel + private let mainScreenViewModel: MainScreenViewModel = .shared + + let userID: String + + var body: some View { + ZStack { + VStack { + Image("DeleteSuccessIcon") + successInfo + } + navigationButtons + } + .padding(.horizontal) + .navigationBarBackButtonHidden(true) + } + + @ViewBuilder + var successInfo: some View { + successTitle + successText + } + + var successTitle: some View { + Text(LocalizableString.DeleteAccount.successfulTitle.localized) + .font(.system(size: CGFloat(.largeFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + .bold() + .padding(.top, 20) + } + + var successText: some View { + Text(LocalizableString.DeleteAccount.successfulText.localized) + .foregroundColor(Color(colorEnum: .text)) + .font(.system(size: CGFloat(.normalFontSize))) + .multilineTextAlignment(.center) + .padding(.top, 20) + .padding(.horizontal, 20) + } + + var navigationButtons: some View { + VStack { + Spacer() + HStack { + Spacer() + goToSignInButton + Spacer() + goToSurveyButton + Spacer() + } + .padding(.bottom, 20) + } + } + + var goToSignInButton: some View { + Button { + mainScreenViewModel.selectedTab = .homeTab + mainScreenViewModel.isUserLoggedIn = false + Router.shared.popToRoot() + } label: { + goToSignInButtonStyle + } + .buttonStyle(WXMButtonStyle()) + } + + var goToSignInButtonStyle: some View { + Text(LocalizableString.finish.localized) + } + + var goToSurveyButton: some View { + Button { + Router.shared.navigateTo(.survey(userID, viewModel.getClientIndentifier())) + } label: { + goToSurveyButtonStyle + } + .buttonStyle(WXMButtonStyle.filled()) + } + + var goToSurveyButtonStyle: some View { + Text(LocalizableString.DeleteAccount.takeSurveyText.localized) + } +} + +struct Previews_SuccessfulDeleteView_Previews: PreviewProvider { + static var previews: some View { + SuccessfulDeleteView(userID: "") + .environmentObject(ViewModelsFactory.getDeleteAccountViewModel(userId: "")) + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/Components/UnitsOptionView.swift b/PresentationLayer/UI Components/Screens/Settings/Components/UnitsOptionView.swift new file mode 100644 index 00000000..7ddaf372 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/Components/UnitsOptionView.swift @@ -0,0 +1,33 @@ +// +// UnitsOptionView.swift +// PresentationLayer +// +// Created by danaekikue on 17/6/22. +// + +import SwiftUI + +struct UnitsOptionView: View { + @EnvironmentObject var settingsViewModel: SettingsViewModel + + let option: String + var unitCase: SettingsEnum + var isOptionActive: Bool + + var body: some View { + Button { + settingsViewModel.setUnits(unitCase: unitCase, chosenOption: option) + settingsViewModel.isShowingUnitsOverlay = false + } label: { + HStack { + circleRadius + Text(option) + } + } + } + + var circleRadius: some View { + CircleRadius(isOptionActive: isOptionActive) + .padding(.trailing, 10) + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/Components/UnitsOptionsModalView.swift b/PresentationLayer/UI Components/Screens/Settings/Components/UnitsOptionsModalView.swift new file mode 100644 index 00000000..4d72d8e7 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/Components/UnitsOptionsModalView.swift @@ -0,0 +1,65 @@ +// +// UnitsOptionsModalView.swift +// PresentationLayer +// +// Created by danaekikue on 17/6/22. +// + +import SwiftUI + +struct UnitsOptionsModalView: View { + @ObservedObject var settingsViewModel: SettingsViewModel + private let mainScreenViewModel: MainScreenViewModel = .shared + + var body: some View { + ZStack { + if settingsViewModel.isShowingUnitsOverlay { + Color.black.opacity(0.6) + .onTapGesture { + settingsViewModel.isShowingUnitsOverlay = false + } + .ignoresSafeArea() + .transition(AnyTransition.opacity.animation(.easeIn(duration: 0.2))) + .zIndex(0) + + modalContainer + .transition(AnyTransition.scale.animation(.easeIn(duration: 0.2))) + .zIndex(1) + + } + } + } + + var modalContainer: some View { + VStack(alignment: .leading) { + modalTitle + unitOptions + cancelButton + } + .WXMCardStyle() + .padding(.horizontal, 10) + } + + var modalTitle: some View { + Text(settingsViewModel.unitCaseModal.settingsTitle) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + } + + var unitOptions: some View { + settingsViewModel.getUnitOptions(mainScreenViewmodel: mainScreenViewModel) + .padding(.bottom, 16) + } + + var cancelButton: some View { + HStack { + Spacer() + Button { + settingsViewModel.isShowingUnitsOverlay = false + } label: { + Text(LocalizableString.cancel.localized) + .foregroundColor(Color(colorEnum: .primary)) + } + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/DeleteAccountView.swift b/PresentationLayer/UI Components/Screens/Settings/DeleteAccountView.swift new file mode 100644 index 00000000..1ccfb80a --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/DeleteAccountView.swift @@ -0,0 +1,132 @@ +// +// DeleteAccountView.swift +// PresentationLayer +// +// Created by Panagiotis Palamidas on 21/10/22. +// + +import SwiftUI +import Toolkit + +struct DeleteAccountView: View { + @StateObject var viewModel: DeleteAccountViewModel + + var body: some View { + Group { + switch viewModel.currentScreen { + case .info: mainInfoBody + case .success: SuccessfulDeleteView(userID: viewModel.userID).environmentObject(viewModel) + case .failure: FailedDeleteView().environmentObject(viewModel) + } + } + .onAppear { + Logger.shared.trackScreen(.deleteAccount) + } + } + + var mainInfoBody: some View { + ZStack { + Color(colorEnum: .bg) + .ignoresSafeArea() + + VStack(alignment: .leading) { + information + .padding() + Spacer() + } + + passwordModal + } + } + + var information: some View { + ScrollView(showsIndicators: true) { + Text( LocalizableString.DeleteAccount.textMarkdown.localized.attributedMarkdown!) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + .padding(.vertical, 10) + } + + @ViewBuilder + var generalInfo: some View { + Text(LocalizableString.DeleteAccount.generalInfo.localized.attributedMarkdown!) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + + var toDeleteInfo: some View { + Group { + CustomText(text: Text(LocalizableString.DeleteAccount.toDeleteTitle.localized)) + .padding(.top, 20) + .padding(.bottom, 1) + BulletPointText(text: Text(SettingsEnum.toDeleteName.settingsDeleteAccountInfo)) + BulletPointText(text: Text(SettingsEnum.toDeleteAddress.settingsDeleteAccountInfo)) + BulletPointText(text: Text(SettingsEnum.toDeletePersonalData.settingsDeleteAccountInfo)) + } + } + + var notDeleteInfo: some View { + Group { + CustomText(text: Text(LocalizableString.DeleteAccount.notDeleteTitle.localized)) + .padding(.top, 20) + .padding(.bottom, 1) + BulletPointText(text: Text(SettingsEnum.notDeleteWeatherData.settingsDeleteAccountInfo)) + BulletPointText(text: Text(SettingsEnum.notDeleteRewards.settingsDeleteAccountInfo)) + } + } + + var collectingDataInfo: some View { + CustomText(text: Text(SettingsEnum.noCollectDataTitle.settingsDeleteAccountInfo)) + .padding(.top, 20) + } + + var contactInfo: some View { + CustomText(text: Text(LocalizableString.DeleteAccount.contactForSupport.localized)) + .padding(.top, 20) + } + + var passwordModal: some View { + VStack { + Spacer() + DeleteAccountModalView() + .environmentObject(viewModel) + } + } +} + +private struct CustomText: View { + var text: Text + var isBold: Bool + + init(text: Text, isBold: Bool = false) { + self.text = text + self.isBold = isBold + } + + var body: some View { + text + .fontWeight(isBold ? .bold : .regular) + .font(.system(size: 14)) + .lineSpacing(3) + } +} + +private struct BulletPointText: View { + var text: Text + + var body: some View { + HStack(alignment: .top) { + Text(verbatim: " •") + text + .font(.system(size: 14)) + .lineSpacing(3) + } + } +} + +struct Previews_DeleteAccountView_Previews: PreviewProvider { + static var previews: some View { + DeleteAccountView(viewModel: ViewModelsFactory.getDeleteAccountViewModel(userId: "")) + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/DeleteAccountViewModel.swift b/PresentationLayer/UI Components/Screens/Settings/DeleteAccountViewModel.swift new file mode 100644 index 00000000..3a4d74a7 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/DeleteAccountViewModel.swift @@ -0,0 +1,128 @@ +// +// DeleteAccountViewModel.swift +// PresentationLayer +// +// Created by Panagiotis Palamidas on 21/10/22. +// + +import Combine +import DomainLayer +import Foundation +import UIKit +import Toolkit + +final class DeleteAccountViewModel: ObservableObject { + @Published var password = "" + @Published var passwordHasError = false + @Published var isToggleOn = false + @Published var currentScreen = DeleteScreen.info + @Published var isValidatingPassword: Bool = false + @Published var deleteFailureErrorMessage: String = "" + let userID: String + private var tokenResponse = NetworkTokenResponse() + private var cancellableSet: Set = [] + + private let authUseCase: AuthUseCase + private let meUseCase: MeUseCase + private let keychainUseCase: KeychainUseCase + private let settingsUseCase: SettingsUseCase + + init(userId: String, authUseCase: AuthUseCase, meUseCase: MeUseCase, keychainUseCase: KeychainUseCase, settingsUseCase: SettingsUseCase) { + self.userID = userId + self.authUseCase = authUseCase + self.meUseCase = meUseCase + self.keychainUseCase = keychainUseCase + self.settingsUseCase = settingsUseCase + } + + func tryLoginAndDeleteAccount() { + isValidatingPassword = true + let usersEmail = keychainUseCase.getUsersEmail() + do { + try authUseCase.login(username: usersEmail, password: password) + .sink { response in + if response.error != nil { + self.isValidatingPassword = false + self.passwordHasError = true + } else { + self.isValidatingPassword = false + self.passwordHasError = false + self.tryDeleteAccount() + } + }.store(in: &cancellableSet) + } catch {} + } + + func tryDeleteAccount() { + LoaderView.shared.show() + do { + try meUseCase.deleteAccount() + .sink { response in + LoaderView.shared.dismiss() + if response.response?.statusCode != 204 { + if response.error != nil { + self.getErrorMessage(responseError: response.error) + self.currentScreen = DeleteScreen.failure + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .failure, + .itemId: .custom(response.error?.backendError?.code ?? "")]) + } + } else { + self.currentScreen = DeleteScreen.success + _ = try? self.settingsUseCase.logout(localOnly: true) + } + }.store(in: &cancellableSet) + } catch {} + } + + private func getErrorMessage(responseError: NetworkErrorResponse?) { + if let error = (responseError?.initialError.underlyingError as? URLError) { + switch error.code { + case .notConnectedToInternet: + deleteFailureErrorMessage = "NO_CONNECTION" + case .timedOut: + deleteFailureErrorMessage = "CONNECTION_TIMEOUT" + case .unknown: + deleteFailureErrorMessage = "UNKNOWN" + case .cannotParseResponse: + deleteFailureErrorMessage = "PARSE_JSON" + default: + deleteFailureErrorMessage = "UNIDENTIFIED" + } + } + } + + public func contactSupport() { + HelperFunctions().openContactSupport(successFailureEnum: .deleteAccount, email: MainScreenViewModel.shared.userInfo?.email) + } + + func getClientIndentifier() -> String { + let bundleID = Bundle.main.buildIDPretty + let bundleVersion = Bundle.main.buildVersionNumberPretty + let appInformation = "wxm-ios:\(bundleID)-\(bundleVersion)" + + let systemVersion = UIDevice.current.systemVersion + let releaseVersion = Bundle.main.releaseVersionNumberPretty + let systemInfo = "iOS:\(systemVersion)-\(releaseVersion)" + + let manufacturer = "Apple" + let deviceModel = UIDevice.modelName + let deviceInfo = "\(manufacturer)-\(deviceModel)" + + let clientIdentifier = "\(appInformation);\(systemInfo);\(deviceInfo)" + return clientIdentifier + } +} + +extension DeleteAccountViewModel { + enum DeleteScreen { + case info + case success + case failure + } +} + +extension DeleteAccountViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + hasher.combine(userID) + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/SettingsEnum.swift b/PresentationLayer/UI Components/Screens/Settings/SettingsEnum.swift new file mode 100644 index 00000000..0a13ce42 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/SettingsEnum.swift @@ -0,0 +1,111 @@ +// +// SettingsEnum.swift +// PresentationLayer +// +// Created by danaekikue on 17/6/22. +// + +import Foundation +import SwiftUI + +enum SettingsEnum { + case units, account, display, theme, temperature, precipitation, windSpeed, windDirection, pressure, analytics, logout, + help, about, appVersion(installationId: String?), documentation, contactSupport, deleteAccount, deleteAccountCaption, deleteAccountWarning, + deleteAccountGeneralInfo, deleteAccountMoreInfoLink, toDeleteTitle, toDeleteName, toDeleteAddress, toDeletePersonalData, + notDeleteTitle, notDeleteWeatherData, notDeleteRewards, noCollectDataTitle, contactForSupport + + var sectionTitle: String { + switch self { + case .units: + return LocalizableString.Settings.weatherUnits.localized + case .account: + return LocalizableString.account.localized + case .help: + return LocalizableString.Settings.help.localized + case .display: + return LocalizableString.display.localized + case .about: + return LocalizableString.about.localized + default: + return "" + } + } + + var settingsTitle: String { + switch self { + case .temperature: + return LocalizableString.temperature.localized + case .precipitation: + return LocalizableString.precipitation.localized + case .windSpeed: + return LocalizableString.windSpeed.localized + case .windDirection: + return LocalizableString.windDirection.localized + case .pressure: + return LocalizableString.pressure.localized + case .analytics: + return LocalizableString.settingsOptionAnalyticsTitle.localized + case .logout: + return LocalizableString.logout.localized + case .documentation: + return LocalizableString.Settings.documentation.localized + case .contactSupport: + return LocalizableString.contactSupport.localized + case .deleteAccount: + return LocalizableString.Settings.deleteMyAccount.localized + case .theme: + return LocalizableString.theme.localized + case .appVersion: + return LocalizableString.appVersion.localized + default: + return "" + } + } + + var settingsDescription: String { + switch self { + case .documentation: + return LocalizableString.Settings.documentationDescription.localized + case .contactSupport: + return LocalizableString.Settings.contactSupportDescritpion.localized + case .deleteAccountCaption: + return LocalizableString.Settings.deleteAccountCaption.localized + case .deleteAccountWarning: + return LocalizableString.Settings.deleteAccountWarning.localized + case .appVersion(let installationId): + let suffix = installationId != nil ? " - \(installationId!)" : "" + return "\(Bundle.main.releaseVersionNumberPretty) (\(Bundle.main.buildVersionNumberPretty))\(suffix)" + default: + return "" + } + } + + var settingsDeleteAccountInfo: String { + switch self { + case .deleteAccountGeneralInfo: + return LocalizableString.DeleteAccount.generalInfo.localized + case .deleteAccountMoreInfoLink: + return LocalizableString.DeleteAccount.moreInfoLink.localized + case .toDeleteTitle: + return LocalizableString.DeleteAccount.toDeleteTitle.localized + case .toDeleteName: + return LocalizableString.DeleteAccount.toDeleteName.localized + case .toDeleteAddress: + return LocalizableString.DeleteAccount.toDeleteAddress.localized + case .toDeletePersonalData: + return LocalizableString.DeleteAccount.toDeletePersonalData.localized + case .notDeleteTitle: + return LocalizableString.DeleteAccount.notDeleteTitle.localized + case .notDeleteWeatherData: + return LocalizableString.DeleteAccount.notDeleteWeatherData.localized + case .notDeleteRewards: + return LocalizableString.DeleteAccount.notDeleteRewards.localized + case .noCollectDataTitle: + return LocalizableString.DeleteAccount.noCollectDataTitle.localized + case .contactForSupport: + return LocalizableString.DeleteAccount.contactForSupport.localized + default: + return "" + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/SettingsView.swift b/PresentationLayer/UI Components/Screens/Settings/SettingsView.swift new file mode 100644 index 00000000..ae70bb06 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/SettingsView.swift @@ -0,0 +1,262 @@ +// +// SettingsScreenView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 27/5/22. +// + +import SwiftUI +import Toolkit + +struct SettingsView: View { + @StateObject var settingsViewModel: SettingsViewModel + private let mainScreenViewModel: MainScreenViewModel = .shared + + var body: some View { + ZStack { + settingsContainer + UnitsOptionsModalView(settingsViewModel: settingsViewModel) + } + .spinningLoader(show: $settingsViewModel.isLoading) + .navigationBarBackButtonHidden(settingsViewModel.isShowingUnitsOverlay) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + Logger.shared.trackScreen(.settings) + } + } + + var settingsContainer: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: CGFloat(.defaultSpacing)) { + unitsContainer + WXMDivider() + displayContainer + accountContainer + helpContainer + WXMDivider() + aboutContainer + WXMDivider() + Spacer() + } + .padding(CGFloat(.defaultSidePadding)) + } + } + + @ViewBuilder + var unitsContainer: some View { + unitsSectionTitle + temperature + precipitation + windSpeed + windDirection + pressure + } + + var unitsSectionTitle: some View { + SettingsSectionTitle(title: .units) + } + + var temperature: some View { + SettingsButtonView( + settingsCase: .temperature, + settingCaption: settingsViewModel.unitsManager.temperatureUnit.settingUnitFriendlyName, + unitCaseModal: $settingsViewModel.unitCaseModal, + isShowingUnitsOverlay: $settingsViewModel.isShowingUnitsOverlay, + action: {} + ) + } + + var precipitation: some View { + SettingsButtonView( + settingsCase: .precipitation, + settingCaption: settingsViewModel.unitsManager.precipitationUnit.settingUnitFriendlyName, + unitCaseModal: $settingsViewModel.unitCaseModal, + isShowingUnitsOverlay: $settingsViewModel.isShowingUnitsOverlay, + action: {} + ) + } + + var windSpeed: some View { + SettingsButtonView( + settingsCase: .windSpeed, + settingCaption: settingsViewModel.unitsManager.windSpeedUnit.settingUnitFriendlyName, + unitCaseModal: $settingsViewModel.unitCaseModal, + isShowingUnitsOverlay: $settingsViewModel.isShowingUnitsOverlay, + action: {} + ) + } + + var windDirection: some View { + SettingsButtonView( + settingsCase: .windDirection, + settingCaption: settingsViewModel.unitsManager.windDirectionUnit.settingUnitFriendlyName, + unitCaseModal: $settingsViewModel.unitCaseModal, + isShowingUnitsOverlay: $settingsViewModel.isShowingUnitsOverlay, + action: {} + ) + } + + var pressure: some View { + SettingsButtonView( + settingsCase: .pressure, + settingCaption: settingsViewModel.unitsManager.pressureUnit.settingUnitFriendlyName, + unitCaseModal: $settingsViewModel.unitCaseModal, + isShowingUnitsOverlay: $settingsViewModel.isShowingUnitsOverlay, + action: {} + ) + } + + @ViewBuilder + var accountContainer: some View { + if mainScreenViewModel.isUserLoggedIn { + Group { + accountSectionTitle + analyticsSwitch + logoutButton + deleteAccountButton + } + WXMDivider() + } + } + + var accountSectionTitle: some View { + SettingsSectionTitle(title: .account) + } + + var analyticsSwitch: some View { + SettingsButtonView(settingsCase: .analytics, + settingCaption: LocalizableString.settingsOptionAnalyticsDescription.localized, + switchValue: $settingsViewModel.isAnalyticsCollectionEnabled) { + settingsViewModel.isAnalyticsCollectionEnabled?.toggle() + } + } + + var logoutButton: some View { + SettingsButtonView( + settingsCase: .logout, + settingCaption: "", + action: { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .logout]) + settingsViewModel.logoutUser { completion in + if completion { + mainScreenViewModel.selectedTab = .homeTab + mainScreenViewModel.isUserLoggedIn = false + Router.shared.popToRoot() + } + } + } + ) + } + + var deleteAccountButton: some View { + Button { + Router.shared.navigateTo(.deleteAccount(ViewModelsFactory.getDeleteAccountViewModel(userId: settingsViewModel.userID))) + } label: { + deleteAccountStyle + } + } + + var deleteAccountStyle: some View { + HStack { + VStack(alignment: .leading) { + deleteAccountTitle + deleteAccountCaption + deleteAccountWarning + } + Spacer() + } + .contentShape(Rectangle()) + } + + var deleteAccountTitle: some View { + Text(SettingsEnum.deleteAccount.settingsTitle) + .font(.system(size: CGFloat(.largeTitleFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + + var deleteAccountCaption: some View { + Text(SettingsEnum.deleteAccountCaption.settingsDescription) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .darkGrey)) + } + + var deleteAccountWarning: some View { + Text(SettingsEnum.deleteAccountWarning.settingsDescription) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + + @ViewBuilder + var displayContainer: some View { + Group { + SettingsSectionTitle(title: .display) + changeThemeButton + } + WXMDivider() + } + + @ViewBuilder + var changeThemeButton: some View { + SettingsButtonView(settingsCase: .theme, + settingCaption: mainScreenViewModel.theme.description, + unitCaseModal: $settingsViewModel.unitCaseModal, + isShowingUnitsOverlay: $settingsViewModel.isShowingUnitsOverlay) + } + + @ViewBuilder + var helpContainer: some View { + helpSectionTitle + documentationButton + contactUsButton + } + + var helpSectionTitle: some View { + SettingsSectionTitle(title: .help) + } + + var documentationButton: some View { + SettingsButtonView( + settingsCase: .documentation, + settingCaption: SettingsEnum.documentation.settingsDescription, + action: { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .documentation]) + if let url = URL(string: DisplayedLinks.documentationLink.linkURL) { + UIApplication.shared.open(url) + } + } + ) + } + + var contactUsButton: some View { + SettingsButtonView( + settingsCase: .contactSupport, + settingCaption: SettingsEnum.contactSupport.settingsDescription, + action: { + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .contactSupport, + .source: .settingsSource]) + + HelperFunctions().openContactSupport(successFailureEnum: .settings, + email: mainScreenViewModel.userInfo?.email ?? "", + serialNumber: nil, + trackSelectContentEvent: false) + } + ) + } + + @ViewBuilder + var aboutContainer: some View { + SettingsSectionTitle(title: .about) + SettingsButtonView( + settingsCase: .appVersion(installationId: settingsViewModel.installationId), + settingCaption: SettingsEnum.appVersion(installationId: settingsViewModel.installationId).settingsDescription, + action: { + } + ) + } +} + +struct Previews_SettingsView_Previews: PreviewProvider { + static var previews: some View { + SettingsView(settingsViewModel: ViewModelsFactory.getSettingsViewModel(userId: "")) + } +} diff --git a/PresentationLayer/UI Components/Screens/Settings/SettingsViewModel.swift b/PresentationLayer/UI Components/Screens/Settings/SettingsViewModel.swift new file mode 100644 index 00000000..9039ac1f --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Settings/SettingsViewModel.swift @@ -0,0 +1,173 @@ +// +// SettingsViewModel.swift +// PresentationLayer +// +// Created by danaekikue on 17/6/22. +// +import Combine +import DomainLayer +import SwiftUI +import Toolkit + +final class SettingsViewModel: ObservableObject { + @Published var isShowingUnitsOverlay: Bool = false + @Published var totalUnitOptions: Int = 0 + @Published var unitCaption: String = "" + @Published var unitCaseModal: SettingsEnum = .temperature + @Published var installationId: String? + @Published var isLoading: Bool = false + @Published var isAnalyticsCollectionEnabled: Bool? = false { + didSet { + guard let isAnalyticsCollectionEnabled else { + return + } + settingsUseCase.optInOutAnalytics(isAnalyticsCollectionEnabled) + } + } + + let userID: String + let unitsManager: WeatherUnitsManager = .default + private let settingsUseCase: SettingsUseCase + private var cancellableSet: Set = .init() + private let mainVM: MainScreenViewModel = .shared + + init(userId: String, settingsUseCase: SettingsUseCase) { + self.userID = userId + self.settingsUseCase = settingsUseCase + self.isAnalyticsCollectionEnabled = settingsUseCase.isAnalyticsEnabled + setInstallationId() + } + + @ViewBuilder + func setDescriptionContent(settingsCase _: SettingsEnum) -> some View { + Text(unitCaption) + } + + @ViewBuilder + func getUnitOptions(mainScreenViewmodel: MainScreenViewModel) -> some View { + switch unitCaseModal { + case .temperature: + ForEach(TemperatureUnitsEnum.allCases, id: \.unit) { option in + UnitsOptionView(option: option.settingUnitFriendlyName, unitCase: self.unitCaseModal, isOptionActive: option == self.unitsManager.temperatureUnit) + .environmentObject(self) + } + case .precipitation: + ForEach(PrecipitationUnitsEnum.allCases, id: \.unit) { option in + UnitsOptionView(option: option.settingUnitFriendlyName, unitCase: self.unitCaseModal, isOptionActive: option == self.unitsManager.precipitationUnit) + .environmentObject(self) + } + case .windSpeed: + ForEach(WindSpeedUnitsEnum.allCases, id: \.unit) { option in + UnitsOptionView(option: option.settingUnitFriendlyName, unitCase: self.unitCaseModal, isOptionActive: option == self.unitsManager.windSpeedUnit) + .environmentObject(self) + } + case .windDirection: + ForEach(WindDirectionUnitsEnum.allCases, id: \.unit) { option in + UnitsOptionView(option: option.settingUnitFriendlyName, unitCase: self.unitCaseModal, isOptionActive: option == self.unitsManager.windDirectionUnit) + .environmentObject(self) + } + case .pressure: + ForEach(PressureUnitsEnum.allCases, id: \.unit) { option in + UnitsOptionView(option: option.settingUnitFriendlyName, unitCase: self.unitCaseModal, isOptionActive: option == self.unitsManager.pressureUnit) + .environmentObject(self) + } + case .theme: + ForEach(Theme.allCases, id: \.self) { option in + UnitsOptionView(option: option.description, unitCase: self.unitCaseModal, isOptionActive: option == self.mainVM.theme) + .environmentObject(self) + } + default: + Text("") + } + } + + func setUnits(unitCase: SettingsEnum, chosenOption: String) { + switch unitCase { + case .temperature: + TemperatureUnitsEnum.allCases.forEach { + if chosenOption == $0.settingUnitFriendlyName { + unitsManager.temperatureUnit = $0 + } + } + case .precipitation: + PrecipitationUnitsEnum.allCases.forEach { + if chosenOption == $0.settingUnitFriendlyName { + unitsManager.precipitationUnit = $0 + } + } + case .windSpeed: + WindSpeedUnitsEnum.allCases.forEach { + if chosenOption == $0.settingUnitFriendlyName { + unitsManager.windSpeedUnit = $0 + } + } + case .windDirection: + WindDirectionUnitsEnum.allCases.forEach { + if chosenOption == $0.settingUnitFriendlyName { + unitsManager.windDirectionUnit = $0 + } + } + case .pressure: + PressureUnitsEnum.allCases.forEach { + if chosenOption == $0.settingUnitFriendlyName { + unitsManager.pressureUnit = $0 + } + } + case .theme: + guard let selectedTheme = Theme(description: chosenOption) else { + return + } + mainVM.setTheme(selectedTheme) + default: + break + } + } + + func logoutUser(completion: @escaping (Bool) -> Void) { + let logoutAction: GenericCallback = { [weak self] _ in + guard let self else { + return + } + + self.isLoading = true + + do { + try settingsUseCase.logout().sink { [weak self] response in + self?.isLoading = false + + if let error = response.error { + let info = error.uiInfo + Toast.shared.show(text: info.description?.attributedMarkdown ?? "") + completion(false) + return + } + + completion(true) + }.store(in: &cancellableSet) + } catch { + print(error) + isLoading = false + completion(false) + } + } + + let obj = AlertHelper.AlertObject(title: LocalizableString.logoutAlertTitle.localized, + message: LocalizableString.logoutAlertText.localized, + okAction: (LocalizableString.yes.localized, logoutAction)) + AlertHelper().showAlert(obj) + } +} + +private extension SettingsViewModel { + func setInstallationId() { + Task { @MainActor in + self.installationId = await FirebaseManager.shared.getInstallationId() + } + } +} + +extension SettingsViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + hasher.combine(userID) + } +} diff --git a/PresentationLayer/UI Components/Screens/Update Firmware/UpdateFirmwareView+Content.swift b/PresentationLayer/UI Components/Screens/Update Firmware/UpdateFirmwareView+Content.swift new file mode 100644 index 00000000..22527398 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Update Firmware/UpdateFirmwareView+Content.swift @@ -0,0 +1,21 @@ +// +// UpdateFirmwareView+Content.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 3/2/23. +// + +import SwiftUI + +extension UpdateFirmwareView { + @ViewBuilder + var installationView: some View { + DeviceUpdatesLoadingView(topTitle: viewModel.topTitle, + topSubtitle: viewModel.topSubtitle, + title: viewModel.title, + subtitle: viewModel.subtile, + steps: viewModel.steps, + currentStepIndex: $viewModel.currentStepIndex, + progress: $viewModel.progress) + } +} diff --git a/PresentationLayer/UI Components/Screens/Update Firmware/UpdateFirmwareView.swift b/PresentationLayer/UI Components/Screens/Update Firmware/UpdateFirmwareView.swift new file mode 100644 index 00000000..f939cad6 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Update Firmware/UpdateFirmwareView.swift @@ -0,0 +1,64 @@ +// +// UpdateFirmwareView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 3/2/23. +// + +import SwiftUI +import Toolkit + +struct UpdateFirmwareView: View { + @StateObject var viewModel: UpdateFirmwareViewModel + @State var stepsViewSize: CGSize = .zero + @EnvironmentObject var navigationObject: NavigationObject + private let mainVM: MainScreenViewModel = .shared + + var body: some View { + ZStack { + Color(colorEnum: .top) + .ignoresSafeArea() + + installationView + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .fail(show: Binding(get: { viewModel.state.isFailed }, set: { _ in }), obj: viewModel.state.stateObject ) + .success(show: Binding(get: { viewModel.state.isSuccess }, set: { _ in }), obj: viewModel.state.stateObject ) + .animation(.easeIn, value: viewModel.state) + } + .onAppear { + navigationObject.willDismissAction = { [weak viewModel] in + viewModel?.navigationBackButtonTapped() + } + + navigationObject.title = viewModel.navigationTitle + + viewModel.mainVM = mainVM + + Logger.shared.trackScreen(.heliumOTA) + } + } +} + +struct UpdateFirmwareView_Previews: PreviewProvider { + static var previews: some View { + NavigationContainerView { + UpdateFirmwareView(viewModel: UpdateFirmwareViewModel.mockInstance) + } + } +} + +struct UpdateFirmwareViewSuccess_Previews: PreviewProvider { + static var previews: some View { + NavigationContainerView { + UpdateFirmwareView(viewModel: UpdateFirmwareViewModel.successMockInstance) + } + } +} + +struct UpdateFirmwareViewError_Previews: PreviewProvider { + static var previews: some View { + NavigationContainerView { + UpdateFirmwareView(viewModel: UpdateFirmwareViewModel.errorMockInstance) + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Update Firmware/View Model/FirmwareUpdateUtils.swift b/PresentationLayer/UI Components/Screens/Update Firmware/View Model/FirmwareUpdateUtils.swift new file mode 100644 index 00000000..2d4820ac --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Update Firmware/View Model/FirmwareUpdateUtils.swift @@ -0,0 +1,154 @@ +// +// FirmwareUpdateUtils.swift +// PresentationLayer +// +// Created by Pantelis Giazitsis on 14/2/23. +// + +import DomainLayer +import Foundation +import SwiftUI +import Toolkit + +extension FirmwareUpdateError: CustomStringConvertible { + public var title: String { + switch self { + case .downloadFile: + return LocalizableString.FirmwareUpdate.failureTitle.localized + case .connection: + return LocalizableString.FirmwareUpdate.failedConnectionTitle.localized + case .installation: + return LocalizableString.FirmwareUpdate.failureTitle.localized + } + } + + public var description: String { + let description = LocalizableString.FirmwareUpdate.failureDescription(errorString).localized + + switch self { + case .downloadFile, .installation: + return description + case .connection: + let linkString = "[\(LocalizableString.ClaimDevice.failedTroubleshootingTextLinkTitle.localized)](\(DisplayedLinks.heliumTroubleshooting.linkURL))" + + return LocalizableString.FirmwareUpdate.failedStationConnectionDescription(linkString, LocalizableString.ClaimDevice.failedTextLinkTitle.localized).localized + } + } + + public var errorString: String { + switch self { + case .downloadFile: + return LocalizableString.FirmwareUpdate.downloadFileError.localized + case .connection: + return LocalizableString.FirmwareUpdate.failedToConnectError.localized + case let .installation(errorMessage): + return LocalizableString.FirmwareUpdate.installError(errorMessage ?? "").localized + } + } +} + +extension UpdateFirmwareViewModel { + enum State: Equatable { + static func == (lhs: UpdateFirmwareViewModel.State, rhs: UpdateFirmwareViewModel.State) -> Bool { + switch (lhs, rhs) { + case (.installing, .installing): + return true + case (.failed, .failed): + return true + case (.success, .success): + return true + default: return false + } + } + + case installing + case failed(FailSuccessStateObject) + case success(FailSuccessStateObject) + + var stateObject: FailSuccessStateObject? { + switch self { + case .installing: + return nil + case .failed(let obj): + return obj + case .success(let obj): + return obj + } + } + + var isFailed: Bool { + if case .failed = self { + return true + } + + return false + } + + var isSuccess: Bool { + if case .success = self { + return true + } + + return false + } + } + + enum Step: Int, CaseIterable, CustomStringConvertible { + case connect = 0 + case download + case install + + var description: String { + switch self { + case .connect: + return LocalizableString.connectToStation.localized + case .download: + return LocalizableString.FirmwareUpdate.stepDownload.localized + case .install: + return LocalizableString.FirmwareUpdate.stepInstall.localized + } + } + + var analyticsValue: ParameterValue { + switch self { + case .connect: + return .connect + case .download: + return .download + case .install: + return .install + } + } + } +} + +// MARK: - Mock + +extension UpdateFirmwareViewModel { + static var mockInstance: UpdateFirmwareViewModel { + let viewModel = UpdateFirmwareViewModel(useCase: nil, device: DeviceDetails.emptyDeviceDetails) + viewModel.title = "Connecting" + viewModel.subtile = nil + viewModel.currentStepIndex = 2 + viewModel.progress = 20 + viewModel.steps = [StepsView.Step(text: "Download Firmware Update", isCompleted: true), + StepsView.Step(text: "Connect to Station", isCompleted: true), + StepsView.Step(text: "Install Firmware Update", isCompleted: false)] + + return viewModel + } + + static var successMockInstance: UpdateFirmwareViewModel { + let viewModel = UpdateFirmwareViewModel(useCase: nil, device: DeviceDetails.emptyDeviceDetails) + viewModel.state = .success(FailSuccessStateObject.mockSuccessObj) + + return viewModel + } + + static var errorMockInstance: UpdateFirmwareViewModel { + let viewModel = UpdateFirmwareViewModel(useCase: nil, device: DeviceDetails.emptyDeviceDetails) + viewModel.state = .failed(FailSuccessStateObject.mockErrorObj) + + return viewModel + } +} diff --git a/PresentationLayer/UI Components/Screens/Update Firmware/View Model/UpdateFirmwareViewModel.swift b/PresentationLayer/UI Components/Screens/Update Firmware/View Model/UpdateFirmwareViewModel.swift new file mode 100644 index 00000000..ea5bebe1 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Update Firmware/View Model/UpdateFirmwareViewModel.swift @@ -0,0 +1,295 @@ +// +// UpdateFirmwareViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 3/2/23. +// + +import Combine +import DataLayer +import DomainLayer +import Foundation +import UIKit +import Toolkit + +class UpdateFirmwareViewModel: ObservableObject { + let topTitle: String? + let topSubtitle: String? + @Published var title: String = "" + @Published var subtile: AttributedString? + @Published var steps: [StepsView.Step] = [] + @Published var progress: UInt? + @Published var currentStepIndex: Int? + @Published var state: State = .installing + let navigationTitle = LocalizableString.FirmwareUpdate.title.localized + var mainVM: MainScreenViewModel? + + private var device: DeviceDetails? + private let firmwareUseCase: UpdateFirmwareUseCase? + private let successCallback: (() -> Void)? + private let cancelCallback: (() -> Void)? + private var cancellableSet: Set = [] + private let scanningTimeout: TimeInterval = 10.0 + private var scanningTimeoutWorkItem: DispatchWorkItem? + private var started: Bool { + currentStepIndex != nil + } + + init(useCase: UpdateFirmwareUseCase? = .init(firmwareRepository: FirmwareUpdateImpl()), + device: DeviceDetails, + successCallback: (() -> Void)? = nil, + cancelCallback: (() -> Void)? = nil) { + self.firmwareUseCase = useCase + self.device = device + self.successCallback = successCallback + self.cancelCallback = cancelCallback + + self.topTitle = LocalizableString.deviceInfoStationInfoFirmwareVersion.localized + self.topSubtitle = device.firmware?.versionUpdateString + generateSteps() + firmwareUseCase?.enableBluetooth() + retry() + } + + func navigationBackButtonTapped() { + stopScanning() + firmwareUseCase?.stopDeviceFirmwareUpdate() + } +} + +private extension UpdateFirmwareViewModel { + /// Resets the UI state and starts the flow from the beginning + func retry() { + cancellableSet.forEach { $0.cancel() } + cancellableSet.removeAll() + reset() + observeBluetoothState() + } + + /// Starts the update firmware process for the specific BT device + /// - Parameter device: The device to update firmware + func startFirmwareUpdate(device: BTWXMDevice) { + guard let firmwareDeviceId = self.device?.id, !started else { + return + } + + UIApplication.shared.isIdleTimerDisabled = true + let publisher = firmwareUseCase?.updateDeviceFirmware(device: device, firmwareDeviceId: firmwareDeviceId) + publisher?.sink { [weak self] state in + DispatchQueue.main.async { + self?.handleState(state: state) + } + }.store(in: &cancellableSet) + } + + /// Observe changes in scanned devices list to find which one is refering to the current `device`. + /// Once the BT device is found we the firmware update process + func observeBluetoothDevices() { + firmwareUseCase?.bluetoothDevices.sink { [weak self] btDevices in + guard let self = self, + let device = self.device, + let btDevice = btDevices.first(where: { $0.isSame(with: device) }) + else { + return + } + self.stopScanning() + self.startFirmwareUpdate(device: btDevice) + }.store(in: &cancellableSet) + } + + /// Observe changes in BT state. Once is `.poweredOn` we launch the process + func observeBluetoothState() { + firmwareUseCase?.bluetoothState.sink { [weak self] state in + guard let self = self else { return } + + var errorDescription = "" + + switch state { + case .unknown: + return + case .unsupported: + errorDescription = LocalizableString.Bluetooth.unsupportedTitle.localized + case .unauthorized: + errorDescription = LocalizableString.Bluetooth.noAccessTitle.localized + case .poweredOff: + errorDescription = LocalizableString.Bluetooth.title.localized + case .poweredOn, .resetting: + // Once the BT is available we start the process + self.observeBluetoothDevices() + self.startScanning() + return + } + + let finishedObject = FailSuccessStateObject(type: .otaFlow, + title: LocalizableString.FirmwareUpdate.failureTitle.localized, + subtitle: errorDescription.attributedMarkdown, + cancelTitle: LocalizableString.cancel.localized, + retryTitle: LocalizableString.FirmwareUpdate.failureButtonTitle.localized, + contactSupportAction: { [weak self] in + HelperFunctions().openContactSupport(successFailureEnum: .otaFlow, + email: self?.mainVM?.userInfo?.email, + serialNumber: self?.device?.label, + errorString: errorDescription) + }, + cancelAction: self.cancelCallback, + retryAction: { [weak self] in self?.retry() }) + + DispatchQueue.main.async { + self.state = .failed(finishedObject) + } + }.store(in: &cancellableSet) + } + + /// Starts the scannig to find the correct BT device. The list is getting observed in `observeBluetoothDevices` + /// The scanning stops after `scanningTimeout` seconds and if no device found we show a failed view + func startScanning() { + scanningTimeoutWorkItem?.cancel() + scanningTimeoutWorkItem = DispatchWorkItem { [weak self] in + self?.stopScanning() + self?.showNotInRangeErrorView() + } + + firmwareUseCase?.startBluetoothScanning() + DispatchQueue.main.asyncAfter(deadline: .now() + scanningTimeout, execute: scanningTimeoutWorkItem!) + } + + /// Stops the scanning for devices + func stopScanning() { + scanningTimeoutWorkItem?.cancel() + firmwareUseCase?.stopBluetoothScanning() + } + + /// Resets all the required fields + func reset() { + state = .installing + title = "" + subtile = nil + currentStepIndex = nil + progress = nil + markCompletedSteps() + } + + /// Updates the UI state according to the firmware state updates + /// - Parameter state: The state to be handled + func handleState(state: FirmwareUpdateState) { + switch state { + case .unknown: + break + case .connecting: + self.state = .installing + title = LocalizableString.FirmwareUpdate.titleConnecting.localized + subtile = nil + currentStepIndex = Step.connect.rawValue + progress = nil + case .downloading: + self.state = .installing + title = LocalizableString.FirmwareUpdate.titleDownloading.localized + subtile = LocalizableString.FirmwareUpdate.descriptionDownloading.localized.attributedMarkdown + currentStepIndex = Step.download.rawValue + progress = nil + case let .installing(progress: progress): + self.state = .installing + title = LocalizableString.FirmwareUpdate.titleInstalling.localized + subtile = LocalizableString.FirmwareUpdate.descriptionInstalling.localized.attributedMarkdown + currentStepIndex = Step.install.rawValue + self.progress = UInt(progress) + case .finished: + let finishedObject = FailSuccessStateObject(type: .otaFlow, + title: LocalizableString.FirmwareUpdate.successTitle.localized, + subtitle: LocalizableString.FirmwareUpdate.successDescription.localized.attributedMarkdown, + cancelTitle: nil, + retryTitle: LocalizableString.FirmwareUpdate.successButtonTitle.localized, + contactSupportAction: nil, + cancelAction: cancelCallback, + retryAction: { [weak self] in + self?.successCallback?() + }) + + self.state = .success(finishedObject) + UIApplication.shared.isIdleTimerDisabled = false + if let deviceId = device?.id, let version = device?.firmware?.current { + mainVM?.firmwareUpdated(for: deviceId, version: version) + } + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .OTAResult, + .contentId: .otaResultContentId, + .itemId: .custom(device?.id ?? ""), + .success: .custom("1")]) + case let .error(error): + var params: [Parameter: ParameterValue] = [.contentName: .OTAError, + .contentId: .failureOtaContentId, + .itemId: .custom(device?.id ?? "")] + if let currentStepIndex, let step = Step(rawValue: currentStepIndex) { + params += [.step: step.analyticsValue] + } + Logger.shared.trackEvent(.viewContent, parameters: params) + + Logger.shared.trackEvent(.viewContent, parameters: [.contentName: .OTAResult, + .contentId: .otaResultContentId, + .itemId: .custom(device?.id ?? ""), + .success: .custom("0")]) + + handleError(error) + } + + markCompletedSteps() + } + + /// Updates the UI state according to the firmware update error + /// - Parameter error: The error to be handled + func handleError(_ error: FirmwareUpdateError) { + let finishedObject = FailSuccessStateObject(type: .otaFlow, + title: error.title, + subtitle: error.description.attributedMarkdown, + cancelTitle: LocalizableString.cancel.localized, + retryTitle: LocalizableString.FirmwareUpdate.failureButtonTitle.localized, + contactSupportAction: { [weak self] in + HelperFunctions().openContactSupport(successFailureEnum: .otaFlow, + email: self?.mainVM?.userInfo?.email, + serialNumber: self?.device?.label, + errorString: error.errorString) + }, + cancelAction: cancelCallback, + retryAction: { [weak self] in self?.retry() }) + + state = .failed(finishedObject) + UIApplication.shared.isIdleTimerDisabled = false + } + + /// The process steps + func generateSteps() { + let steps = Step.allCases.map { StepsView.Step(text: $0.description, isCompleted: false) } + self.steps = steps + } + + /// Marks all the completed steps in `steps` array + func markCompletedSteps() { + guard let currentStepIndex else { + (0 ..< steps.count).forEach { index in + steps[index].setCompleted(false) + } + return + } + + (0 ..< currentStepIndex).forEach { index in + steps[index].setCompleted(index < currentStepIndex) + } + } + + func showNotInRangeErrorView() { + let finishedObject = FailSuccessStateObject(type: .otaFlow, + title: LocalizableString.FirmwareUpdate.stationNotInRangeTitle.localized, + subtitle: LocalizableString.FirmwareUpdate.stationNotInRangeDescription.localized.attributedMarkdown, + cancelTitle: LocalizableString.cancel.localized, + retryTitle: LocalizableString.FirmwareUpdate.failureButtonTitle.localized, + contactSupportAction: { [weak self] in + HelperFunctions().openContactSupport(successFailureEnum: .otaFlow, + email: self?.mainVM?.userInfo?.email, + serialNumber: self?.device?.label, + errorString: LocalizableString.FirmwareUpdate.stationNotInRangeTitle.localized) + }, + cancelAction: cancelCallback, + retryAction: { [weak self] in self?.retry() }) + + state = .failed(finishedObject) + } +} diff --git a/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardDatePoint.swift b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardDatePoint.swift new file mode 100644 index 00000000..c59f2179 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardDatePoint.swift @@ -0,0 +1,37 @@ +// +// RewardDatePoint.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 12/9/22. +// + +import SwiftUI + +struct RewardDatePoint: View { + let dateOfTransaction: String + + var body: some View { + datePoint + } + + var datePoint: some View { + HStack(spacing: 16) { + circlePoint + dateOfTransactionText + } + .padding(.leading, 36) + .padding(.vertical, 20) + } + + var circlePoint: some View { + Circle() + .fill(Color(colorEnum: .midGrey)) + .frame(width: 12, height: 12) + } + + var dateOfTransactionText: some View { + Text(dateOfTransaction) + .foregroundColor(Color(colorEnum: .darkGrey)) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + } +} diff --git a/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/BaseRewardsCard.swift b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/BaseRewardsCard.swift new file mode 100644 index 00000000..8a9a4a66 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/BaseRewardsCard.swift @@ -0,0 +1,101 @@ +// +// BaseRewardsCard.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 12/9/22. +// + +import DomainLayer +import SwiftUI + +struct BaseRewardsCard: View { + let transactionDate: String + let stationRewards: Double + let colorOfProgressBar: Color + let rewardScore: Int + let networkMaxValue: Double + let rewardScoreHexagonColor: Color + let networkMaxHexagonColor: Color + let lostRewardsData: StationRewardsLostAmountData? + + var body: some View { + rewardCard + } + + var rewardCard: some View { + VStack(alignment: .leading, spacing: CGFloat(.defaultSpacing)) { + titleView + wxmRewards + rewardsProgressBar + rewardsScores + } + .WXMCardStyle() + } + + var titleView: some View { + HStack { + Text(transactionDate) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .darkGrey)) + + Spacer() + + if let lostRewardsData { + StationLostRewardsView(lostRewards: lostRewardsData, rounded: false) + } + } + } + + var wxmRewards: some View { + HStack { + Text("+ \(stationRewards.toWXMTokenPrecisionString) \(StringConstants.wxmCurrency)") + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + } + + var rewardsProgressBar: some View { + ProgressBar(colorOfProgressBar: colorOfProgressBar, progressOfBar: stationRewards / networkMaxValue) + } + + var rewardsScores: some View { + HStack(spacing: 16) { + RewardsScoreItem( + score: (Double(rewardScore) / 100.0).toPrecisionString(minDecimals: 2, precision: 2), + caption: LocalizableString.rewardScoreText.localized, + hexagonColor: rewardScoreHexagonColor + ) + RewardsScoreItem( + score: networkMaxValue.toWXMTokenPrecisionString, + caption: LocalizableString.networkMax.localized, + hexagonColor: networkMaxHexagonColor + ) + } + } +} + +extension BaseRewardsCard { + init(record: UITransaction) { + transactionDate = record.formattedTimestamp + stationRewards = record.actualReward ?? 0 + colorOfProgressBar = Color(colorEnum: record.hexagonColor) + rewardScore = record.rewardScore ?? 0 + networkMaxValue = record.dailyReward ?? 0 + rewardScoreHexagonColor = Color(colorEnum: record.hexagonColor) + networkMaxHexagonColor = Color(colorEnum: .reward_score_unknown) + lostRewardsData = StationRewardsLostAmountData(value: record.lostAmount, percentage: record.lostPercentage) + } +} + +#Preview { + BaseRewardsCard(transactionDate: Date.now.getFormattedDate(format: .monthLiteralDayTime, relativeFormat: true).localizedCapitalized, + stationRewards: 1.035869637693, + colorOfProgressBar: .red, + rewardScore: 100, + networkMaxValue: 4.5, + rewardScoreHexagonColor: .blue, + networkMaxHexagonColor: .green, + lostRewardsData: .init(value: 1.231324, percentage: 80)) +} diff --git a/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/ProgressBar.swift b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/ProgressBar.swift new file mode 100644 index 00000000..bf9fca46 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/ProgressBar.swift @@ -0,0 +1,42 @@ +// +// ProgressBar.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 12/9/22. +// + +import SwiftUI + +struct ProgressBar: View { + let colorOfProgressBar: Color + let progressOfBar: Double + + var body: some View { + progressBar + .frame(height: 16) + } + + var progressBar: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + backgroundPartOfBar + .frame(width: geometry.size.width) + activePartOfBar + .frame(width: geometry.size.width * progressOfBar) + } + } + } + + var backgroundPartOfBar: some View { + Rectangle() + .fill(Color(colorEnum: .layer2)) + .cornerRadius(CGFloat(.buttonCornerRadius)) + } + + var activePartOfBar: some View { + Rectangle() + .fill(colorOfProgressBar) + + .cornerRadius(CGFloat(.buttonCornerRadius)) + } +} diff --git a/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/RewardsScoreItem.swift b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/RewardsScoreItem.swift new file mode 100644 index 00000000..efc1ccb7 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/RewardsCard/RewardsScoreItem.swift @@ -0,0 +1,51 @@ +// +// RewardsScoreItem.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 12/9/22. +// + +import SwiftUI + +struct RewardsScoreItem: View { + let score: String + let caption: String + let hexagonColor: Color + + var body: some View { + rewardsScoreItem + } + + var rewardsScoreItem: some View { + HStack(alignment: .top) { + rewardScoreIcon + rewardsScoreInformation + } + } + + var rewardScoreIcon: some View { + Image(asset: .hexagonBigger) + .renderingMode(.template) + .foregroundColor(hexagonColor) + } + + var rewardsScoreInformation: some View { + VStack(alignment: .leading, spacing: 4) { + rewardScore + rewardScoreCaption + } + .padding(.top, 5) + } + + var rewardScore: some View { + Text(score) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + } + + var rewardScoreCaption: some View { + Text(caption) + .font(.system(size: CGFloat(.littleCaption))) + .foregroundColor(Color(colorEnum: .darkGrey)) + } +} diff --git a/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionDetailsView.swift b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionDetailsView.swift new file mode 100644 index 00000000..3ca91deb --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionDetailsView.swift @@ -0,0 +1,106 @@ +// +// TransactionDetailsView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 12/9/22. +// +import DomainLayer +import SwiftUI +import Toolkit + +struct TransactionDetailsView: View { + @StateObject var viewModel: TransactionDetailsViewModel + @EnvironmentObject var navigationObject: NavigationObject + + var body: some View { + ZStack { + Color(colorEnum: .bg) + .ignoresSafeArea() + + transactionDetails + } + .onAppear { + navigationObject.title = viewModel.device.displayName + navigationObject.titleColor = Color(colorEnum: .primary) + + Logger.shared.trackScreen(.rewardTransactions) + } + } + + @ViewBuilder + var transactionDetails: some View { + transactions + .wxmEmptyView(show: Binding(get: { viewModel.transactions.isEmpty }, set: { _ in }), + configuration: .init(animationEnum: .emptyDevices, + title: LocalizableString.noTransactionTitle.localized, + description: LocalizableString.noTransactionDesc.localized.attributedMarkdown ?? "", + buttonTitle: LocalizableString.retry.localized, + action: { viewModel.refresh(showFullScreenLoader: true, reset: true) })) + .spinningLoader(show: $viewModel.showFullScreenLoader, hideContent: true) + .fail(show: $viewModel.isFailed, obj: viewModel.failObj) + } + + @ViewBuilder + var transactions: some View { + ZStack(alignment: .bottom) { + TrackableScrollView(showIndicators: false, + offsetObject: viewModel.scrollOffsetObject) { completion in + viewModel.refresh(showFullScreenLoader: false, reset: true, completion: completion) + } content: { + ZStack(alignment: .topLeading) { + timeLineOfTransactions + VStack(alignment: .leading) { + ForEach(viewModel.transactions, id: \.self) { (arrayOfTransactions: [UITransaction]) in + RewardDatePoint(dateOfTransaction: arrayOfTransactions.first!.formattedDate) + LazyVStack(spacing: CGFloat(.mediumSpacing)) { + ForEach(arrayOfTransactions) { record in + let lostData = record.lostAmountData + BaseRewardsCard(record: record) + .indication(show: .constant(!record.annotationsList.isEmpty), + borderColor: Color(colorEnum: lostData.problemsViewBorder), + bgColor: Color(colorEnum: lostData.problemsViewBackground)) { + StationRewardsErrorView(lostAmount: record.lostAmount, + buttonTitle: viewModel.errorIndicationButtonTitle, + showButton: true) { + viewModel.handleTransactionTap(from: record) + } + .padding(CGFloat(.defaultSidePadding)) + } + .onTapGesture { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .transactionOnExplorer, + .contentType: .deviceTransactions, + .itemListId: .custom(record.formattedTimestamp), + .itemId: .custom(viewModel.device.id ?? "")]) + + viewModel.handleTransactionTap(from: record) + } + .onAppear { + viewModel.fetchNextPageIfNeeded(for: record) + } + } + } + .padding(.horizontal, CGFloat(.mediumSidePadding)) + .padding(.top, CGFloat(.mediumSidePadding)) + } + + if viewModel.isRequestInProgress { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + } + } + } + } + } + + var timeLineOfTransactions: some View { + Rectangle() + .fill(Color(colorEnum: .midGrey)) + .frame(width: 4) + .padding(.leading, 40) + .padding(.top, 25) + } +} diff --git a/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionDetailsViewModel.swift b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionDetailsViewModel.swift new file mode 100644 index 00000000..3e26ac4e --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionDetailsViewModel.swift @@ -0,0 +1,187 @@ +// +// TransactionDetailsViewModel.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 12/9/22. +// + +import Combine +import DomainLayer +import struct SwiftUI.CGFloat +import UIKit +import Toolkit + +final class TransactionDetailsViewModel: ObservableObject { + private let tokenUseCase: TokenUseCase + + private static let FETCH_INTERVAL_MONTHS = 3 + + private var cancellableSet: Set = [] + private var pendingTask: Task<(), Never>? { + didSet { + isRequestInProgress = pendingTask != nil + } + } + private var data: [Datum] = [] + + weak var mainVM: MainScreenViewModel? + @Published var isRequestInProgress: Bool = false + @Published var transactions = [[UITransaction]]() + @Published var showFullScreenLoader = false + let scrollOffsetObject: TrackableScrollOffsetObject = .init() + + @Published var navigationTitle: String = "" + @Published var isFailed: Bool = false + private(set) var failObj: FailSuccessStateObject? + let device: DeviceDetails + let followState: UserDeviceFollowState? + var errorIndicationButtonTitle: String { + followState?.relation == .owned ? LocalizableString.StationDetails.ownedRewardsErrorButtonTitle.localized : LocalizableString.StationDetails.rewardsErrorButtonTitle.localized + } + private var pagination: TransactionsPagination + + init(device: DeviceDetails, followState: UserDeviceFollowState?, tokenUseCase: TokenUseCase) { + self.device = device + self.followState = followState + self.tokenUseCase = tokenUseCase + self.pagination = .init(device: device, transactionsObject: nil, currentPage: 0) + + refresh(showFullScreenLoader: true, reset: true) + } + + /// Fetches new page and updates the data source + /// - Parameters: + /// - showFullScreenLoader: If `true` shows full screen loader + /// - reset: If `true` resets the data source and fetches the first page + /// - completion: Called once the request is finished + func refresh(showFullScreenLoader: Bool, reset: Bool, completion: (() -> Void)? = nil) { + guard pendingTask == nil else { + return + } + + self.showFullScreenLoader = showFullScreenLoader + + if reset { + self.pagination = .init(device: device, transactionsObject: nil, currentPage: 0) + } + + pendingTask = Task { @MainActor [weak self] in + guard let self else { + return + } + let nextData = await fetchNext() + if let error = nextData?.error { + handleNetworkError(error: error, fullScreen: showFullScreenLoader) + } else if reset { + data = nextData?.data ?? [] + } else { + data.append(contentsOf: nextData?.data ?? []) + } + + let uiTransactions = Array(Set(data.map { UITransaction.generate(from: $0) })) + let grouped = Dictionary(grouping: uiTransactions, by: { $0.formattedDate }).values.sorted(by: { $0.first!.sortDate > $1.first!.sortDate }) + transactions = grouped + + self.showFullScreenLoader = false + self.pendingTask = nil + completion?() + } + } + + /// Called `onAppear` of each transacition in list and if is last (bottom of list) asks for the next page + /// - Parameter transaction: The appeared transaction + func fetchNextPageIfNeeded(for transaction: UITransaction) { + guard transaction == transactions.last?.last else { + return + } + + refresh(showFullScreenLoader: false, reset: false) + } + + /// Called once the user tap the cell or the error button of a transaaction + /// - Parameter transaction: The tapped transaction + func handleTransactionTap(from transaction: UITransaction) { + let itemId = device.id ?? "" + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .identifyProblems, + .contentType: .deviceRewardTransactions, + .itemId: .custom(itemId)]) + + guard let datum = transaction.datum else { + return + } + + let errorButtonTitle: String = errorIndicationButtonTitle + let rewardsCardOverview = DeviceRewardsOverview(datum: datum).toRewardsCardOverview(title: "", errorButtonTitle: errorButtonTitle) + let viewModel = ViewModelsFactory.getRewardDetailsViewModel(device: device, followState: followState, overview: rewardsCardOverview) + Router.shared.navigateTo(.rewardDetails(viewModel)) + } +} + +extension TransactionDetailsViewModel: HashableViewModel { + func hash(into hasher: inout Hasher) { + hasher.combine(device.id) + } +} + +private extension TransactionDetailsViewModel { + + @discardableResult + /// Fetches the next page + /// - Returns: Tuple with received data and error, if exists + private func fetchNext() async -> (data: [Datum]?, error: NetworkErrorResponse?)? { + // if there is a pending request of there is no next page, we stop + guard let nextPagination = pagination.getNextPagination() else { + return nil + } + + let page = nextPagination.page + let fromDate = nextPagination.fromDate + let toDate = nextPagination.toDate + + do { + let result = try await self.tokenUseCase.getTransactions(deviceId: device.id ?? "", + page: page, + fromDate: fromDate, + toDate: toDate) + switch result { + case .success(let transactionsObj): + // Once the request is successful, we update the pagination with the latest state + pagination = .init(device: device, + transactionsObject: transactionsObj, + currentPage: page, + fromDate: fromDate, + toDate: toDate) + return (transactionsObj?.data ?? [], nil) + case .failure(let error): + return (nil, error) + } + } catch { + print(error) + } + + return nil + } + + /// Handles the received error and shows it on UI + /// - Parameters: + /// - error: The received network error + /// - fullScreen: If true we show the full screen UI, if not we show an error toast + func handleNetworkError(error: NetworkErrorResponse, fullScreen: Bool) { + let info = error.uiInfo + guard fullScreen else { + if let message = info.description?.attributedMarkdown { + Toast.shared.show(text: message) + } + + return + } + + let obj = info.defaultFailObject(type: .noTransactions) { [weak self] in + self?.isFailed = false + self?.refresh(showFullScreenLoader: true, reset: true) + } + + self.failObj = obj + self.isFailed = true + } +} diff --git a/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionsPagination.swift b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionsPagination.swift new file mode 100644 index 00000000..ff3cb028 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WXMTransactionsDetails/TransactionsPagination.swift @@ -0,0 +1,42 @@ +// +// TransactionsPagination.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 9/11/23. +// + +import Foundation +import DomainLayer +import Toolkit + +struct TransactionsPagination { + private static let FETCH_INTERVAL_MONTHS = 3 + + let device: DeviceDetails + let transactionsObject: NetworkDeviceIDTokensTransactionsResponse? + let currentPage: Int + var fromDate: String = Date.now.getFormattedDateOffsetByMonths(-Self.FETCH_INTERVAL_MONTHS) + var toDate: String = Date.now.getFormattedDateOffsetByMonths(0) + + /// Returns the params for the next page request + /// - Returns: Tuple with the essentials + func getNextPagination() -> (page: Int, fromDate: String, toDate: String)? { + guard let transactionsObject else { + return (currentPage, fromDate, toDate) + } + + let hasNextPage = transactionsObject.hasNextPage ?? false + if !hasNextPage { + // If there is no next page, we change the date range of the pagination + // If there is no next page and no data, we assume the transactions list is finished + if !transactionsObject.data.isNilOrEmpty, let date = fromDate.onlyDateStringToDate() { + return (0, date.getFormattedDateOffsetByMonths(-Self.FETCH_INTERVAL_MONTHS), fromDate) + } + return nil + } + + let page = currentPage + 1 + + return (page, fromDate, toDate) + } +} diff --git a/PresentationLayer/UI Components/Screens/Weather Charts/CustomYAxisFormatter.swift b/PresentationLayer/UI Components/Screens/Weather Charts/CustomYAxisFormatter.swift new file mode 100644 index 00000000..ba26f38d --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Weather Charts/CustomYAxisFormatter.swift @@ -0,0 +1,38 @@ +// +// CustomYAxisFormatter.swift +// PresentationLayer +// +// Created by Lampros Zouloumis on 25/8/22. +// + +import Charts +import Foundation + +public class CustomYAxisFormatter: AxisValueFormatter { + private let weatherUnit: String + private let decimals: Int + + public init(weatherUnit: String, decimals: Int = 0) { + self.weatherUnit = weatherUnit + self.decimals = decimals + } + + public func stringForValue(_ value: Double, axis _: AxisBase?) -> String { + var returnedStringDecimal = "" + var returnedStringInt = "" + + if weatherUnit == "%" || weatherUnit == "°C" || weatherUnit == "°F" { + returnedStringDecimal = String(format: "%.\(decimals)f\(weatherUnit)", value) + returnedStringInt = "\(Int(round(value)))\(weatherUnit)" + + } else { + returnedStringDecimal = String(format: "%.\(decimals)f \(weatherUnit)", value) + returnedStringInt = "\(Int(round(value))) \(weatherUnit)" + } + + if decimals > 0 { + return returnedStringDecimal + } + return returnedStringInt + } +} diff --git a/PresentationLayer/UI Components/Screens/Weather Charts/NetStatsChartViewModel.swift b/PresentationLayer/UI Components/Screens/Weather Charts/NetStatsChartViewModel.swift new file mode 100644 index 00000000..daf3cb8c --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Weather Charts/NetStatsChartViewModel.swift @@ -0,0 +1,21 @@ +// +// NetStatsChartViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 12/6/23. +// + +import Foundation +import Charts + +struct NetStatsChartViewModel { + let entries: [ChartDataEntry] +} + +extension NetStatsChartViewModel { + static func mock(dataEntries: [ChartDataEntry] = [ChartDataEntry(x: 0.0, y: 1.0), + ChartDataEntry(x: 1.0, y: 2.5), + ChartDataEntry(x: 2.0, y: 4.0)]) -> NetStatsChartViewModel { + return NetStatsChartViewModel(entries: dataEntries) + } +} diff --git a/PresentationLayer/UI Components/Screens/Weather Charts/WeatherChartDataModel.swift b/PresentationLayer/UI Components/Screens/Weather Charts/WeatherChartDataModel.swift new file mode 100644 index 00000000..9a8b89be --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Weather Charts/WeatherChartDataModel.swift @@ -0,0 +1,31 @@ +// +// WeatherChartDataModel.swift +// DomainLayer +// +// Created by Pantelis Giazitsis on 9/5/23. +// + +import Charts +import DomainLayer + +struct WeatherChartDataModel { + let weatherField: WeatherField + var timestamps: [String] + var entries: [ChartDataEntry] + + public func isNilOrEmpty() -> Bool { + return timestamps.isEmpty && entries.isEmpty + } +} + +extension WeatherChartDataModel { + static func mock(type: WeatherField = .temperature, + timestamps: [String] = ["0", "1", "2"], + dataEntries: [ChartDataEntry] = [ChartDataEntry(x: 0.0, y: 1.0), + ChartDataEntry(x: 1.0, y: 2.5), + ChartDataEntry(x: 2.0, y: 4.0)]) -> WeatherChartDataModel { + return WeatherChartDataModel(weatherField: type, + timestamps: timestamps, + entries: dataEntries) + } +} diff --git a/PresentationLayer/UI Components/Screens/Weather Charts/WeatherLineChart.swift b/PresentationLayer/UI Components/Screens/Weather Charts/WeatherLineChart.swift new file mode 100644 index 00000000..dc1c34e7 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Weather Charts/WeatherLineChart.swift @@ -0,0 +1,291 @@ +// +// WXMLineChart.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 11/5/23. +// + +import Foundation +import SwiftUI +import Charts +import DomainLayer + +enum WeatherChartsConstants { + static let LINE_WIDTH: CGFloat = 2.0 + static let POINT_SIZE: CGFloat = 2.0 + static let X_AXIS_DEFAULT_TIME_GRANULARITY: Double = 3.0 + static let X_AXIS_GRANULARITY_1_HOUR: Double = 1.0 + static let VALID_DATASET_LABEL = "validDataset" + + /* Colors */ + static let GRID_COLOR = UIColor(colorEnum: .bg) + static let legendColors: [ColorEnum] = [.primary, .chartSecondaryLine] + +} + +struct WeatherLineChart: UIViewRepresentable { + let type: ChartCardType + let chartData: [WeatherChartDataModel] + let delegate: ChartDelegate + + func makeUIView(context _: Context) -> WeatherLineChartView { + let chartView = WeatherLineChartView() + chartView.delegate = delegate + chartView.initializeWXMChart(type: type, chartData: chartData) + + return chartView + } + + func updateUIView(_ uiView: WeatherLineChartView, context _: Context) { + guard let selectedIndex = delegate.selectedIndex, + let dataSetIndex = uiView.lineData?.dataSets.firstIndex(where: { dataSet in + let label = dataSet.label + let entries = dataSet.entriesForXValue(Double(selectedIndex)) + return label == WeatherChartsConstants.VALID_DATASET_LABEL && !entries.isEmpty + }) else { + return + } + + uiView.highlightValue(x: Double(selectedIndex), dataSetIndex: dataSetIndex, callDelegate: false) + } +} + +class WeatherLineChartView: LineChartView { + func initializeWXMChart(type: ChartCardType, chartData: [WeatherChartDataModel]) { + configureDefault(chartData: chartData) + configure(for: type, chartData: chartData) + notifyDataSetChanged() + } +} + +private extension WeatherLineChartView { + + func configureDefault(chartData: [WeatherChartDataModel]) { + legend.enabled = false + lineData?.setDrawValues(false) + scaleYEnabled = false + dragYEnabled = false + + leftAxis.granularityEnabled = true + leftAxis.resetCustomAxisMin() + leftAxis.resetCustomAxisMax() + leftAxis.zeroLineWidth = 1.0 + leftAxis.zeroLineColor = UIColor(colorEnum: .darkGrey) + leftAxis.drawZeroLineEnabled = true + leftAxis.labelCount = 4 + + rightAxis.granularityEnabled = true + rightAxis.resetCustomAxisMin() + rightAxis.resetCustomAxisMax() + rightAxis.labelCount = 4 + + xAxis.labelPosition = .bottom + xAxis.drawAxisLineEnabled = false + xAxis.granularity = WeatherChartsConstants.X_AXIS_DEFAULT_TIME_GRANULARITY + xAxis.avoidFirstLastClippingEnabled = true + let allTimestamps = chartData.flatMap { $0.timestamps }.withNoDuplicates + xAxis.valueFormatter = ChartXAxisValueFormatter(times: allTimestamps) + + // Axis Colors + leftAxis.gridColor = WeatherChartsConstants.GRID_COLOR + rightAxis.gridColor = WeatherChartsConstants.GRID_COLOR + rightAxis.drawGridLinesEnabled = false + xAxis.gridColor = WeatherChartsConstants.GRID_COLOR + } + + func configure(for type: ChartCardType, chartData: [WeatherChartDataModel]) { + var dataSets: [LineChartDataSet] = [] + for (index, element) in chartData.enumerated() { + let datasetsTuple = generateDataSets(from: element.entries, + color: WeatherChartsConstants.legendColors[safe: index] ?? .primary, + weatherField: element.weatherField, + axisDependency: type.getAxisDependecy(for: element.weatherField)) + + // Insert at beggining to render the first model + // on top of the others + dataSets.insert(contentsOf: datasetsTuple.validDataSets, at: 0) + dataSets.insert(contentsOf: datasetsTuple.emptyDataSets, at: 0) + } + + adjustAxisToPreventLabelsFromHiding(dataSets: dataSets.filter { $0.label == WeatherChartsConstants.VALID_DATASET_LABEL }) + + let lineData = LineChartData(dataSets: dataSets) + lineData.setValueTextColor(.clear) + data = lineData + + adjustAxisToPreventLabelsFromHiding(data: lineData) + + if let firstWeatherField = type.weatherFields.first { + leftAxis.valueFormatter = YAxisValueFormatter(weatherField: firstWeatherField, handleSidePadding: true) + leftAxis.granularity = firstWeatherField.granularity(for: lineData) + } + + if let lastWeatherField = type.weatherFields.last { + rightAxis.valueFormatter = YAxisValueFormatter(weatherField: lastWeatherField, handleSidePadding: false) + rightAxis.granularity = lastWeatherField.granularity(for: lineData) + } + + rightAxis.enabled = type.isRightAxisEnabled + + switch type { + case .temperature: + break + case .precipitation: + rightAxis.axisMinimum = 0.0 + leftAxis.axisMinimum = 0.0 + case .wind: + break + case .humidity: + break + case .pressure: + let isInHg = WeatherUnitsManager.default.pressureUnit == .inchOfMercury + let yMin = lineData.yMin + let yMax = lineData.yMax + if isInHg && (yMax - yMin < 2.0) { + leftAxis.axisMinimum = yMin - 0.1 + leftAxis.axisMaximum = yMax + 0.1 + rightAxis.axisMinimum = yMin - 0.1 + rightAxis.axisMaximum = yMax + 0.1 + } + case .solar: + rightAxis.axisMinimum = 0.0 + leftAxis.axisMinimum = 0.0 + } + } + + func configureDataSet(dataSet: LineChartDataSet, for weatherField: WeatherField) { + switch weatherField { + case .temperature: + break + case .feelsLike: + break + case .humidity: + break + case .wind: + break + case .windDirection: + break + case .precipitationRate: + dataSet.drawCirclesEnabled = dataSet.entries.count == 1 + dataSet.mode = .stepped + case .precipitationProbability: + break + case .dailyPrecipitation: + dataSet.drawCirclesEnabled = dataSet.entries.count == 1 + dataSet.lineWidth = 0.0 + dataSet.mode = .horizontalBezier + dataSet.drawFilledEnabled = true + case .windGust: + break + case .pressure: + break + case .solarRadiation: + dataSet.drawCirclesEnabled = dataSet.entries.count == 1 + dataSet.drawFilledEnabled = true + dataSet.lineWidth = 0.0 + dataSet.mode = .horizontalBezier + case .illuminance: + break + case .dewPoint: + break + case .uv: + dataSet.drawCirclesEnabled = dataSet.entries.count == 1 + dataSet.mode = .stepped + } + } + + func adjustAxisToPreventLabelsFromHiding(dataSets: [LineChartDataSet]) { + #warning("TBD: Since the y axis labels are hidden is this snippet necessary") + /* + * If max - min < 2 that means that the values are probably too close together. + * Which causes a bug not showing labels on Y axis because granularity is set 1. + * So this is a custom fix to add custom minimum and maximum values on the Y Axis + */ +// let yMax = dataSets.max { $0.yMax < $1.yMax }?.yMax ?? 0.0 +// let yMin = dataSets.min { $0.yMin < $1.yMin }?.yMin ?? 0.0 +// if yMax - yMin < 2 { +// leftAxis.axisMinimum = yMin - 1 +// leftAxis.axisMaximum = yMax + 1 +// } + } + + func adjustAxisToPreventLabelsFromHiding(data: LineChartData) { + let yMax = data.yMax + let yMin = data.yMin + if yMax - yMin < 2 { + leftAxis.axisMinimum = yMin - 1 + leftAxis.axisMaximum = yMax + 1 + rightAxis.axisMinimum = yMin - 1 + rightAxis.axisMaximum = yMax + 1 + } + } + + func generateDataSets(from entries: [ChartDataEntry], + color: ColorEnum, + weatherField: WeatherField, + axisDependency: YAxis.AxisDependency) -> (validDataSets: [LineChartDataSet], emptyDataSets: [LineChartDataSet]) { + var validDataSets: [LineChartDataSet] = [] + var emptyDataSets: [LineChartDataSet] = [] + + var iterateEntries = entries + while !iterateEntries.isEmpty { + let withValues = iterateEntries.remove(while: { !$0.y.isNaN }) + if !withValues.isEmpty { + let dataSet = LineChartDataSet(entries: withValues, label: WeatherChartsConstants.VALID_DATASET_LABEL) + dataSet.setDefaultSettings(color: UIColor(colorEnum: color)) + configureDataSet(dataSet: dataSet, for: weatherField) + dataSet.axisDependency = axisDependency + validDataSets.append(dataSet) + } + + let withNoValues = iterateEntries.remove(while: { $0.y.isNaN }) + if !withNoValues.isEmpty { + let yMax = validDataSets.last?.yMax ?? 0.0 + let emptyEntries = withNoValues.map { ChartDataEntry(x: $0.x, y: yMax) } + let emptyDataSet = LineChartDataSet(entries: emptyEntries) + emptyDataSet.setDefaultSettings(color: .clear) + emptyDataSet.highlightEnabled = false + emptyDataSet.axisDependency = axisDependency + emptyDataSets.append(emptyDataSet) + } + } + + return (validDataSets, emptyDataSets) + } +} + +private extension LineChartDataSet { + func setDefaultSettings(color: UIColor = UIColor(colorEnum: .primary)) { + drawCircleHoleEnabled = false + circleRadius = WeatherChartsConstants.POINT_SIZE + lineWidth = WeatherChartsConstants.LINE_WIDTH + mode = .cubicBezier + setColor(color) + setCircleColor(color) + fillColor = color + highlightColor = color + drawHorizontalHighlightIndicatorEnabled = false + highlightLineWidth = 1.0 + highlightColor = UIColor(colorEnum: .primary) + highlightLineDashLengths = [4.0] + } +} + +private extension WeatherField { + func granularity(for lineData: LineChartData) -> Double { + switch self { + case .temperature, .feelsLike, .humidity, .wind, .windDirection, + .precipitationProbability, .windGust, .solarRadiation, .illuminance, .dewPoint, .uv: + return 1.0 + case .precipitationRate, .dailyPrecipitation: + let isInchesSelected = WeatherUnitsManager.default.precipitationUnit == .inches + return isInchesSelected ? 0.01 : 0.1 + case .pressure: + let isInHg = WeatherUnitsManager.default.pressureUnit == .inchOfMercury + let yMin = lineData.yMin + let yMax = lineData.yMax + let shouldChangeGranularity = isInHg && (yMax - yMin < 2.0) + return isInHg ? 0.01 : 1.0 + } + } +} diff --git a/PresentationLayer/UI Components/Screens/Weather Charts/XAxisValueFormatter.swift b/PresentationLayer/UI Components/Screens/Weather Charts/XAxisValueFormatter.swift new file mode 100644 index 00000000..22a69b1a --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Weather Charts/XAxisValueFormatter.swift @@ -0,0 +1,19 @@ +// +// XAxisValueFormatter.swift +// PresentationLayer +// +// Created by Lampros Zouloumis on 25/8/22. +// + +import Charts + +public class ChartXAxisValueFormatter: AxisValueFormatter { + private let times: [String] + public init(times: [String]) { + self.times = times + } + + public func stringForValue(_ value: Double, axis _: AxisBase?) -> String { + return times[safe: Int(value)] ?? String(value) + } +} diff --git a/PresentationLayer/UI Components/Screens/Weather Charts/YAxisValueFormatter.swift b/PresentationLayer/UI Components/Screens/Weather Charts/YAxisValueFormatter.swift new file mode 100644 index 00000000..227ed6d5 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/Weather Charts/YAxisValueFormatter.swift @@ -0,0 +1,53 @@ +// +// YAxisValueFormatter.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 22/8/23. +// + +import Foundation +import Charts +import DomainLayer + +class YAxisValueFormatter: AxisValueFormatter { + + let weatherField: WeatherField + let handleSidePadding: Bool + private let mainVM = MainScreenViewModel.shared + private let maxCharsCount = 4 + private lazy var formatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.maximumFractionDigits = 1 + return formatter + }() + + init(weatherField: WeatherField, handleSidePadding: Bool) { + self.weatherField = weatherField + self.handleSidePadding = handleSidePadding + } + + func stringForValue(_ value: Double, axis: Charts.AxisBase?) -> String { + var valueStr = formatter.string(from: NSNumber(value: value)) ?? "" + if valueStr.count > 4 { + valueStr = value.toCompactDecimaFormat ?? "" + } + + let fixedStr = fixValueString(str: valueStr) + return fixedStr + } +} + +private extension YAxisValueFormatter { + func fixValueString(str: String) -> String { + guard handleSidePadding else { + return str + } + + var fixedStr = str + while fixedStr.count < maxCharsCount { + fixedStr = " " + fixedStr + } + + return fixedStr + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Home/Filters/FilterView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Home/Filters/FilterView.swift new file mode 100644 index 00000000..797dd0a1 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Home/Filters/FilterView.swift @@ -0,0 +1,123 @@ +// +// FilterView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 15/9/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct FilterView: View { + @Binding var show: Bool + @StateObject var viewModel: FilterViewModel + + var body: some View { + VStack { + ScrollView(showsIndicators: false) { + VStack(spacing: CGFloat(.defaultSpacing)) { + HStack { + Text(LocalizableString.Filters.title.localized) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .foregroundStyle(Color(colorEnum: .darkestBlue)) + + Spacer() + } + + section(title: SortBy.title, + options: SortBy.allCases, + selected: viewModel.selectedSortBy) + section(title: Filter.title, + options: Filter.allCases, + selected: viewModel.selectedFilter) + section(title: GroupBy.title, + options: GroupBy.allCases, + selected: viewModel.selectedGroupBy) + } + .padding(CGFloat(.defaultSidePadding)) + } + + bottomButtons + .padding(CGFloat(.defaultSidePadding)) + } + .onAppear { + Logger.shared.trackScreen(.sortFilter) + } + } +} + +private extension FilterView { + @ViewBuilder + var bottomButtons: some View { + HStack { + Button { + viewModel.handleResetTap() + } label: { + Text(LocalizableString.Filters.reset.localized) + } + .buttonStyle(WXMButtonStyle.plain(fixedSize: true)) + + Spacer() + + PercentageGridLayoutView(alignments: [.center, .center], firstColumnPercentage: 0.5) { + Group { + Button { + Logger.shared.trackEvent(.userAction, + parameters: [.actionName: .filtersCancel]) + show = false + } label: { + Text(LocalizableString.cancel.localized) + } + .buttonStyle(WXMButtonStyle.plain(fixedSize: true)) + + Button { + viewModel.handleSaveTap() + show = false + } label: { + Text(LocalizableString.save.localized) + .padding(.vertical, CGFloat(.smallSidePadding)) + .frame(maxWidth: .infinity) + } + .buttonStyle(WXMButtonStyle.filled(fixedSize: true)) + .disabled(!viewModel.isSaveEnabled) + } + } + } + } + + @ViewBuilder + func section(title: String, options: [some FilterPresentable], selected: some FilterPresentable) -> some View { + VStack(spacing: CGFloat(.mediumSpacing)) { + HStack { + Text(title) + .font(.system(size: CGFloat(.largeFontSize), weight: .bold)) + .foregroundStyle(Color(colorEnum: .darkestBlue)) + + Spacer() + } + + ForEach(options, id: \.self.description) { option in + Button { + viewModel.handleTapOn(filterPresentable: option) + } label: { + HStack(spacing: CGFloat(.smallSpacing)) { + let isSelected = option.description == selected.description + Image(asset: isSelected ? .radioButtonActive : .radioButton) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: isSelected ? .primary : .midGrey)) + Text(option.description) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + Spacer() + } + } + + } + } + } +} + +#Preview { + FilterView(show: .constant(true), viewModel: ViewModelsFactory.getFilterViewModel()) +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Home/Filters/FilterViewModel.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Home/Filters/FilterViewModel.swift new file mode 100644 index 00000000..1a454e35 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Home/Filters/FilterViewModel.swift @@ -0,0 +1,94 @@ +// +// FilterViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 15/9/23. +// + +import Foundation +import Combine +import DomainLayer +import Toolkit + +class FilterViewModel: ObservableObject { + @Published var selectedSortBy: SortBy = .defaultValue + @Published var selectedFilter: Filter = .defaultValue + @Published var selectedGroupBy: GroupBy = .defaultValue + var isSaveEnabled: Bool { + initialFilters?.sortBy != selectedSortBy || + initialFilters?.filter != selectedFilter || + initialFilters?.groupBy != selectedGroupBy + } + + private let useCase: FiltersUseCase + private var cancellableSet: Set = [] + private var initialFilters: FilterValues? + + init(useCase: FiltersUseCase) { + self.useCase = useCase + observeFilters() + } + + func handleTapOn(filterPresentable: any FilterPresentable) { + switch filterPresentable { + case let sortBy as SortBy: + selectedSortBy = sortBy + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .filters, + .itemId: .sortBy, + .itemListId: selectedSortBy.analyticsParameterValue]) + case let filter as Filter: + selectedFilter = filter + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .filters, + .itemId: .filter, + .itemListId: selectedFilter.analyticsParameterValue]) + case let groupBy as GroupBy: + selectedGroupBy = groupBy + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .filters, + .itemId: .groupBy, + .itemListId: selectedGroupBy.analyticsParameterValue]) + default: + break + } + } + + func handleResetTap() { + resetSelectedFilters(defaults: true) + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .filtersReset]) + } + + func handleSaveTap() { + let values: FilterValues = .init(sortBy: selectedSortBy, filter: selectedFilter, groupBy: selectedGroupBy) + useCase.saveFilters(filterValues: values) + Logger.shared.trackEvent(.userAction, parameters: getSaveEventParameters()) + } +} + +private extension FilterViewModel { + + func getSaveEventParameters() -> [Parameter: ParameterValue] { + [.actionName: .filtersSave, + .sortBy: selectedSortBy.analyticsParameterValue, + .filter: selectedFilter.analyticsParameterValue, + .groupBy: selectedGroupBy.analyticsParameterValue] + } + + func observeFilters() { + useCase.getFiltersPublisher().sink { [weak self] filtersTuple in + self?.initialFilters = filtersTuple + self?.resetSelectedFilters(defaults: false) + }.store(in: &cancellableSet) + } + + func resetSelectedFilters(defaults: Bool) { + guard !defaults, let initialFilters else { + selectedSortBy = .defaultValue + selectedFilter = .defaultValue + selectedGroupBy = .defaultValue + return + } + + selectedSortBy = initialFilters.sortBy + selectedFilter = initialFilters.filter + selectedGroupBy = initialFilters.groupBy + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Home/WeatherStationsHomeView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Home/WeatherStationsHomeView.swift new file mode 100644 index 00000000..36369c3b --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Home/WeatherStationsHomeView.swift @@ -0,0 +1,171 @@ +// +// WeatherStationsHomeView.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 18/5/22. +// + +import DomainLayer +import MapKit +import SwiftUI +import Toolkit + +struct WeatherStationsHomeView: View { + + @Binding private var isTabBarShowing: Bool + @Binding private var tabBarItemsSize: CGSize + @Binding private var isWalletEmpty: Bool + @State private var showFilters: Bool = false + @StateObject private var viewModel: WeatherStationsHomeViewModel + + init(swinjectHelper: SwinjectInterface, isTabBarShowing: Binding, tabBarItemsSize: Binding, isWalletEmpty: Binding) { + let container = swinjectHelper.getContainerForSwinject() + _viewModel = StateObject(wrappedValue: ViewModelsFactory.getWeatherStationsHomeViewModel()) + _isTabBarShowing = isTabBarShowing + _tabBarItemsSize = tabBarItemsSize + _isWalletEmpty = isWalletEmpty + } + + var body: some View { + NavigationContainerView(showBackButton: false) { + navigationBarRightView + } content: { + ContentView(vieModel: viewModel, + isTabBarShowing: $isTabBarShowing, + tabBarItemsSize: $tabBarItemsSize, + isWalletEmpty: $isWalletEmpty) + .bottomSheet(show: $showFilters, initialDetentId: .large) { + FilterView(show: $showFilters, viewModel: ViewModelsFactory.getFilterViewModel()) + } + } + } + + @ViewBuilder + var navigationBarRightView: some View { + Button { + showFilters = true + } label: { + Text(FontIcon.sliders.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: viewModel.isFiltersActive ? .primary : .text)) + .frame(width: 30.0, height: 30.0) + } + } +} + +private struct ContentView: View { + @Environment(\.scenePhase) var scenePhase + @EnvironmentObject var navigationObject: NavigationObject + + @StateObject private var viewModel: WeatherStationsHomeViewModel + @Binding private var isTabBarShowing: Bool + @Binding private var tabBarItemsSize: CGSize + @Binding private var isWalletEmpty: Bool + private let mainVM: MainScreenViewModel = .shared + + init(vieModel: WeatherStationsHomeViewModel, isTabBarShowing: Binding, tabBarItemsSize: Binding, isWalletEmpty: Binding) { + _viewModel = StateObject(wrappedValue: vieModel) + _isTabBarShowing = isTabBarShowing + _tabBarItemsSize = tabBarItemsSize + _isWalletEmpty = isWalletEmpty + } + + var body: some View { + VStack(spacing: 0.0) { + weatherStationsFlow(for: viewModel.devices) + .onAppear { + Logger.shared.trackScreen(.deviceList) + } + .zIndex(0) + .onChange(of: viewModel.isTabBarShowing) { newValue in + withAnimation { + self.isTabBarShowing = newValue + } + } + }.onAppear { + viewModel.mainVM = mainVM + viewModel.getDevices() + navigationObject.title = LocalizableString.weatherStationsHomeTitle.localized + } + } + + @ViewBuilder + func weatherStationsFlow(for devices: [DeviceDetails]) -> some View { + weatherStations(devices: devices) + .wxmEmptyView(show: Binding(get: { devices.isEmpty }, set: { _ in }), configuration: viewModel.getEmptyViewConfiguration()) + .fail(show: $viewModel.isFailed, obj: viewModel.failObj) + .spinningLoader(show: $viewModel.shouldShowFullScreenLoader, hideContent: true) + } + + @ViewBuilder + func weatherStations(devices: [DeviceDetails]) -> some View { + TrackableScrollView(showIndicators: false, + offsetObject: viewModel.scrollOffsetObject) { completion in + viewModel.getDevices(refreshMode: true, completion: completion) + } content: { + VStack(spacing: CGFloat(.smallSpacing)) { + if mainVM.showWalletWarning && isWalletEmpty { + CardWarningView(title: LocalizableString.walletAddressMissingTitle.localized, + message: LocalizableString.walletAddressMissingText.localized) { + + Logger.shared.trackEvent(.prompt, parameters: [.promptName: .walletMissing, + .promptType: .warnPromptType, + .action: .dismissAction]) + + withAnimation { + mainVM.hideWalletWarning() + } + } content: { + Button { + Router.shared.navigateTo(.wallet(ViewModelsFactory.getMyWalletViewModel())) + Logger.shared.trackEvent(.prompt, parameters: [.promptName: .walletMissing, + .promptType: .warnPromptType, + .action: .action]) + + } label: { + Text(LocalizableString.addWalletTitle.localized) + .foregroundColor(Color(colorEnum: .primary)) + .font(.system(size: CGFloat(.smallFontSize), weight: .bold)) + } + } + .onAppear { + Logger.shared.trackEvent(.prompt, parameters: [.promptName: .walletMissing, + .promptType: .warnPromptType, + .action: .viewAction]) + } + } + + weatherStationsList(devices: devices) + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + .padding(.top) + .padding(.bottom, tabBarItemsSize.height) + } + } + + @ViewBuilder + func weatherStationsList(devices: [DeviceDetails]) -> some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + ForEach(devices) { device in + WeatherStationCard(device: device, followState: viewModel.getFollowState(for: device)) { + mainVM.showFirmwareUpdate(device: device) + } viewMoreAction: { + Router.shared.navigateTo(.viewMoreAlerts(ViewModelsFactory.getAlertsViewModel(device: device, + mainVM: mainVM, + followState: viewModel.getFollowState(for: device)))) + } followAction: { + viewModel.followButtonTapped(device: device) + } + .onTapGesture { + Router.shared.navigateTo(.stationDetails(ViewModelsFactory.getStationDetailsViewModel(deviceId: device.id ?? "", + cellIndex: device.cellIndex, + cellCenter: device.cellCenter?.toCLLocationCoordinate2D()))) + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .selectDevice, + .contentType: .userDeviceList, + .itemListId: .custom(device.id ?? "")]) + } + } + } + .padding(.bottom) + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Home/WeatherStationsHomeViewModel.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Home/WeatherStationsHomeViewModel.swift new file mode 100644 index 00000000..6d961a58 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Home/WeatherStationsHomeViewModel.swift @@ -0,0 +1,298 @@ +// +// WeatherStationsHomeViewModel.swift +// PresentationLayer +// +// Created by Danae Kikue Dimou on 25/5/22. +// + +import Combine +import DomainLayer +import SwiftUI +import Toolkit + +public final class WeatherStationsHomeViewModel: ObservableObject { + private final let meUseCase: MeUseCase + private let tabBarVisibilityHandler: TabBarVisibilityHandler + private var cancellableSet: Set = [] + private var filters: FilterValues? { + didSet { + updateFilteredDevices() + } + } + private var allDevices: [DeviceDetails] = [] { + didSet { + updateFilteredDevices() + } + } + + /// Dicitonary with device id as key. + /// We use dictionany to reduce the access complexity + private var followStates: [String: UserDeviceFollowState] = [:] { + didSet { + updateFilteredDevices() + } + } + + var isFiltersActive: Bool { + FilterValues.default != filters + } + + @Published var shouldShowFullScreenLoader = true + @Published var devices = [DeviceDetails]() + @Published var scrollOffsetObject: TrackableScrollOffsetObject + @Published var isTabBarShowing: Bool = true + @Published var isFailed = false + private(set) var failObj: FailSuccessStateObject? + weak var mainVM: MainScreenViewModel? + + public init(meUseCase: MeUseCase) { + self.meUseCase = meUseCase + let scrollOffsetObject: TrackableScrollOffsetObject = .init() + self.scrollOffsetObject = scrollOffsetObject + self.tabBarVisibilityHandler = .init(scrollOffsetObject: scrollOffsetObject) + self.tabBarVisibilityHandler.$isTabBarShowing.assign(to: &$isTabBarShowing) + observeFilters() + } + + /// Perform request to get all the essentials + /// - Parameters: + /// - refreshMode: Set true if coming from pull to refresh to prevent showing full screen loader + /// - completion: Called once the request is finished + func getDevices(refreshMode: Bool = false, completion: (() -> Void)? = nil) { + do { + shouldShowFullScreenLoader = !refreshMode + try meUseCase.getDevices() + .sink { [weak self] response in + guard let self else { + return + } + + self.shouldShowFullScreenLoader = false + switch response { + case .failure(let error): + let info = error.uiInfo + let title = info.title + var description = info.description + if error.backendError?.code == FailAPICodeEnum.deviceNotFound.rawValue { + description = LocalizableString.Error.userDeviceNotFound.localized + } + + let obj = FailSuccessStateObject(type: .weatherStations, + title: title, + subtitle: description?.attributedMarkdown, + cancelTitle: nil, + retryTitle: LocalizableString.retry.localized, + actionButtonsAtTheBottom: false, + contactSupportAction: { + HelperFunctions().openContactSupport(successFailureEnum: .weatherStations, + email: self.mainVM?.userInfo?.email, + serialNumber: nil) + }, + cancelAction: nil, + retryAction: { [weak self] in + self?.getDevices() + }) + + self.failObj = obj + self.isFailed = true + case .success(let devices): + self.allDevices = devices + self.refreshFollowStates() + self.isFailed = false + } + + completion?() + }.store(in: &cancellableSet) + } catch { + completion?() + } + } + + func getFollowState(for device: DeviceDetails) -> UserDeviceFollowState? { + guard let deviceId = device.id else { + return nil + } + + return followStates[deviceId] + } + + func followButtonTapped(device: DeviceDetails) { + guard let followState = getFollowState(for: device) else { + return + } + + if followState.relation == .followed { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .devicesListFollow, + .contentType: .unfollow]) + performUnfollow(device: device) + } else { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .devicesListFollow, + .contentType: .follow]) + performFollow(device: device) + } + } + + func getEmptyViewConfiguration() -> WXMEmptyView.Configuration? { + let obj = WXMEmptyView.Configuration(animationEnum: .emptyDevices, + title: LocalizableString.Home.totalWeatherStationsEmptyTitle.localized, + description: LocalizableString.Home.totalWeatherStationsEmptyDescription.localized.attributedMarkdown, + buttonTitle: LocalizableString.Home.totalWeatherStationsEmptyButtonTitle.localized) { [weak self] in + self?.mainVM?.selectedTab = .mapTab + } + return obj + } +} + +private extension WeatherStationsHomeViewModel { + func refreshFollowStates() { + Task { @MainActor [weak self] in + guard let self else { return } + let states: [UserDeviceFollowState] = await self.allDevices.asyncCompactMap { device in + guard let deviceId = device.id else { + return nil + } + return try? await self.meUseCase.getDeviceFollowState(deviceId: deviceId).get() + } + + let followStates: [String: UserDeviceFollowState] = [:] + self.followStates = states.reduce(into: followStates) { $0[$1.deviceId] = $1 } + } + } + + func performFollow(device: DeviceDetails) { + guard let deviceId = device.id else { + return + } + + let followAction = { [weak self] in + guard let self else { + return + } + LoaderView.shared.show() + Task { + + let result = try await self.meUseCase.followStation(deviceId: deviceId) + + DispatchQueue.main.async { + LoaderView.shared.dismiss { + self.handleFollowResult(result) + } + } + } + } + + if device.isActive == false { + let title = LocalizableString.followAlertTitle.localized + let description = LocalizableString.followAlertDescription(device.name).localized + let okAction: AlertHelper.AlertObject.Action = (LocalizableString.confirm.localized, { _ in followAction() }) + let obj = AlertHelper.AlertObject(title: title, + message: description, + okAction: okAction) + AlertHelper().showAlert(obj) + + } else { + followAction() + } + } + + func performUnfollow(device: DeviceDetails) { + guard let deviceId = device.id else { + return + } + + let okAction: AlertHelper.AlertObject.Action = (LocalizableString.confirm.localized, { _ in + LoaderView.shared.show() + Task { [weak self] in + guard let self else { + return + } + let result = try await meUseCase.unfollowStation(deviceId: deviceId) + + DispatchQueue.main.async { + LoaderView.shared.dismiss { + self.handleFollowResult(result) + } + } + } + }) + + let title = LocalizableString.unfollowAlertTitle.localized + let description = LocalizableString.unfollowAlertDescription(device.name).localized + let obj = AlertHelper.AlertObject(title: title, + message: description, + okAction: okAction) + AlertHelper().showAlert(obj) + } + + func handleFollowResult(_ result: Result) { + switch result { + case .success: + DispatchQueue.main.async { + self.getDevices() + } + case .failure(let error): + let info = error.uiInfo + DispatchQueue.main.async { + Toast.shared.show(text: info.description?.attributedMarkdown ?? "") + } + } + } + + func observeFilters() { + meUseCase.getFiltersPublisher().sink { [weak self] filters in + self?.filters = filters + }.store(in: &cancellableSet) + } + + func updateFilteredDevices() { + guard let filters else { + devices = allDevices + return + } + + var filteredDevices = allDevices.sorted(by: sortDevices(filterValues: filters)).filter(filterDevices(filterValues: filters)) + + filteredDevices = groupDevices(devices: filteredDevices, filterValues: filters) + devices = filteredDevices + } + + func sortDevices(filterValues: FilterValues) -> (DeviceDetails, DeviceDetails) -> Bool { + { + switch filterValues.sortBy { + case .dateAdded: + false + case .name: + $0.displayName < $1.displayName + case .lastActive: + $0.lastActiveAt!.timestampToDate() > $1.lastActiveAt!.timestampToDate() + } + } + } + + func filterDevices(filterValues: FilterValues) -> (DeviceDetails) -> Bool { + { [weak self] in + switch filterValues.filter { + case .all: + true + case .ownedOnly: + self?.getFollowState(for: $0)?.state == .owned + case .favoritesOnly: + self?.getFollowState(for: $0)?.state == .followed + } + } + } + + func groupDevices(devices: [DeviceDetails], filterValues: FilterValues) -> [DeviceDetails] { + switch filterValues.groupBy { + case .noGroup: + return devices + case .relationship: + let dict = Dictionary(grouping: devices, by: { getFollowState(for: $0)?.state }) + return [dict[.owned], dict[.followed], dict[nil]].flatMap { $0 ?? [] } + case .status: + let dict = Dictionary(grouping: devices, by: { $0.isActive }) + return [dict[true], dict[false]].flatMap { $0 ?? [] } + + } + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/CustomRangeSlider.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/CustomRangeSlider.swift new file mode 100644 index 00000000..85ba754b --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/CustomRangeSlider.swift @@ -0,0 +1,62 @@ +// +// CustomRangeSlider.swift +// PresentationLayer +// +// Created by Panagiotis Palamidas on 29/7/22. +// + +import SwiftUI + +struct CustomRangeSlider: View { + var minWeeklyTemp: CGFloat + var maxWeeklyTemp: CGFloat + var minDailyTemp: CGFloat + var maxDailyTemp: CGFloat + + private let sliderHeight: CGFloat = 14.0 + + var body: some View { + GeometryReader { proxy in + ZStack { + Color(colorEnum: .bg) + shapeFor(size: proxy.size) + } + } + .frame(height: sliderHeight) + .cornerRadius(CGFloat(.cardCornerRadius)) + } +} + +private extension CustomRangeSlider { + var totalRange: CGFloat { + maxWeeklyTemp - minWeeklyTemp + } + + var diff: CGFloat { + maxDailyTemp - minDailyTemp + } + + var offset: CGFloat { + minDailyTemp - minWeeklyTemp + } + + @ViewBuilder + func shapeFor(size: CGSize) -> some View { + HStack { + Rectangle() + .fill(Color(colorEnum: .primary)) + .frame(width: (diff * size.width) / totalRange, + height: sliderHeight) + .cornerRadius(CGFloat(.cardCornerRadius)) + .offset(x: (offset * size.width) / totalRange) + + Spacer(minLength: 0.0) + } + } +} + +struct Previews_CustomRangeSlider_Previews: PreviewProvider { + static var previews: some View { + CustomRangeSlider(minWeeklyTemp: 8.0, maxWeeklyTemp: 19.0, minDailyTemp: 13, maxDailyTemp: 19) + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForeCastCardView+Content.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForeCastCardView+Content.swift new file mode 100644 index 00000000..1e944271 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForeCastCardView+Content.swift @@ -0,0 +1,154 @@ +// +// StationForecastCardView+Content.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 16/3/23. +// + +import SwiftUI +import DomainLayer + +extension StationForecastCardView { + @ViewBuilder + var dailyView: some View { + PercentageGridLayoutView(alignments: [.leading, .trailing], firstColumnPercentage: 0.8) { + Group { + VStack(alignment: .leading, spacing: CGFloat(.smallSpacing)) { + temperatureBar + + HStack(spacing: 0.0) { + ForEach(fields, id: \.self) { field in + fieldView(for: field) + Spacer(minLength: 0.0) + } + } + } + + weatherImage + } + } + } + + @ViewBuilder + var hourlyView: some View { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: CGFloat(.mediumSpacing)) { + ForEach(forecast.hourly ?? []) { weather in + hourlyWeatherView(weather: weather) + } + } + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + } +} + +private extension StationForecastCardView { + @ViewBuilder + var weatherImage: some View { + Group { + if let weather = forecast.daily { + LottieView(animationCase: weather.icon?.getAnimationString() ?? "".getAnimationString(), loopMode: .loop) + } else { + LottieView(animationCase: "anim_not_available", loopMode: .loop) + } + } + .frame(width: weatherIconDimensions, height: weatherIconDimensions) + } + + var fields: [WeatherField] { + [.precipitationProbability, .dailyPrecipitation, .wind, .humidity] + } + + @ViewBuilder + var temperatureBar: some View { + HStack(spacing: CGFloat(.minimumSpacing)) { + Text("\(forecast.daily?.temperatureMin?.toTemeratureString(for: unitsManager.temperatureUnit) ?? "")") + CustomRangeSlider(minWeeklyTemp: minWeekTemperature.toTemeratureUnit(unitsManager.temperatureUnit).rounded(toPlaces: 0), + maxWeeklyTemp: maxWeekTemperature.toTemeratureUnit(unitsManager.temperatureUnit).rounded(toPlaces: 0), + minDailyTemp: forecast.daily?.temperatureMin?.toTemeratureUnit(unitsManager.temperatureUnit).rounded(toPlaces: 0) ?? 0.0, + maxDailyTemp: forecast.daily?.temperatureMax?.toTemeratureUnit(unitsManager.temperatureUnit).rounded(toPlaces: 0) ?? 0.0) + Text("\(forecast.daily?.temperatureMax?.toTemeratureString(for: unitsManager.temperatureUnit) ?? "")") + } + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + } + + @ViewBuilder + func fieldView(for field: WeatherField) -> some View { + if let weather = forecast.daily { + HStack(spacing: 0.0) { + Image(asset: field.icon) + .resizable() + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .frame(width: 25.0, height: 25.0) + + Text(getFieldText(weatherField: field, + weather: weather, + unitsManager: unitsManager)) + .font(.system(size: CGFloat(.caption), + weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + .fixedSize(horizontal: false, vertical: true) + } + } else { + EmptyView() + } + } + + func getFieldText(weatherField: WeatherField, + weather: CurrentWeather, + unitsManager: WeatherUnitsManager) -> String { + let literals = weatherField.weatherLiterals(from: weather, unitsManager: unitsManager) + return "\(literals?.value ?? "")\(weatherField.shouldHaveSpaceWithUnit ? " " : "")\(literals?.unit ?? "")" + } + + @ViewBuilder + func hourlyWeatherView(weather: CurrentWeather) -> some View { + VStack(alignment: .center, spacing: 0.0) { + Text(weather.timestamp?.getTimeForLatestDateWeatherDetail() ?? "") + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.caption))) + LottieView(animationCase: weather.icon?.getAnimationString() ?? "", loopMode: .loop) + .frame(width: forecastHoulrlyScrollerImageSize, + height: forecastHoulrlyScrollerImageSize) + + let temperature = WeatherField.temperature.weatherLiterals(from: weather, unitsManager: unitsManager) + Text("\(temperature?.value ?? "")\(temperature?.unit ?? "")") + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.mediumFontSize), weight: .bold)) + + let feelsLikeTemperature = WeatherField.feelsLike.weatherLiterals(from: weather, unitsManager: unitsManager) + Text("\(LocalizableString.feelsLike.localized) **\(feelsLikeTemperature?.value ?? "")°**".attributedMarkdown ?? "") + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.caption))) + + VStack(alignment: .leading, spacing: CGFloat(.smallSpacing)) { + ForEach(WeatherField.hourlyFields, id: \.self) { field in + hourlyWeatherFieldView(weather: weather, field: field) + } + } + .padding(.top, CGFloat(.smallSidePadding)) + } + } + + @ViewBuilder + func hourlyWeatherFieldView(weather: CurrentWeather, field: WeatherField) -> some View { + if let literals = field.weatherLiterals(from: weather, + unitsManager: unitsManager, + includeDirection: false, + isForHourlyForecast: true) { + HStack(spacing: 0.0) { + Image(asset: field.hourlyIcon()) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .darkestBlue)) + .rotationEffect(.degrees(field.iconRotation(from: weather))) + + Text("**\(literals.value)**\(field.shouldHaveSpaceWithUnit ? " " : "")\(literals.unit)") + .foregroundColor(Color(colorEnum: .darkestBlue)) + .font(.system(size: CGFloat(.mediumFontSize))) + } + } else { + EmptyView() + } + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastCardView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastCardView.swift new file mode 100644 index 00000000..cbe0380d --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastCardView.swift @@ -0,0 +1,77 @@ +// +// StationForecastCardView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 16/3/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct StationForecastCardView: View { + let mainVM: MainScreenViewModel = .shared + let unitsManager: WeatherUnitsManager = .default + let forecast: NetworkDeviceForecastResponse + let minWeekTemperature: Double + let maxWeekTemperature: Double + @Binding var isExpanded: Bool + let isExpandable: Bool + + let forecastHoulrlyScrollerImageSize: CGFloat = 50.0 + let weatherIconDimensions: CGFloat = 70.0 + + var body: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + VStack(spacing: CGFloat(.minimumSpacing)) { + HStack { + Text(forecast.daily?.timestamp?.getWeekDayAndDate() ?? "-") + .foregroundColor(Color(colorEnum: .primary)) + .font(.system(size: CGFloat(.normalFontSize))) + + Spacer() + + Image(asset: .downArrow) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: .primary)) + .rotationEffect(.degrees(isExpanded ? 180.0 : 0.0)) + } + .padding(.horizontal, CGFloat(.defaultSidePadding)) + + dailyView + .WXMCardStyle(backgroundColor: Color(colorEnum: .top), + insideHorizontalPadding: CGFloat(.defaultSidePadding), + insideVerticalPadding: CGFloat(.defaultSidePadding), + cornerRadius: CGFloat(.cardCornerRadius)) + } + + if isExpanded { + hourlyView + .padding(.bottom, CGFloat(.smallSidePadding)) + } + } + .padding(.top, CGFloat(.smallSidePadding)) + .WXMCardStyle(backgroundColor: Color(colorEnum: .layer1), + insideHorizontalPadding: 0.0, + insideVerticalPadding: 0.0, + cornerRadius: CGFloat(.cardCornerRadius)) + .onTapGesture { + isExpanded.toggle() + } + .allowsHitTesting(isExpandable) + } +} + +struct StationForecastCardView_Previews: PreviewProvider { + static var previews: some View { + var forecast = NetworkDeviceForecastResponse() + let hourlyWeather = CurrentWeather.mockInstance + forecast.hourly = [hourlyWeather] + forecast.daily = CurrentWeather.mockInstance + return StationForecastCardView(forecast: forecast, + minWeekTemperature: 8.0, + maxWeekTemperature: 20.0, + isExpanded: .constant(true), + isExpandable: true) + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastView.swift new file mode 100644 index 00000000..8e67d1b8 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastView.swift @@ -0,0 +1,95 @@ +// +// StationForecastView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 16/3/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct StationForecastView: View { + @StateObject var viewModel: StationForecastViewModel + @State private var expandedForecasts: Set? + + var body: some View { + ZStack { + ScrollViewReader { proxy in + TrackableScrollView(showIndicators: false, offsetObject: viewModel.offsetObject) { completion in + viewModel.refresh(completion: completion) + } content: { + VStack(spacing: CGFloat(.smallSpacing)) { + ForEach(viewModel.forecasts, id: \.date) { forecast in + StationForecastCardView(forecast: forecast, + minWeekTemperature: viewModel.overallMinTemperature ?? 0.0, + maxWeekTemperature: viewModel.overallMaxTemperature ?? 0.0, + isExpanded: Binding(get: { expandedForecasts?.contains(forecast.date) == true }, + set: { _ in + withAnimation(.easeIn(duration: 0.3)) { + let isExpanded = expandCollapseCard(for: forecast.date) + if isExpanded { + proxy.scrollTo(forecast.date, anchor: .top) + } + viewModel.trackSelectContentEvent(forecast: forecast, isOpen: isExpanded) + } + }), + isExpandable: forecast.hourly?.isEmpty == false) + .wxmShadow() + } + } + .padding() + } + } + .onAppear { + withAnimation { + initializeExpandedForecastsIfNeeded() + } + } + } + .wxmEmptyView(show: Binding(get: { viewModel.viewState == .hidden }, set: { _ in }), configuration: viewModel.hiddenViewConfiguration) + .fail(show: Binding(get: { viewModel.viewState == .fail }, set: { _ in }), obj: viewModel.failObj) + .spinningLoader(show: Binding(get: { viewModel.viewState == .loading }, set: { _ in }), hideContent: true) + .onAppear { + Logger.shared.trackScreen(.forecast) + } + } +} + +private extension StationForecastView { + /// Expand/collapse the card for the passed date according to its state + /// - Parameter date: The date to expand or collapse + /// - Returns: `true` if will be expanded, `false` if not + func expandCollapseCard(for date: String) -> Bool { + if expandedForecasts?.contains(date) == true { + expandedForecasts?.remove(date) + return false + } + + expandedForecasts?.insert(date) + return true + } + + func initializeExpandedForecastsIfNeeded() { + guard expandedForecasts == nil else { + return + } + + expandedForecasts = [] + if let firstForecast = viewModel.forecasts.first, + firstForecast.hourly?.isEmpty == false { + let firstDate = firstForecast.date + expandedForecasts?.insert(firstDate) + } + } +} + +struct StationForecastView_Previews: PreviewProvider { + static var previews: some View { + let vm = StationForecastViewModel.mockInstance + Task { @MainActor in + await vm.refreshWithDevice(.emptyDeviceDetails, followState: .init(deviceId: "", relation: .followed), error: nil) + } + return StationForecastView(viewModel: vm) + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastViewModel.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastViewModel.swift new file mode 100644 index 00000000..e5873a98 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Forecast/StationForecastViewModel.swift @@ -0,0 +1,164 @@ +// +// StationForecastViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 16/3/23. +// + +import Foundation +import Toolkit +import DomainLayer +import Combine + +class StationForecastViewModel: ObservableObject { + weak var containerDelegate: StationDetailsViewModelDelegate? + let offsetObject: TrackableScrollOffsetObject = TrackableScrollOffsetObject() + @Published private(set) var forecasts: [NetworkDeviceForecastResponse] = [] + @Published private(set) var viewState: ViewState = .loading + var overallMinTemperature: Double? { + forecasts.min { ($0.daily?.temperatureMin ?? 0.0) < ($1.daily?.temperatureMin ?? 0.0) }?.daily?.temperatureMin + } + var overallMaxTemperature: Double? { + forecasts.max { ($0.daily?.temperatureMax ?? 0.0) < ($1.daily?.temperatureMax ?? 0.0) }?.daily?.temperatureMax + } + private(set) var failObj: FailSuccessStateObject? + private(set) var hiddenViewConfiguration: WXMEmptyView.Configuration? + + private var device: DeviceDetails? + private var cancellables: Set = [] + private let useCase: MeUseCase? + + init(containerDelegate: StationDetailsViewModelDelegate? = nil, useCase: MeUseCase?) { + self.containerDelegate = containerDelegate + self.useCase = useCase + observeOffset() + } + + func refresh(completion: @escaping VoidCallback) { + + Task { @MainActor in + await containerDelegate?.shouldRefresh() + completion() + } + } + + func hadndleRetryButtonTap() { + viewState = .loading + refresh {} + } + + func trackSelectContentEvent(forecast: NetworkDeviceForecastResponse, isOpen: Bool) { + var params: [Parameter: ParameterValue] = [.contentType: .forecastDay, + .itemId: .custom(device?.id ?? ""), + .state: isOpen ? .openState : .closeState] + + if let index = forecasts.firstIndex(where: { $0.date == forecast.date }) { + params += [.index: .custom("\(index)")] + } + + Logger.shared.trackEvent(.selectContent, parameters: params) + } + + private func getDeviceForecastDaily(deviceId: String?) async -> Result<[NetworkDeviceForecastResponse], NetworkErrorResponse>? { + guard let deviceId = deviceId else { + return nil + } + + do { + let getUserDeviceForecastById = try useCase?.getUserDeviceForecastById(deviceId: deviceId, + fromDate: getCurrentDateInStringForForecast(), + toDate: getΤοDateForWeeklyForecastCall()) + return await withCheckedContinuation { continuation in + + getUserDeviceForecastById?.sink { response in + continuation.resume(returning: response.result) + }.store(in: &cancellables) + } + } catch { return nil } + } +} + +private extension StationForecastViewModel { + func observeOffset() { + offsetObject.$diffOffset.sink { [weak self] value in + guard let self = self else { + return + } + self.containerDelegate?.offsetUpdated(diffOffset: value) + } + .store(in: &cancellables) + } + + func generateHiddenViewConfiguration() -> WXMEmptyView.Configuration { + let description: String = LocalizableString.hiddenContentDescription( device?.name ?? "").localized + let buttonIcon: FontIcon = .heart + let buttonTitle: LocalizableString = .favorite + let buttonAction: VoidCallback = { [weak self] in self?.containerDelegate?.shouldAskToFollow() } + + return WXMEmptyView.Configuration(image: (.lockedIcon, .darkestBlue), + title: LocalizableString.hiddenContentTitle.localized, + description: description.attributedMarkdown ?? "", + buttonFontIcon: buttonIcon, + buttonTitle: buttonTitle.localized, + action: buttonAction) + } + + func handleResponseResult(_ result: Result<[NetworkDeviceForecastResponse], NetworkErrorResponse>) { + switch result { + case .success(let forecasts): + self.forecasts = forecasts + self.viewState = .content + case .failure(let error): + let obj = error.uiInfo.defaultFailObject(type: .stationForecast) { + self.hadndleRetryButtonTap() + } + + self.failObj = obj + self.viewState = .fail + } + } + + func getΤοDateForWeeklyForecastCall() -> String { + Date.now.getFormattedDateOffsetByDays(7) + } + + func getCurrentDateInStringForForecast() -> String { + Date.now.getFormattedDate(format: .onlyDate) + } +} + +// MARK: - StationDetailsViewModelChild + +extension StationForecastViewModel: StationDetailsViewModelChild { + func refreshWithDevice(_ device: DeviceDetails?, followState: UserDeviceFollowState?, error: NetworkErrorResponse?) async { + self.device = device + + if followState == nil { + DispatchQueue.main.async { + self.hiddenViewConfiguration = self.generateHiddenViewConfiguration() + self.viewState = .hidden + } + return + } + + guard let res = await getDeviceForecastDaily(deviceId: device?.id) else { + return + } + DispatchQueue.main.async { + self.handleResponseResult(res) + } + } + + func showLoading() { + viewState = .loading + } +} + +// MARK: - Mock + +extension StationForecastViewModel { + + static var mockInstance: StationForecastViewModel { + StationForecastViewModel(useCase: SwinjectHelper.shared.getContainerForSwinject().resolve(MeUseCase.self)) + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Observations/ObservationsView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Observations/ObservationsView.swift new file mode 100644 index 00000000..d2279432 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Observations/ObservationsView.swift @@ -0,0 +1,70 @@ +// +// ObservationsView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 6/3/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct ObservationsView: View { + @StateObject var viewModel: ObservationsViewModel + @State private var containerSize: CGSize = .zero + + var body: some View { + ZStack { + TrackableScrollView(showIndicators: false, offsetObject: viewModel.offsetObject) { completion in + viewModel.refresh(completion: completion) + } content: { + LazyVStack { // Embeded in `LazyVStack` to fix iOS 15 UI issues + VStack(spacing: CGFloat(.defaultSpacing)) { + currentWeatherView + .shadow(color: Color(.black).opacity(0.25), + radius: ShadowEnum.stationCard.radius, + x: ShadowEnum.stationCard.xVal, + y: ShadowEnum.stationCard.yVal) + + if let ctaObj = viewModel.ctaObject { + CTAContainerView(ctaObject: ctaObj) + } + } + .padding() + .padding(.bottom, containerSize.height / 2.0) // Quick fix for better experience while expanding/collapsing the containers's header +#warning("TODO: Find a better solution") + } + } + .sizeObserver(size: $containerSize) + } + .spinningLoader(show: Binding(get: { viewModel.viewState == .loading }, set: { _ in }), hideContent: true) + .fail(show: Binding(get: { viewModel.viewState == .fail }, set: { _ in }), obj: viewModel.failObj) + .onAppear { + Logger.shared.trackScreen(.currentWeather) + } + } +} + +private extension ObservationsView { + @ViewBuilder + var currentWeatherView: some View { + if let device = viewModel.device { + WeatherOverviewView(weather: device.weather, + showSecondaryFields: true, + lastUpdatedText: device.weather?.updatedAtString(with: TimeZone(identifier: device.timezone ?? "") ?? .current), + buttonTitle: LocalizableString.StationDetails.viewHistoricalData.localized, + isButtonEnabled: viewModel.followState != nil) { + viewModel.handleHistoricalDataButtonTap() + } + } else { + EmptyView() + } + } +} + +struct ObservationsView_Previews: PreviewProvider { + static var previews: some View { + let mainVM = MainScreenViewModel.shared + ObservationsView(viewModel: ObservationsViewModel.mockInstance) + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Observations/ObservationsViewModel.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Observations/ObservationsViewModel.swift new file mode 100644 index 00000000..87d80503 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Observations/ObservationsViewModel.swift @@ -0,0 +1,117 @@ +// +// ObservationsViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 6/3/23. +// + +import Foundation +import DomainLayer +import Toolkit +import Combine + +class ObservationsViewModel: ObservableObject { + @Published private(set) var viewState: ViewState = .loading + @Published private(set) var ctaObject: CTAContainerView.CTAObject? + + let offsetObject: TrackableScrollOffsetObject = TrackableScrollOffsetObject() + private(set) var device: DeviceDetails? + private(set) var followState: UserDeviceFollowState? + weak var containerDelegate: StationDetailsViewModelDelegate? + private(set) var failObj: FailSuccessStateObject? + private var cancellables: Set = [] + + init(device: DeviceDetails?) { + self.device = device + refresh {} + observeOffset() + } + + func refresh(completion: @escaping VoidCallback) { + Task { @MainActor [weak self] in + await self?.containerDelegate?.shouldRefresh() + completion() + } + } + + func handleRetryButtonTap() { + viewState = .loading + refresh { + + } + } + + func handleHistoricalDataButtonTap() { + guard let device else { + return + } + Router.shared.navigateTo(.history(ViewModelsFactory.getHistoryContainerViewModel(device: device))) + } +} + +private extension ObservationsViewModel { + func observeOffset() { + offsetObject.$diffOffset.sink { [weak self] value in + guard let self = self else { + return + } + self.containerDelegate?.offsetUpdated(diffOffset: value) + } + .store(in: &cancellables) + } + + func generateCtaObject() -> CTAContainerView.CTAObject { + let description: LocalizableString.StationDetails = .observationsFollowCtaText + let buttonTitle: LocalizableString = .favorite + let buttonIcon: FontIcon = .heart + let buttonAction: VoidCallback = { [weak self] in self?.containerDelegate?.shouldAskToFollow() } + + let obj = CTAContainerView.CTAObject(description: description.localized, + buttonTitle: buttonTitle.localized, + buttonFontIcon: buttonIcon, + buttonAction: buttonAction) + + return obj + } +} + +// MARK: - StationDetailsViewModelChild + +extension ObservationsViewModel: StationDetailsViewModelChild { + func refreshWithDevice(_ device: DeviceDetails?, followState: UserDeviceFollowState?, error: NetworkErrorResponse?) async { + DispatchQueue.main.async { + self.device = device + self.followState = followState + self.ctaObject = followState == nil ? self.generateCtaObject() : nil + + if let error { + let info = error.uiInfo + self.failObj = info.defaultFailObject(type: .observations, retryAction: self.handleRetryButtonTap) + self.viewState = .fail + } else { + self.viewState = .content + } + } + } + + func showLoading() { + viewState = .loading + } +} + +// MARK: - Mock + +extension ObservationsViewModel { + private convenience init() { + var device = NetworkDevicesResponse() + device.address = "WetherXM HQ" + device.name = "A nice station" + device.attributes.isActive = true + device.attributes.lastActiveAt = Date().ISO8601Format() + self.init(device: nil) + } + + static var mockInstance: ObservationsViewModel { + ObservationsViewModel() + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationLostRewardsView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationLostRewardsView.swift new file mode 100644 index 00000000..5d38b49c --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationLostRewardsView.swift @@ -0,0 +1,56 @@ +// +// StationLostRewardsView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 8/11/23. +// + +import SwiftUI + +struct StationLostRewardsView: View { + + let lostRewards: StationRewardsLostAmountData + var rounded: Bool = true + + var body: some View { + HStack(spacing: CGFloat(.smallSpacing)) { + Text(lostRewards.fontIcon.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: lostRewards.iconColor)) + + Text(title) + .font(.system(size: CGFloat(.smallFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + } + .modify { view in + if rounded { + view + .padding(CGFloat(.smallSidePadding)) + .background(Capsule().foregroundColor(Color(colorEnum: lostRewards.tintColor))) + } else { + view + .WXMCardStyle(backgroundColor: Color(colorEnum: lostRewards.tintColor), + insideHorizontalPadding: CGFloat(.smallSidePadding), + insideVerticalPadding: CGFloat(.smallSidePadding), + cornerRadius: CGFloat(.buttonCornerRadius)) + } + } + } +} + +private extension StationLostRewardsView { + var title: String { + let lostWxm = lostRewards.percentage > 0 + let title = lostWxm ? LocalizableString.StationDetails.lostRewards(lostRewards.percentage) : LocalizableString.StationDetails.gotRewards(100) + + return title.localized + } +} + +#Preview { + StationLostRewardsView(lostRewards: StationRewardsLostAmountData(value: 190, percentage: 0)) +} + +#Preview { + StationLostRewardsView(lostRewards: StationRewardsLostAmountData(value: 190, percentage: 50), rounded: false) +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardTypes.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardTypes.swift new file mode 100644 index 00000000..7ea828e8 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardTypes.swift @@ -0,0 +1,156 @@ +// +// StationRewardTypes.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 25/10/23. +// + +import Foundation +import Charts +import UIKit +import Toolkit +import DomainLayer + +struct StationRewardsCardOverview: Hashable { + let title: String + let date: Date? + let fromDate: Date? + let toDate: Date? + let actualReward: Double + let lostPercentage: Int + let lostAmount: Double + let rewardScore: Int? + let maxRewards: Double? + let annnotationsList: [DeviceAnnotation] + let timelineEntries: [Int]? + let timelineAxis: [String]? + let timelineCaption: String? + let errorButtonTitle: String + + var lostAmountData: StationRewardsLostAmountData { + let data = StationRewardsLostAmountData(value: lostAmount, percentage: lostPercentage) + return data + } + + static func mock(title: String) -> Self { + StationRewardsCardOverview(title: title, + date: nil, + fromDate: .now, + toDate: .now.advancedByDays(days: 5), + actualReward: 3784.32, + lostPercentage: 0, + lostAmount: 0.0, + rewardScore: nil, + maxRewards: nil, + annnotationsList: DeviceAnnotation.AnnotationType.allCases.map { .init(error: $0, ratio: 1, affects: [.init(ratio: 1.0, parameter: .temperature)]) }, + timelineEntries: [], + timelineAxis: [], + timelineCaption: "Timeline for yesterday", + errorButtonTitle: LocalizableString.StationDetails.rewardsErrorButtonTitle.localized) + } +} + +struct StationRewardsLostAmountData { + let value: Double + let percentage: Int + var fontIcon: FontIcon! + var iconColor: ColorEnum! + var tintColor: ColorEnum! + var problemsViewBackground: ColorEnum! + var problemsViewBorder: ColorEnum! + var cardWarningType: CardWarningType { + switch percentage { + case let val where val > lostRewardsThreshold: + return .error + default: + return .warning + } + } + + /// The threshold to change error color from `warning` to `error` + /// If <=30 warning (user claimed more than 70% of available tokens) + /// If >30 error (user claimed less than 70% of available tokens) + private let lostRewardsThreshold = 30 + + init(value: Double, percentage: Int) { + self.value = value + self.percentage = percentage + self.fontIcon = getFontIcon(percentage: percentage) + self.iconColor = getIconColor(percentage: percentage) + self.tintColor = getTintColor(percentage: percentage) + self.problemsViewBackground = getProblemsViewColor(percentage: percentage) + self.problemsViewBorder = getProblemsViewBorderColor(percentage: percentage) + } + + private func getFontIcon(percentage: Int) -> FontIcon { + let lostWxm = percentage > 0 + let fontIcon: FontIcon = lostWxm ? .triangleExclamation : .badgeCheck + return fontIcon + } + + private func getIconColor(percentage: Int) -> ColorEnum { + switch percentage { + case let val where val == 0: + return .darkestBlue + case let val where val > lostRewardsThreshold: + return .error + case let val where val <= lostRewardsThreshold: + return .warning + default: + return .success + } + } + + private func getTintColor(percentage: Int) -> ColorEnum { + switch percentage { + case let val where val == 0: + return .successTint + case let val where val > lostRewardsThreshold: + return .errorTint + case let val where val <= lostRewardsThreshold: + return .warningTint + default: + return .successTint + } + } + + private func getProblemsViewBorderColor(percentage: Int) -> ColorEnum { + switch percentage { + case let val where val > lostRewardsThreshold: + return .error + case let val where val <= lostRewardsThreshold: + return .warning + default: + return .success + } + } + + private func getProblemsViewColor(percentage: Int) -> ColorEnum { + switch percentage { + case let val where val > lostRewardsThreshold: + return .errorTint + case let val where val <= lostRewardsThreshold: + return .warningTint + default: + return .successTint + } + } +} + +/// The actions to be called +struct RewardsOverviewButtonActions { + typealias Info = (title: String?, description: String) + let rewardsScoreInfoAction: VoidCallback + let dailyMaxInfoAction: VoidCallback + let timelineInfoAction: VoidCallback + let errorButtonAction: VoidCallback + + static let rewardsScoreInfo: Info = (LocalizableString.RewardDetails.rewardScoreInfoTitle.localized, + LocalizableString.RewardDetails.rewardScoreInfoDescription(DisplayedLinks.rewardMechanism.linkURL).localized) + static let dailyMaxInfo: Info = (LocalizableString.RewardDetails.maxRewardsInfoTitle.localized, + LocalizableString.RewardDetails.maxRewardsInfoDescription(DisplayedLinks.rewardMechanism.linkURL).localized) + static func timelineInfo(timezoneOffset: String?) -> Info { + (LocalizableString.RewardDetails.timelineInfoTitle.localized, + LocalizableString.RewardDetails.timelineInfoDescription(timezoneOffset, DisplayedLinks.rewardMechanism.linkURL).localized) + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsCardView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsCardView.swift new file mode 100644 index 00000000..efad870a --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsCardView.swift @@ -0,0 +1,76 @@ +// +// StationRewardsCardView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 25/10/23. +// + +import SwiftUI +import Toolkit + +struct StationRewardsCardView: View { + @Binding var selectedIndex: Int + let totalRewards: Double + let showErrorButtonAction: Bool + let overviews: [StationRewardsCardOverview] + let buttonActions: RewardsOverviewButtonActions + + var body: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + titleView + .padding(.top, CGFloat(.defaultSidePadding)) + .padding(.horizontal, CGFloat(.defaultSidePadding)) + + contentView + } + .WXMCardStyle(backgroundColor: Color(colorEnum: .layer1), + insideHorizontalPadding: 0.0, + insideVerticalPadding: 0.0) + } +} + +private extension StationRewardsCardView { + @ViewBuilder + var titleView: some View { + VStack(spacing: 0.0) { + HStack { + Text(LocalizableString.StationDetails.rewardsTitle.localized) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .text)) + Spacer() + } + + HStack { + Text("\(totalRewards, specifier: "%.2f") \(StringConstants.wxmCurrency)") + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .darkestBlue)) + Spacer() + } + } + } + + @ViewBuilder + var contentView: some View { + VStack(spacing: CGFloat(.defaultSpacing)) { + CustomSegmentView(options: overviews.map { $0.title }, selectedIndex: $selectedIndex) + StationRewardsOverviewView(overview: overviews[selectedIndex], showErrorAction: showErrorButtonAction, buttonActions: buttonActions) + } + .WXMCardStyle() + } +} + +#Preview { + ZStack { + Color(colorEnum: .bg) + StationRewardsCardView(selectedIndex: .constant(0), + totalRewards: 173023.54, + showErrorButtonAction: true, + overviews: [.mock(title: "Latest"), .mock(title: "7D"), .mock(title: "30D")], + buttonActions: .init(rewardsScoreInfoAction: {}, + dailyMaxInfoAction: {}, + timelineInfoAction: {}, + errorButtonAction: {})) + .wxmShadow() + .padding() + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsErrorView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsErrorView.swift new file mode 100644 index 00000000..c17e72cf --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsErrorView.swift @@ -0,0 +1,54 @@ +// +// StationRewardsErrorView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 2/11/23. +// + +import SwiftUI +import Toolkit + +struct StationRewardsErrorView: View { + let lostAmount: Double + let buttonTitle: String + let showButton: Bool + let buttonTapAction: VoidCallback + + private var description: String { + guard lostAmount > 0.0 else { + return LocalizableString.RewardDetails.zeroLostProblemsDescription.localized + } + + return LocalizableString.StationDetails.rewardsErrorDescription(lostAmount.toWXMTokenPrecisionString).localized + } + + var body: some View { + VStack(spacing: CGFloat(.smallToMediumSpacing)) { + HStack { + Text(LocalizableString.StationDetails.rewardsErrorsTitle.localized) + .font(.system(size: CGFloat(.normalFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .text)) + Spacer() + } + + HStack { + Text(description.attributedMarkdown ?? "") + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + + if showButton { + Button(action: buttonTapAction) { + Text(buttonTitle) + } + .buttonStyle(WXMButtonStyle.transparent) + } + } + } +} + +#Preview { + StationRewardsErrorView(lostAmount: 349.2142, buttonTitle: LocalizableString.StationDetails.rewardsErrorButtonTitle.localized, showButton: true) {} +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsOverviewView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsOverviewView.swift new file mode 100644 index 00000000..c1d3892b --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsOverviewView.swift @@ -0,0 +1,192 @@ +// +// StationRewardsOverviewView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 31/10/23. +// + +import SwiftUI +import Toolkit + +struct StationRewardsOverviewView: View { + + let overview: StationRewardsCardOverview + var showError: Bool = true + var showErrorAction: Bool = true + let buttonActions: RewardsOverviewButtonActions + + var body: some View { + content + } +} + +private extension StationRewardsOverviewView { + @ViewBuilder + var content: some View { + let lostAmountData = overview.lostAmountData + VStack(spacing: CGFloat(.defaultSpacing)) { + VStack(spacing: 0.0) { + dateView + HStack(spacing: CGFloat(.smallSpacing)) { + Text("+ \(overview.actualReward.toWXMTokenPrecisionString) \(StringConstants.wxmCurrency)") + .lineLimit(1) + .font(.system(size: CGFloat(.titleFontSize), weight: .bold)) + .foregroundColor(Color(colorEnum: .darkestBlue)) + + Spacer(minLength: 0.0) + + StationLostRewardsView(lostRewards: lostAmountData) + } + } + + if showError, !overview.annnotationsList.isEmpty { + errorView(for: lostAmountData) + } + + if let rewardScore = overview.rewardScore, let maxRewards = overview.maxRewards { + PercentageGridLayoutView(firstColumnPercentage: 0.5) { + Group { + Button(action: buttonActions.rewardsScoreInfoAction) { + let value = Double(rewardScore)/100.0 + rewardsScoreView(title: LocalizableString.StationDetails.rewardsScore.localized, + value: value.toPrecisionString(minDecimals: 2, precision: 2), + hexColor: getHexagonColor(validationScore: value)) + } + Button(action: buttonActions.dailyMaxInfoAction) { + rewardsScoreView(title: LocalizableString.StationDetails.rewardsMax.localized, + value: maxRewards.toWXMTokenPrecisionString, + hexColor: getHexagonColor(validationScore: maxRewards)) + } + } + } + } + + if let timelineEntries = overview.timelineEntries, !timelineEntries.isEmpty { + timelineView(for: timelineEntries, caption: overview.timelineCaption) + } + } + } + + @ViewBuilder + var dateView: some View { + HStack { + Text(dateString) + .font(.system(size: CGFloat(.normalFontSize))) + .foregroundColor(Color(colorEnum: .darkGrey)) + Spacer() + } + } + + var dateString: String { + if let fromDate = overview.fromDate, let toDate = overview.toDate { + let from = fromDate.getFormattedDate(format: .monthLiteralDay, + relativeFormat: false, + timezone: .UTCTimezone, + showTimeZoneIndication: false).capitalizedSentence + + let to = toDate.getFormattedDate(format: .monthLiteralDay, + relativeFormat: false, + timezone: .UTCTimezone, + showTimeZoneIndication: true).capitalizedSentence + return from + " - " + to + } + + return overview.date?.getFormattedDate(format: .monthLiteralDayTime, + relativeFormat: false, + timezone: .UTCTimezone, + showTimeZoneIndication: true).capitalizedSentence ?? "" + } + + @ViewBuilder + func errorView(for lostAmount: StationRewardsLostAmountData) -> some View { + StationRewardsErrorView(lostAmount: lostAmount.value, + buttonTitle: overview.errorButtonTitle, + showButton: showErrorAction, + buttonTapAction: buttonActions.errorButtonAction) + .WXMCardStyle(backgroundColor: Color(colorEnum: lostAmount.problemsViewBackground), + insideHorizontalPadding: CGFloat(.defaultSidePadding), + insideVerticalPadding: CGFloat(.smallToMediumSidePadding), + cornerRadius: CGFloat(.buttonCornerRadius)) + } + + @ViewBuilder + func timelineView(for entries: [Int], caption: String?) -> some View { + VStack(spacing: CGFloat(.smallToMediumSpacing)) { + VStack(spacing: CGFloat(.minimumSpacing)) { + StationRewardsTimelineView(values: entries) + .frame(height: 70.0) + + if let axis = overview.timelineAxis { + HStack { + let count = axis.count + ForEach(0.. some View { + HStack(alignment: .top, spacing: CGFloat(.minimumSpacing)) { + Image(asset: .hexagonBigger) + .renderingMode(.template) + .foregroundColor(Color(colorEnum: hexColor)) + .frame(width: 24.0, height: 24.0) + + VStack(spacing: CGFloat(.minimumSpacing)) { + HStack { + Text(value) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + + HStack(spacing: CGFloat(.minimumSpacing)) { + Text(title) + .font(.system(size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .darkGrey)) + + Text(FontIcon.infoCircle.rawValue) + .font(.fontAwesome(font: .FAProLight, size: CGFloat(.caption))) + .foregroundColor(Color(colorEnum: .text)) + + Spacer() + } + } + } + } +} + +#Preview { + ZStack { + StationRewardsOverviewView(overview: .mock(title: "Latest"), + buttonActions: .init(rewardsScoreInfoAction: {}, + dailyMaxInfoAction: {}, + timelineInfoAction: {}, + errorButtonAction: {})) + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsTimelineView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsTimelineView.swift new file mode 100644 index 00000000..503ba193 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsTimelineView.swift @@ -0,0 +1,37 @@ +// +// RewardsTimelineView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 27/10/23. +// + +import SwiftUI + +struct StationRewardsTimelineView: View { + let values: [Int] + private let maxWidthFactor: CGFloat = 1.0 / 15.0 + + var body: some View { + let indices = values.indices + GeometryReader { proxy in + HStack(spacing: 0.0) { + ForEach(indices, id: \.self) { index in + let val = values[index] + Capsule() + .foregroundColor(Color(colorEnum: getHexagonColor(validationScore: Double(val) / 100.0))) + .frame(maxWidth: proxy.size.width * maxWidthFactor) + + if indices.last != index { + Spacer(minLength: CGFloat(.minimumSpacing)) + } + } + } + } + } +} + +#Preview { + let range = 0..<7 + let values = range.map { _ in Int.random(in: 0...100) } + return StationRewardsTimelineView(values: values) +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsView.swift new file mode 100644 index 00000000..7cd028f8 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsView.swift @@ -0,0 +1,65 @@ +// +// RewardsView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 20/3/23. +// + +import SwiftUI +import Toolkit +struct StationRewardsView: View { + @StateObject var viewModel: StationRewardsViewModel + + var body: some View { + GeometryReader { proxy in + ZStack { + TrackableScrollView(offsetObject: viewModel.offsetObject) { completion in + viewModel.refresh(completion: completion) + } content: { + VStack(spacing: CGFloat(.mediumSpacing)) { + VStack(spacing: CGFloat(.largeSpacing)) { + if let data = viewModel.data { + StationRewardsCardView(selectedIndex: $viewModel.selectedIndex, + totalRewards: viewModel.totalRewards, + showErrorButtonAction: true, + overviews: data, + buttonActions: viewModel.cardButtonActions) + .wxmShadow() + } + + VStack(spacing: CGFloat(.defaultSpacing)) { + Button { + viewModel.handleDetailedRewardsButtonTap() + } label: { + Text(LocalizableString.StationDetails.detailedRewardsButtonTitle.localized) + } + .buttonStyle(WXMButtonStyle.solid) + + InfoView(text: LocalizableString.StationDetails.rewardsInfoText.localized.attributedMarkdown ?? "") + } + } + .animation(.easeIn, value: viewModel.selectedIndex) + } + .padding() + .padding(.bottom, proxy.size.height / 2.0) // Quick fix for better experience while expanding/collapsing the containers's header + #warning("TODO: Find a better solution") + } + .spinningLoader(show: Binding(get: { viewModel.viewState == .loading }, set: { _ in }), hideContent: true) + .fail(show: Binding(get: { viewModel.viewState == .fail }, set: { _ in }), obj: viewModel.failObj) + } + } + .bottomSheet(show: $viewModel.showInfo, fitContent: true) { + bottomInfoView(info: viewModel.info) + } + .onAppear { + Logger.shared.trackScreen(.rewards) + } + } +} + +struct RewardsView_Previews: PreviewProvider { + static var previews: some View { + StationRewardsView(viewModel: StationRewardsViewModel(deviceId: "", useCase: nil)) + .background(Color.red) + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsViewModel.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsViewModel.swift new file mode 100644 index 00000000..2c893835 --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/Rewards/StationRewardsViewModel.swift @@ -0,0 +1,207 @@ +// +// StationRewardsViewModel.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 20/3/23. +// + +import Foundation +import Combine +import DomainLayer +import Toolkit + +class StationRewardsViewModel: ObservableObject { + let offsetObject: TrackableScrollOffsetObject = TrackableScrollOffsetObject() + @Published private(set) var viewState: ViewState = .loading + + @Published private(set) var totalRewards: Double = 0.0 + @Published var selectedIndex: Int = 0 { + didSet { + trackSelectContentChange() + } + } + @Published var data: [StationRewardsCardOverview]? + lazy var cardButtonActions: RewardsOverviewButtonActions = { + getCardButtonActions() + }() + + @Published var showInfo: Bool = false + private(set) var info: RewardsOverviewButtonActions.Info? + + private(set) var device: DeviceDetails? + private(set) var followState: UserDeviceFollowState? + private(set) var failObj: FailSuccessStateObject? + + private var response: NetworkDeviceTokensResponse? + private var useCase: RewardsUseCase? + private weak var containerDelegate: StationDetailsViewModelDelegate? + private let deviceId: String + private var cancellables: Set = [] + + init(deviceId: String, containerDelegate: StationDetailsViewModelDelegate? = nil, useCase: RewardsUseCase?) { + self.deviceId = deviceId + self.containerDelegate = containerDelegate + self.useCase = useCase + observeOffset() + } + + func refresh(completion: @escaping VoidCallback) { + Task { @MainActor in + await containerDelegate?.shouldRefresh() + completion() + } + } + + func handleDetailedRewardsButtonTap() { + navigateToTransactions() + } + + func handleRetryButtonTap() { + viewState = .loading + refresh { } + } + + func trackSelectContentChange() { + let state = data?[safe: selectedIndex]?.title ?? "" + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .rewardsCard, + .itemId: .custom(deviceId), + .state: .custom(state)]) + } +} + +private extension StationRewardsViewModel { + func observeOffset() { + offsetObject.$diffOffset.sink { [weak self] value in + guard let self = self else { + return + } + self.containerDelegate?.offsetUpdated(diffOffset: value) + } + .store(in: &cancellables) + } +} + +extension StationRewardsViewModel: StationDetailsViewModelChild { + func refreshWithDevice(_ device: DeviceDetails?, followState: UserDeviceFollowState?, error: NetworkErrorResponse?) async { + self.device = device + self.followState = followState + let tuple = await getRewards() + if let error = tuple.error { + DispatchQueue.main.async { + self.showErrorView(error: error) + } + return + } + + DispatchQueue.main.async { + self.response = tuple.response + self.updateOverviews(with: tuple.response!) + self.viewState = .content + } + } + + func showLoading() { + viewState = .loading + } +} + +private extension StationRewardsViewModel { + + func navigateToTransactions() { + guard let device else { + return + } + Router.shared.navigateTo(.transactions(ViewModelsFactory.getTransactionDetailsViewModel(device: device, followState: followState))) + } + + func showErrorView(error: NetworkErrorResponse) { + let obj = error.uiInfo.defaultFailObject(type: .stationRewards) { [weak self] in + self?.handleRetryButtonTap() + } + + DispatchQueue.main.async { + self.failObj = obj + self.viewState = .fail + } + } + + func getCardButtonActions() -> RewardsOverviewButtonActions { + let actions = RewardsOverviewButtonActions { [weak self] in + self?.info = RewardsOverviewButtonActions.rewardsScoreInfo + self?.showInfo = true + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .learnMore, + .itemId: .rewardsScore]) + } dailyMaxInfoAction: { [weak self] in + self?.info = RewardsOverviewButtonActions.dailyMaxInfo + self?.showInfo = true + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .learnMore, + .itemId: .maxRewards]) + } timelineInfoAction: { [weak self] in + var offsetString: String? + if let identifier = self?.device?.timezone, + let timezone = TimeZone(identifier: identifier), + !timezone.isUTC { + offsetString = timezone.hoursOffsetString + } + self?.info = RewardsOverviewButtonActions.timelineInfo(timezoneOffset: offsetString) + self?.showInfo = true + Logger.shared.trackEvent(.selectContent, parameters: [.contentType: .learnMore, + .itemId: .timeline]) + } errorButtonAction: { [weak self] in + self?.trackErrorButtonTap() + // If the latest is selected, navigate to reward details + if self?.selectedIndex == 0, + let cardOverView = self?.data?[safe: 0], + let device = self?.device { + let viewModel = ViewModelsFactory.getRewardDetailsViewModel(device: device, + followState: self?.followState, + overview: cardOverView) + Router.shared.navigateTo(.rewardDetails(viewModel)) + return + } + self?.navigateToTransactions() + } + + return actions + } + + func updateOverviews(with rewards: NetworkDeviceTokensResponse) { + let errorButtonTitle: String = followState?.relation == .owned ? LocalizableString.StationDetails.ownedRewardsErrorButtonTitle.localized : LocalizableString.StationDetails.rewardsErrorButtonTitle.localized + let latestRewards = rewards.latest + let latest = latestRewards?.toRewardsCardOverview(title: LocalizableString.StationDetails.rewardsLatestTab.localized, errorButtonTitle: errorButtonTitle) + + let weekly = rewards.weekly + let week = weekly?.toRewardsCardOverview(title: LocalizableString.StationDetails.rewardsSevenDaysTab.localized, errorButtonTitle: errorButtonTitle) + + let monthly = rewards.monthly + let month = monthly?.toRewardsCardOverview(title: LocalizableString.StationDetails.rewardsThirtyDaysTab.localized, errorButtonTitle: errorButtonTitle) + + self.data = [latest, week, month].compactMap { $0 } + self.totalRewards = rewards.totalRewards ?? 0.0 + } + + func getRewards() async -> (response: NetworkDeviceTokensResponse?, error: NetworkErrorResponse?) { + do { + guard let result = try await useCase?.getDeviceRewards(deviceId: deviceId) else { + return (nil, nil) + } + + switch result { + case .success(let rewards): + return (rewards, nil) + case .failure(let error): + return (nil, error) + } + } catch { + print(error) + return (nil, nil) + } + } + + func trackErrorButtonTap() { + let itemId = data?[safe: selectedIndex]?.title ?? "" + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .identifyProblems, + .contentType: .deviceRewards, + .itemId: .custom(itemId)]) + } +} diff --git a/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/StationDetailsContainerView.swift b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/StationDetailsContainerView.swift new file mode 100644 index 00000000..146900be --- /dev/null +++ b/PresentationLayer/UI Components/Screens/WeatherStations/Station Details/StationDetailsContainerView.swift @@ -0,0 +1,204 @@ +// +// StationDetailsContainerView.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 2/3/23. +// + +import SwiftUI +import DomainLayer +import Toolkit + +struct StationDetailsContainerView: View { + @StateObject var viewModel: StationDetailsViewModel + @State private var showSettingsPopOver: Bool = false + + var body: some View { + NavigationContainerView { + navigationBarRightView + } content: { + StationDetailsView(viewModel: viewModel) + } + } + + @ViewBuilder + var navigationBarRightView: some View { + HStack(spacing: CGFloat(.smallSidePadding)) { + Button { [weak viewModel] in + viewModel?.handleShareButtonTap() + } label: { + Text(FontIcon.share.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: .primary)) + .frame(width: 30.0, height: 30.0) + } + + if viewModel.followState != nil { + Button { + Logger.shared.trackEvent(.userAction, parameters: [.actionName: .deviceDetailsPopUp]) + showSettingsPopOver = true + } label: { + Text(FontIcon.threeDots.rawValue) + .font(.fontAwesome(font: .FAProSolid, size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: .primary)) + .frame(width: 30.0, height: 30.0) + } + .wxmPopOver(show: $showSettingsPopOver) { + VStack { + Button { [weak viewModel] in + showSettingsPopOver = false + viewModel?.settingsButtonTapped() + } label: { + Text(LocalizableString.settings.localized) + .font(.system(size: CGFloat(.mediumFontSize))) + .foregroundColor(Color(colorEnum: .text)) + } + } + .padding() + .background(Color(colorEnum: .top).scaleEffect(2.0).ignoresSafeArea()) + } + } + } + } + +} + +private struct StationDetailsView: View { + @EnvironmentObject var navigationObject: NavigationObject + @StateObject var viewModel: StationDetailsViewModel + @State private var selectedIndex = 0 + @State private var titleViewSize: CGSize = .zero + @State private var titleViewAddressSize: CGSize = .zero + @State private var isHeaderHidden: Bool = false + private let mainVM: MainScreenViewModel = .shared + + var body: some View { + content + } + + @ViewBuilder + var content: some View { + ZStack { + Color(colorEnum: .bg) + .ignoresSafeArea() + + ZStack(alignment: .top) { + titleView + .padding(.horizontal, CGFloat(.mediumSidePadding)) + .background { + Color(colorEnum: .top) + } + .cornerRadius(CGFloat(.cardCornerRadius), + corners: [.bottomLeft, .bottomRight]) + .background { + // The following "hack" is to drop shadow only at the bottom of the view + VStack { + Spacer() + Color(colorEnum: .top) + .frame(height: 30.0) + .cornerRadius(CGFloat(.cardCornerRadius), + corners: [.bottomLeft, .bottomRight]) + .wxmShadow() + } + }.zIndex(1) + .offset(x: 0.0, y: isHeaderHidden ? -titleViewAddressSize.height : 0.0) + .animation(.easeIn(duration: 0.15), value: isHeaderHidden) + .sizeObserver(size: $titleViewSize) + + TabViewWrapper(selection: $selectedIndex) { [unowned viewModel] in + ForEach(0..