diff --git a/PresentationLayer/Extensions/DomainExtensions/PublicHex+.swift b/PresentationLayer/Extensions/DomainExtensions/PublicHex+.swift new file mode 100644 index 000000000..fc6aac83b --- /dev/null +++ b/PresentationLayer/Extensions/DomainExtensions/PublicHex+.swift @@ -0,0 +1,18 @@ +// +// PublicHexes+.swift +// wxm-ios +// +// Created by Pantelis Giazitsis on 26/9/25. +// + +import DomainLayer + +extension PublicHex { + var cellCapacityReached: Bool { + guard let deviceCount, let capacity else { + return false + } + + return deviceCount >= capacity + } +} diff --git a/PresentationLayer/Navigation/DeepLinkHandler.swift b/PresentationLayer/Navigation/DeepLinkHandler.swift index 42bee48d2..0b46e6e1c 100644 --- a/PresentationLayer/Navigation/DeepLinkHandler.swift +++ b/PresentationLayer/Navigation/DeepLinkHandler.swift @@ -239,8 +239,9 @@ private extension DeepLinkHandler { let lon = cell.center.lon let lat = cell.center.lat let cellCenter = CLLocationCoordinate2D(latitude: lat, longitude: lon) + let cellCapacity = cell.capacity ?? 0 - let route = Route.explorerList(ViewModelsFactory.getExplorerStationsListViewModel(cellIndex: index, cellCenter: cellCenter)) + let route = Route.explorerList(ViewModelsFactory.getExplorerStationsListViewModel(cellIndex: index, cellCenter: cellCenter, cellCapacity: cellCapacity)) router.navigateTo(route) } } diff --git a/PresentationLayer/UIComponents/BaseComponents/SelectLocationMap/SelectLocationMapView.swift b/PresentationLayer/UIComponents/BaseComponents/SelectLocationMap/SelectLocationMapView.swift index 74ae19ed3..26adefcc3 100644 --- a/PresentationLayer/UIComponents/BaseComponents/SelectLocationMap/SelectLocationMapView.swift +++ b/PresentationLayer/UIComponents/BaseComponents/SelectLocationMap/SelectLocationMapView.swift @@ -19,7 +19,12 @@ struct SelectLocationMapView: View { annotationTitle: Binding(get: { viewModel.selectedDeviceLocation?.name }, set: { _ in }), geometryProxyForFrameOfMapView: proxy.frame(in: .local), - mapControls: viewModel.mapControls) + polygonPoints: viewModel.explorerData?.cellCapacityPoints, + polylinePoints: viewModel.explorerData?.cellBorderPoints, + textPoints: viewModel.explorerData?.cellCapacityTextPoints, + mapControls: viewModel.mapControls) { annotations in + viewModel.handlePointedAnnotationsChange(annotations: annotations) + } searchArea } @@ -126,5 +131,6 @@ private extension SelectLocationMapView { #Preview { let useCase = SwinjectHelper.shared.getContainerForSwinject().resolve(DeviceLocationUseCaseApi.self)! - return SelectLocationMapView(viewModel: .init(useCase: useCase)) + let explorerUseCase = SwinjectHelper.shared.getContainerForSwinject().resolve(ExplorerUseCaseApi.self)! + return SelectLocationMapView(viewModel: .init(useCase: useCase, explorerUseCase: explorerUseCase)) } diff --git a/PresentationLayer/UIComponents/BaseComponents/SelectLocationMap/SelectLocationMapViewModel.swift b/PresentationLayer/UIComponents/BaseComponents/SelectLocationMap/SelectLocationMapViewModel.swift index c0df1361a..13fa8cb40 100644 --- a/PresentationLayer/UIComponents/BaseComponents/SelectLocationMap/SelectLocationMapViewModel.swift +++ b/PresentationLayer/UIComponents/BaseComponents/SelectLocationMap/SelectLocationMapViewModel.swift @@ -10,6 +10,7 @@ import CoreLocation import Combine import DomainLayer import Toolkit +import MapboxMaps @MainActor protocol SelectLocationMapViewModelDelegate: AnyObject { @@ -43,16 +44,26 @@ class SelectLocationMapViewModel: ObservableObject { } } @Published private(set) var searchResults: [DeviceLocationSearchResult] = [] + @Published private(set) var explorerData: ExplorerData? var mapControls: MapControls = .init() private var cancellableSet: Set = .init() private var latestTask: Cancellable? + private var latestPointedAnnotations: [PolygonAnnotation]? let useCase: DeviceLocationUseCaseApi + let explorerUseCase: ExplorerUseCaseApi weak var delegate: SelectLocationMapViewModelDelegate? + let linkNavigation: LinkNavigation - init(useCase: DeviceLocationUseCaseApi, initialCoordinate: CLLocationCoordinate2D? = nil, delegate: SelectLocationMapViewModelDelegate? = nil) { + init(useCase: DeviceLocationUseCaseApi, + explorerUseCase: ExplorerUseCaseApi, + initialCoordinate: CLLocationCoordinate2D? = nil, + delegate: SelectLocationMapViewModelDelegate? = nil, + linkNavigation: LinkNavigation = LinkNavigationHelper()) { self.useCase = useCase + self.explorerUseCase = explorerUseCase self.delegate = delegate self.selectedCoordinate = initialCoordinate ?? useCase.getSuggestedDeviceLocation() ?? .init() + self.linkNavigation = linkNavigation $selectedCoordinate .debounce(for: 1.0, scheduler: DispatchQueue.main) .sink { [weak self] _ in @@ -70,6 +81,10 @@ class SelectLocationMapViewModel: ObservableObject { useCase.searchResults.sink { [weak self] results in self?.searchResults = results }.store(in: &cancellableSet) + + Task { @MainActor in + await getExplorerData() + } } func handleSearchResultTap(result: DeviceLocationSearchResult) { @@ -105,6 +120,28 @@ class SelectLocationMapViewModel: ObservableObject { } } } + + func handlePointedAnnotationsChange(annotations: [PolygonAnnotation]) { + latestPointedAnnotations = annotations + let capacityReached = isPointedCellCapacityReached() + + if capacityReached { + Toast.shared.show(text: LocalizableString.ClaimDevice.cellCapacityReachedMessage.localized.attributedMarkdown ?? "", + type: .info, + retryButtonTitle: LocalizableString.readMore.localized) { [weak self] in + self?.linkNavigation.openUrl(DisplayedLinks.cellCapacity.linkURL) + } + } + } + + func isPointedCellCapacityReached() -> Bool { + guard let annotation = latestPointedAnnotations?.first, + let count = annotation.userInfo?[ExplorerKeys.deviceCount.rawValue] as? Int, + let capacity = annotation.userInfo?[ExplorerKeys.cellCapacity.rawValue] as? Int else { + return false + } + return count >= capacity + } } private extension SelectLocationMapViewModel { @@ -116,4 +153,18 @@ private extension SelectLocationMapViewModel { self?.selectedDeviceLocation = location } } + + func getExplorerData() async { + do { + let result = try await explorerUseCase.getPublicHexes() + switch result { + case .success(let hexes): + self.explorerData = ExplorerFactory(publicHexes: hexes).generateExplorerData() + case .failure(let error): + print(error) + } + } catch { + print(error) + } + } } diff --git a/PresentationLayer/UIComponents/MapBox/MapBoxClaimDevice.swift b/PresentationLayer/UIComponents/MapBox/MapBoxClaimDevice.swift index 6856bbb8b..49d2a1452 100644 --- a/PresentationLayer/UIComponents/MapBox/MapBoxClaimDevice.swift +++ b/PresentationLayer/UIComponents/MapBox/MapBoxClaimDevice.swift @@ -17,6 +17,10 @@ struct MapBoxClaimDeviceView: View { @Binding var location: CLLocationCoordinate2D @Binding var annotationTitle: String? let geometryProxyForFrameOfMapView: CGRect + let polygonPoints: [PolygonAnnotation]? + let polylinePoints: [PolylineAnnotation]? + let textPoints: [PointAnnotation]? + let annotationPointCallback: GenericMainActorCallback<[PolygonAnnotation]> private let markerSize: CGSize = CGSize(width: 44.0, height: 44.0) @State private var locationPoint: CGPoint = .zero @@ -26,11 +30,19 @@ struct MapBoxClaimDeviceView: View { init(location: Binding, annotationTitle: Binding, geometryProxyForFrameOfMapView: CGRect, - mapControls: MapControls) { + polygonPoints: [PolygonAnnotation]?, + polylinePoints: [PolylineAnnotation]?, + textPoints: [PointAnnotation]?, + mapControls: MapControls, + annotationPointCallback: @escaping GenericMainActorCallback<[PolygonAnnotation]>) { _location = location _annotationTitle = annotationTitle self.geometryProxyForFrameOfMapView = geometryProxyForFrameOfMapView + self.polygonPoints = polygonPoints + self.polylinePoints = polylinePoints + self.textPoints = textPoints _controls = StateObject(wrappedValue: mapControls) + self.annotationPointCallback = annotationPointCallback } var body: some View { @@ -38,7 +50,11 @@ struct MapBoxClaimDeviceView: View { MapBoxClaimDevice(location: $location, locationPoint: $locationPoint, geometryProxyForFrameOfMapView: geometryProxyForFrameOfMapView, - controls: controls) + polygonPoints: polygonPoints, + polylinePoints: polylinePoints, + textPoints: textPoints, + controls: controls, + annotationPointCallback: annotationPointCallback) markerAnnotation .offset(x: 0.0, y: markerAnnotationOffset) @@ -90,9 +106,13 @@ private struct MapBoxClaimDevice: UIViewControllerRepresentable { @Binding var location: CLLocationCoordinate2D @Binding var locationPoint: CGPoint let geometryProxyForFrameOfMapView: CGRect + let polygonPoints: [PolygonAnnotation]? + let polylinePoints: [PolylineAnnotation]? + let textPoints: [PointAnnotation]? @StateObject var controls: MapControls + let annotationPointCallback: GenericMainActorCallback<[PolygonAnnotation]> - func makeUIViewController(context _: Context) -> MapViewLocationController { + func makeUIViewController(context: Context) -> MapViewLocationController { let controller = MapViewLocationController(frame: geometryProxyForFrameOfMapView, location: $location, locationPoint: $locationPoint) @@ -109,11 +129,33 @@ private struct MapBoxClaimDevice: UIViewControllerRepresentable { controller?.setCenter(coordinate) } + controller.delegate = context.coordinator + return controller } + func makeCoordinator() -> Coordinator { + Coordinator(annotationPointCallback: annotationPointCallback) + } + func updateUIViewController(_ controller: MapViewLocationController, context _: Context) { + controller.polygonManager?.annotations = polygonPoints ?? [] + controller.pointManager?.annotations = textPoints ?? [] + controller.polylineManager?.annotations = polylinePoints ?? [] } + + class Coordinator: MapViewLocationControllerDelegate { + let annotationPointCallback: GenericMainActorCallback<[PolygonAnnotation]> + + init(annotationPointCallback: @escaping GenericMainActorCallback<[PolygonAnnotation]>) { + self.annotationPointCallback = annotationPointCallback + } + + @MainActor + func didPointAnnotations(_ annotations: [PolygonAnnotation]) { + annotationPointCallback(annotations) + } + } } class MapControls: ObservableObject { @@ -122,6 +164,11 @@ class MapControls: ObservableObject { var setMapCenter: GenericCallback? } +@MainActor +fileprivate protocol MapViewLocationControllerDelegate: AnyObject { + func didPointAnnotations(_ annotations: [PolygonAnnotation]) +} + class MapViewLocationController: UIViewController { private static let ZOOM_LEVEL: CGFloat = 15 @@ -129,9 +176,15 @@ class MapViewLocationController: UIViewController { @Binding var location: CLLocationCoordinate2D @Binding var locationPoint: CGPoint internal var mapView: MapView! + internal weak var polygonManager: PolygonAnnotationManager? + internal weak var pointManager: PointAnnotationManager? + internal weak var polylineManager: PolylineAnnotationManager? + fileprivate weak var delegate: MapViewLocationControllerDelegate? private var cancelablesSet = Set() - init(frame: CGRect, location: Binding, locationPoint: Binding) { + init(frame: CGRect, + location: Binding, + locationPoint: Binding) { self.frame = frame _location = location _locationPoint = locationPoint @@ -157,6 +210,7 @@ class MapViewLocationController: UIViewController { mapView.ornaments.options.scaleBar.visibility = .hidden mapView.gestures.options.rotateEnabled = false mapView.gestures.options.pitchEnabled = false + mapView.mapboxMap.setCamera(to: CameraOptions(zoom: 2)) mapView.translatesAutoresizingMaskIntoConstraints = false @@ -173,6 +227,7 @@ class MapViewLocationController: UIViewController { } self.locationPoint = self.mapView.mapboxMap.point(for: self.location) + self.updateLocationPointedAnnotations(self.locationPoint) }.store(in: &cancelablesSet) self.mapView.mapboxMap.onMapIdle.observe { [weak self] _ in @@ -182,10 +237,13 @@ class MapViewLocationController: UIViewController { if let self = self { self.location = pointAnnotation.point.coordinates self.locationPoint = mapView.mapboxMap.point(for: self.location) + self.updateLocationPointedAnnotations(self.locationPoint) } } }.store(in: &cancelablesSet) + setPolygonManagers() + setCenter(location) } @@ -210,11 +268,68 @@ class MapViewLocationController: UIViewController { internal func cameraSetup() -> CameraOptions { return CameraOptions(center: CLLocationCoordinate2D()) } + + func updateLocationPointedAnnotations(_ point: CGPoint) { + guard let polygonManager else { + return + } + let layerIds = [polygonManager.layerId] + let annotations = polygonManager.annotations + let options = RenderedQueryOptions(layerIds: layerIds, filter: nil) + mapView.mapboxMap.queryRenderedFeatures(with: point, options: options) { [weak self] result in + switch result { + case .success(let features): + let tappedAnnotations = annotations.filter { annotation in + features.contains(where: { feature in + guard let featureid = feature.queriedFeature.feature.identifier else { + return false + } + switch featureid { + case .string(let str): + return annotation.id == str + case .number(_): + return false + } + }) + } + + self?.delegate?.didPointAnnotations(tappedAnnotations) + case .failure(let error): + print(error) + } + } + } } + extension MapViewLocationController { func locationUpdate(newLocation: Location) { mapView.mapboxMap.setCamera(to: CameraOptions(center: newLocation.coordinate, zoom: 13)) location = newLocation.coordinate } + + func setPolygonManagers() { + self.polygonManager = mapView.annotations.makePolygonAnnotationManager(id: MapBoxConstants.cellCapacityPolygonManagerId) + self.polylineManager = mapView.annotations.makePolylineAnnotationManager(id: MapBoxConstants.bordersManagerId) + + let pointAnnotationManager = mapView.annotations.makePointAnnotationManager(id: MapBoxConstants.pointManagerId) + pointManager = pointAnnotationManager + + try? mapView.mapboxMap.updateLayer(withId: MapBoxConstants.pointManagerId, type: SymbolLayer.self) { layer in + layer.minZoom = 10 + + let stops: [Double: Double] = [ + 10: CGFloat(.mediumFontSize), + 12: CGFloat(.XLTitleFontSize), + 16: CGFloat(.maxFontSize) + ] + + layer.textSize = .expression(Exp(.interpolate) { + Exp(.exponential) { 1.75 } + Exp(.zoom) + stops + }) + layer.textColor = .constant(StyleColor(UIColor(colorEnum: .textWhite))) + } + } } diff --git a/PresentationLayer/UIComponents/MapBox/MapBoxMapView.swift b/PresentationLayer/UIComponents/MapBox/MapBoxMapView.swift index 9cda8e37b..de3013c36 100644 --- a/PresentationLayer/UIComponents/MapBox/MapBoxMapView.swift +++ b/PresentationLayer/UIComponents/MapBox/MapBoxMapView.swift @@ -86,11 +86,12 @@ extension MapBoxMap { func didTapAnnotation(_: MapViewController, _ annotations: [PolygonAnnotation]) { guard let firstValidAnnotation = annotations.first, - let hexIndex = firstValidAnnotation.userInfo?[ExplorerKeys.cellIndex.rawValue] as? String else { + let hexIndex = firstValidAnnotation.userInfo?[ExplorerKeys.cellIndex.rawValue] as? String, + let cellCapacity = firstValidAnnotation.userInfo?[ExplorerKeys.cellCapacity.rawValue] as? Int else { return } - viewModel.routeToDeviceListFor(hexIndex, firstValidAnnotation.polygon.center) + viewModel.routeToDeviceListFor(hexIndex, firstValidAnnotation.polygon.center, capacity: cellCapacity) } func didTapMapArea(_: MapViewController) { diff --git a/PresentationLayer/UIComponents/Screens/ClaimDevice/Location/ClaimDeviceLocationViewModel.swift b/PresentationLayer/UIComponents/Screens/ClaimDevice/Location/ClaimDeviceLocationViewModel.swift index 2a2f9598e..99e4a43a6 100644 --- a/PresentationLayer/UIComponents/Screens/ClaimDevice/Location/ClaimDeviceLocationViewModel.swift +++ b/PresentationLayer/UIComponents/Screens/ClaimDevice/Location/ClaimDeviceLocationViewModel.swift @@ -29,6 +29,20 @@ class ClaimDeviceLocationViewModel: ObservableObject { guard let selectedLocation else { return } + + if locationViewModel.isPointedCellCapacityReached() { + let okAction: AlertHelper.AlertObject.Action = (LocalizableString.ClaimDevice.proceedAnyway.localized, { [weak self] _ in self?.completion(selectedLocation) }) + let obj: AlertHelper.AlertObject = .init(title: LocalizableString.ClaimDevice.cellCapacityReachedAlertTitle.localized, + message: LocalizableString.ClaimDevice.cellCapacityReachedAlertText.localized, + cancelActionTitle: LocalizableString.ClaimDevice.relocate.localized, + cancelAction: { }, + okAction: okAction) + + AlertHelper().showAlert(obj) + + return + } + completion(selectedLocation) } } diff --git a/PresentationLayer/UIComponents/Screens/Explorer/ExplorerData.swift b/PresentationLayer/UIComponents/Screens/Explorer/ExplorerData.swift index 17bc1c1ae..fae730c7f 100644 --- a/PresentationLayer/UIComponents/Screens/Explorer/ExplorerData.swift +++ b/PresentationLayer/UIComponents/Screens/Explorer/ExplorerData.swift @@ -20,6 +20,9 @@ struct ExplorerData: Equatable { let polygonPoints: [PolygonAnnotation] let coloredPolygonPoints: [PolygonAnnotation] let textPoints: [PointAnnotation] + let cellCapacityPoints: [PolygonAnnotation] + let cellBorderPoints: [PolylineAnnotation] + let cellCapacityTextPoints: [PointAnnotation] init() { self.totalDevices = 0 @@ -27,17 +30,27 @@ struct ExplorerData: Equatable { self.polygonPoints = [] self.coloredPolygonPoints = [] self.textPoints = [] + self.cellCapacityPoints = [] + self.cellBorderPoints = [] + self.cellCapacityTextPoints = [] } init(totalDevices: Int, geoJsonSource: GeoJSONSource, polygonPoints: [PolygonAnnotation], coloredPolygonPoints: [PolygonAnnotation], - textPoints: [PointAnnotation]) { + textPoints: [PointAnnotation], + cellCapacityPoints: [PolygonAnnotation], + cellBorderPoints: [PolylineAnnotation], + cellCapacityTextPoints: [PointAnnotation] + ) { self.totalDevices = totalDevices self.geoJsonSource = geoJsonSource self.polygonPoints = polygonPoints self.coloredPolygonPoints = coloredPolygonPoints self.textPoints = textPoints + self.cellCapacityPoints = cellCapacityPoints + self.cellBorderPoints = cellBorderPoints + self.cellCapacityTextPoints = cellCapacityTextPoints } } diff --git a/PresentationLayer/UIComponents/Screens/Explorer/ExplorerFactory.swift b/PresentationLayer/UIComponents/Screens/Explorer/ExplorerFactory.swift index 6a380fa0d..e770029fe 100644 --- a/PresentationLayer/UIComponents/Screens/Explorer/ExplorerFactory.swift +++ b/PresentationLayer/UIComponents/Screens/Explorer/ExplorerFactory.swift @@ -14,6 +14,7 @@ enum ExplorerKeys: String { case deviceCount = "device_count" case cellIndex = "cell_index" case cellCenter = "cell_center" + case cellCapacity = "cell_capacity" } @MainActor @@ -22,7 +23,11 @@ struct ExplorerFactory { private let fillOpacity = 0.5 private let fillColor = StyleColor(UIColor(colorEnum: .explorerPolygon)) private let fillOutlineColor = StyleColor(UIColor.white) - + private let cellCapacityFillOpacity = 0.2 + private let cellCapacityFillColor = StyleColor(UIColor(colorEnum: .wxmPrimary)) + private let cellCapacityFillOutlineColor = StyleColor(UIColor(colorEnum: .wxmPrimary)) + private let cellCapacityReachedFillColor = StyleColor(UIColor(colorEnum: .error)) + private let cellCapacityReachedFillOutlineColor = StyleColor(UIColor(colorEnum: .error)) func generateExplorerData() -> ExplorerData { var geoJsonSource = GeoJSONSource(id: "heatmap") @@ -42,7 +47,10 @@ struct ExplorerFactory { var totalDevices = 0 var polygonPoints: [PolygonAnnotation] = [] var coloredPolygonPoints: [PolygonAnnotation] = [] + var cellCapacityPolygonPoints: [PolygonAnnotation] = [] var textPoints: [PointAnnotation] = [] + var cellCapacityPolylinePoints: [PolylineAnnotation] = [] + var cellCapacityTextPoints: [PointAnnotation] = [] publicHexes.forEach { publicHex in totalDevices += publicHex.deviceCount ?? 0 var ringCords = publicHex.polygon.map { point in @@ -66,7 +74,9 @@ struct ExplorerFactory { polygonAnnotation.userInfo = [ExplorerKeys.cellCenter.rawValue: CLLocationCoordinate2D(latitude: publicHex.center.lat, longitude: publicHex.center.lon), ExplorerKeys.cellIndex.rawValue: publicHex.index, - ExplorerKeys.deviceCount.rawValue: publicHex.deviceCount ?? 0] + ExplorerKeys.deviceCount.rawValue: publicHex.deviceCount ?? 0, + ExplorerKeys.cellCapacity.rawValue: publicHex.capacity as Any] + polygonAnnotation.customData[ExplorerKeys.deviceCount.rawValue] = JSONValue(publicHex.deviceCount ?? 0) polygonPoints.append(polygonAnnotation) @@ -74,12 +84,29 @@ struct ExplorerFactory { coloredAnnotation.fillColor = StyleColor(UIColor(colorEnum: publicHex.averageDataQuality?.rewardScoreColor ?? .darkGrey)) coloredPolygonPoints.append(coloredAnnotation) + var cellCapacityAnnotation = polygonAnnotation + let capacityReached = publicHex.cellCapacityReached + cellCapacityAnnotation.fillColor = capacityReached ? cellCapacityReachedFillColor : cellCapacityFillColor + cellCapacityAnnotation.fillOpacity = cellCapacityFillOpacity + cellCapacityAnnotation.fillOutlineColor = capacityReached ? cellCapacityReachedFillOutlineColor : cellCapacityFillOutlineColor + cellCapacityPolygonPoints.append(cellCapacityAnnotation) + var pointAnnotation = PointAnnotation(id: publicHex.index, point: .init(.init(latitude: publicHex.center.lat, longitude: publicHex.center.lon))) pointAnnotation.customData[ExplorerKeys.deviceCount.rawValue] = JSONValue(publicHex.deviceCount ?? 0) if let deviceCount = publicHex.deviceCount, deviceCount > 0 { pointAnnotation.textField = "\(deviceCount)" } textPoints.append(pointAnnotation) + + if let deviceCount = publicHex.deviceCount, let capacity = publicHex.capacity { + pointAnnotation.textField = "\(deviceCount)/\(capacity)" + cellCapacityTextPoints.append(pointAnnotation) + } + + var polylineAnnotation = PolylineAnnotation(id: publicHex.index, lineCoordinates: ringCords) + polylineAnnotation.lineWidth = 2.0 + polylineAnnotation.lineColor = capacityReached ? cellCapacityReachedFillOutlineColor : cellCapacityFillOutlineColor + cellCapacityPolylinePoints.append(polylineAnnotation) } @@ -87,7 +114,10 @@ struct ExplorerFactory { geoJsonSource: geoJsonSource, polygonPoints: polygonPoints, coloredPolygonPoints: coloredPolygonPoints, - textPoints: textPoints) + textPoints: textPoints, + cellCapacityPoints: cellCapacityPolygonPoints, + cellBorderPoints: cellCapacityPolylinePoints, + cellCapacityTextPoints: cellCapacityTextPoints) return explorerData } diff --git a/PresentationLayer/UIComponents/Screens/Explorer/ExplorerViewModel.swift b/PresentationLayer/UIComponents/Screens/Explorer/ExplorerViewModel.swift index 05aa71a1d..d2af0e82a 100644 --- a/PresentationLayer/UIComponents/Screens/Explorer/ExplorerViewModel.swift +++ b/PresentationLayer/UIComponents/Screens/Explorer/ExplorerViewModel.swift @@ -72,9 +72,9 @@ public final class ExplorerViewModel: ObservableObject { } } - func routeToDeviceListFor(_ hexIndex: String, _ coordinates: CLLocationCoordinate2D?) { + func routeToDeviceListFor(_ hexIndex: String, _ coordinates: CLLocationCoordinate2D?, capacity: Int) { if let coordinates { - let route = Route.explorerList(ViewModelsFactory.getExplorerStationsListViewModel(cellIndex: hexIndex, cellCenter: coordinates)) + let route = Route.explorerList(ViewModelsFactory.getExplorerStationsListViewModel(cellIndex: hexIndex, cellCenter: coordinates, cellCapacity: capacity)) Router.shared.navigateTo(route) } } diff --git a/PresentationLayer/UIComponents/Screens/ExplorerStationsList/ExplorerStationsListView.swift b/PresentationLayer/UIComponents/Screens/ExplorerStationsList/ExplorerStationsListView.swift index 02d81cc54..59d10f3e3 100644 --- a/PresentationLayer/UIComponents/Screens/ExplorerStationsList/ExplorerStationsListView.swift +++ b/PresentationLayer/UIComponents/Screens/ExplorerStationsList/ExplorerStationsListView.swift @@ -208,6 +208,6 @@ private extension ContentView { } #Preview { - let vm = ViewModelsFactory.getExplorerStationsListViewModel(cellIndex: "", cellCenter: CLLocationCoordinate2D()) + let vm = ViewModelsFactory.getExplorerStationsListViewModel(cellIndex: "", cellCenter: CLLocationCoordinate2D(), cellCapacity: 0) return ExplorerStationsListView(viewModel: vm) } diff --git a/PresentationLayer/UIComponents/Screens/ExplorerStationsList/ExplorerStationsListViewModel.swift b/PresentationLayer/UIComponents/Screens/ExplorerStationsList/ExplorerStationsListViewModel.swift index 01e3051fb..39c2ba742 100644 --- a/PresentationLayer/UIComponents/Screens/ExplorerStationsList/ExplorerStationsListViewModel.swift +++ b/PresentationLayer/UIComponents/Screens/ExplorerStationsList/ExplorerStationsListViewModel.swift @@ -43,7 +43,7 @@ class ExplorerStationsListViewModel: ObservableObject { let count = devices.count - pills.append(.stationsCount(LocalizableString.presentStations(count).localized)) + pills.append(.stationsCount(LocalizableString.presentStations(count, cellCapacity).localized)) if let dataQualityPill = getDataQualityPill() { pills.append(dataQualityPill) @@ -56,14 +56,16 @@ class ExplorerStationsListViewModel: ObservableObject { } let cellIndex: String + let cellCapacity: Int private let useCase: ExplorerUseCaseApi? private let cellCenter: CLLocationCoordinate2D? private var cancellableSet: Set = .init() - init(useCase: ExplorerUseCaseApi?, cellIndex: String, cellCenter: CLLocationCoordinate2D?) { + init(useCase: ExplorerUseCaseApi?, cellIndex: String, cellCenter: CLLocationCoordinate2D?, cellCapacity: Int) { self.useCase = useCase self.cellIndex = cellIndex self.cellCenter = cellCenter + self.cellCapacity = cellCapacity fetchDeviceList() useCase?.userDevicesListChangedPublisher.sink { [weak self] _ in diff --git a/PresentationLayer/UIComponents/Screens/SelectStationLocation/SelectStationLocationViewModel.swift b/PresentationLayer/UIComponents/Screens/SelectStationLocation/SelectStationLocationViewModel.swift index fb7f909d9..aaf537235 100644 --- a/PresentationLayer/UIComponents/Screens/SelectStationLocation/SelectStationLocationViewModel.swift +++ b/PresentationLayer/UIComponents/Screens/SelectStationLocation/SelectStationLocationViewModel.swift @@ -50,6 +50,19 @@ class SelectStationLocationViewModel: ObservableObject { return } + if locationViewModel.isPointedCellCapacityReached() { + let okAction: AlertHelper.AlertObject.Action = (LocalizableString.ClaimDevice.proceedAnyway.localized, { [weak self] _ in self?.setLocation() }) + let obj: AlertHelper.AlertObject = .init(title: LocalizableString.ClaimDevice.cellCapacityReachedAlertTitle.localized, + message: LocalizableString.ClaimDevice.cellCapacityReachedAlertText.localized, + cancelActionTitle: LocalizableString.ClaimDevice.relocate.localized, + cancelAction: { }, + okAction: okAction) + + AlertHelper().showAlert(obj) + + return + } + setLocation() } } diff --git a/PresentationLayer/UIComponents/Screens/WeatherStations/StationDetails/Overview/OverviewView.swift b/PresentationLayer/UIComponents/Screens/WeatherStations/StationDetails/Overview/OverviewView.swift index f532b5a1c..f0cfe4fda 100644 --- a/PresentationLayer/UIComponents/Screens/WeatherStations/StationDetails/Overview/OverviewView.swift +++ b/PresentationLayer/UIComponents/Screens/WeatherStations/StationDetails/Overview/OverviewView.swift @@ -119,6 +119,6 @@ private extension OverviewView { struct OverviewView_Previews: PreviewProvider { static var previews: some View { - OverviewView(viewModel: OverviewViewModel.mockInstance) + OverviewView(viewModel: ViewModelsFactory.getStationOverviewViewModel(device: .mockDevice, delegate: nil)) } } diff --git a/PresentationLayer/UIComponents/Screens/WeatherStations/StationDetails/Overview/OverviewViewModel.swift b/PresentationLayer/UIComponents/Screens/WeatherStations/StationDetails/Overview/OverviewViewModel.swift index ef8f35d2b..1d7a72d71 100644 --- a/PresentationLayer/UIComponents/Screens/WeatherStations/StationDetails/Overview/OverviewViewModel.swift +++ b/PresentationLayer/UIComponents/Screens/WeatherStations/StationDetails/Overview/OverviewViewModel.swift @@ -37,10 +37,12 @@ class OverviewViewModel: ObservableObject { private(set) var failObj: FailSuccessStateObject? private var cancellables: Set = [] private let linkNavigation: LinkNavigation + private let explorerUseCase: ExplorerUseCaseApi - init(device: DeviceDetails?, linkNavigation: LinkNavigation = LinkNavigationHelper()) { + init(device: DeviceDetails?, linkNavigation: LinkNavigation = LinkNavigationHelper(), explorerUseCase: ExplorerUseCaseApi) { self.device = device self.linkNavigation = linkNavigation + self.explorerUseCase = explorerUseCase refresh {} observeOffset() } @@ -125,16 +127,24 @@ private extension OverviewViewModel { } func navigateToCell() { - guard let cellIndex = device?.cellIndex, - let center = device?.cellCenter?.toCLLocationCoordinate2D() else { - return - } + LoaderView.shared.show() + Task { @MainActor in + defer { + LoaderView.shared.dismiss() + } - WXMAnalytics.shared.trackEvent(.selectContent, parameters: [.contentName: .stationDetailsChip, - .contentType: .region, - .itemId: .stationRegion]) + guard let cellIndex = device?.cellIndex, + let center = device?.cellCenter?.toCLLocationCoordinate2D(), + let capacity = try? await explorerUseCase.getCell(cellIndex: cellIndex).get()?.capacity else { + return + } + + WXMAnalytics.shared.trackEvent(.selectContent, parameters: [.contentName: .stationDetailsChip, + .contentType: .region, + .itemId: .stationRegion]) - Router.shared.navigateTo(.explorerList(ViewModelsFactory.getExplorerStationsListViewModel(cellIndex: cellIndex, cellCenter: center))) + Router.shared.navigateTo(.explorerList(ViewModelsFactory.getExplorerStationsListViewModel(cellIndex: cellIndex, cellCenter: center, cellCapacity: capacity))) + } } } @@ -160,20 +170,3 @@ extension OverviewViewModel: StationDetailsViewModelChild { viewState = .loading } } - -// MARK: - Mock - -extension OverviewViewModel { - 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: OverviewViewModel { - OverviewViewModel() - } -} diff --git a/PresentationLayer/UIComponents/ViewModelsFactory.swift b/PresentationLayer/UIComponents/ViewModelsFactory.swift index d13b6574c..d14ff0c66 100644 --- a/PresentationLayer/UIComponents/ViewModelsFactory.swift +++ b/PresentationLayer/UIComponents/ViewModelsFactory.swift @@ -24,8 +24,9 @@ enum ViewModelsFactory { StationDetailsViewModel(deviceId: deviceId, cellIndex: cellIndex, cellCenter: cellCenter, swinjectHelper: SwinjectHelper.shared) } - static func getStationOverviewViewModel(device: DeviceDetails?, delegate: StationDetailsViewModelDelegate) -> OverviewViewModel { - let vm = OverviewViewModel(device: device) + static func getStationOverviewViewModel(device: DeviceDetails?, delegate: StationDetailsViewModelDelegate?) -> OverviewViewModel { + let useCase = SwinjectHelper.shared.getContainerForSwinject().resolve(ExplorerUseCaseApi.self)! + let vm = OverviewViewModel(device: device, explorerUseCase: useCase) vm.containerDelegate = delegate return vm } @@ -86,9 +87,9 @@ enum ViewModelsFactory { return HomeSearchViewModel(useCase: useCase) } - static func getExplorerStationsListViewModel(cellIndex: String, cellCenter: CLLocationCoordinate2D?) -> ExplorerStationsListViewModel { + static func getExplorerStationsListViewModel(cellIndex: String, cellCenter: CLLocationCoordinate2D?, cellCapacity: Int) -> ExplorerStationsListViewModel { let useCase = SwinjectHelper.shared.getContainerForSwinject().resolve(ExplorerUseCaseApi.self) - let vm = ExplorerStationsListViewModel(useCase: useCase, cellIndex: cellIndex, cellCenter: cellCenter) + let vm = ExplorerStationsListViewModel(useCase: useCase, cellIndex: cellIndex, cellCenter: cellCenter, cellCapacity: cellCapacity) return vm } @@ -307,7 +308,8 @@ enum ViewModelsFactory { static func getLocationMapViewModel(initialCoordinate: CLLocationCoordinate2D? = nil) -> SelectLocationMapViewModel { let useCase = SwinjectHelper.shared.getContainerForSwinject().resolve(DeviceLocationUseCaseApi.self)! - return SelectLocationMapViewModel(useCase: useCase, initialCoordinate: initialCoordinate) + let exlporerUseCase = SwinjectHelper.shared.getContainerForSwinject().resolve(ExplorerUseCaseApi.self)! + return SelectLocationMapViewModel(useCase: useCase, explorerUseCase: exlporerUseCase, initialCoordinate: initialCoordinate) } static func getClaimDeviceLocationViewModel(completion: @escaping GenericCallback) -> ClaimDeviceLocationViewModel { diff --git a/PresentationLayer/Utils/MapBox/MapBoxConstants.swift b/PresentationLayer/Utils/MapBox/MapBoxConstants.swift index 8a00ab079..71796c179 100644 --- a/PresentationLayer/Utils/MapBox/MapBoxConstants.swift +++ b/PresentationLayer/Utils/MapBox/MapBoxConstants.swift @@ -16,10 +16,12 @@ enum MapBoxConstants { static let snapshotMarkerName = "marker" static let polygonManagerId = "wtxm-polygon-annotation-manager" static let coloredPolygonManagerId = "wtxm-colored-polygon-annotation-manager" + static let cellCapacityPolygonManagerId = "wtxm-cell-capacity-polygon-annotation-manager" static let initialLat = 37.98075475244475 static let initialLon = 23.710478235562956 static let heatmapLayerId = "wtxm-heatmap-layer" static let pointManagerId = "wtxm-point-annotation-manager" + static let bordersManagerId = "wtxm-borders-annotation-manager" static let heatmapSource = "heatmap" static let customData = "custom_data" diff --git a/WeatherXMTests/PresentationLayer/ViewModels/ExplorerStationsListViewModelTests.swift b/WeatherXMTests/PresentationLayer/ViewModels/ExplorerStationsListViewModelTests.swift index 3715efcd3..9f9234f51 100644 --- a/WeatherXMTests/PresentationLayer/ViewModels/ExplorerStationsListViewModelTests.swift +++ b/WeatherXMTests/PresentationLayer/ViewModels/ExplorerStationsListViewModelTests.swift @@ -20,7 +20,8 @@ struct ExplorerStationsListViewModelTests { useCase = MockExplorerUseCase() viewModel = ExplorerStationsListViewModel(useCase: useCase, cellIndex: "", - cellCenter: nil) + cellCenter: nil, + cellCapacity: 2) } @Test func cellInfo() async throws { diff --git a/WeatherXMTests/PresentationLayer/ViewModels/OverviewViewModelTests.swift b/WeatherXMTests/PresentationLayer/ViewModels/OverviewViewModelTests.swift index 06d2c002b..99776dcdd 100644 --- a/WeatherXMTests/PresentationLayer/ViewModels/OverviewViewModelTests.swift +++ b/WeatherXMTests/PresentationLayer/ViewModels/OverviewViewModelTests.swift @@ -43,7 +43,7 @@ struct OverviewViewModelTests { init() { delegate = StationDetailsContainerDelegate() - viewModel = OverviewViewModel(device: DeviceDetails.mockDevice, linkNavigation: linkNavigator) + viewModel = OverviewViewModel(device: DeviceDetails.mockDevice, linkNavigation: linkNavigator, explorerUseCase: MockExplorerUseCase()) viewModel.containerDelegate = delegate } diff --git a/wxm-ios.xcodeproj/project.pbxproj b/wxm-ios.xcodeproj/project.pbxproj index 2206193c8..727948f0b 100644 --- a/wxm-ios.xcodeproj/project.pbxproj +++ b/wxm-ios.xcodeproj/project.pbxproj @@ -369,6 +369,7 @@ 267EC4292A98C3D300F2C37E /* WXMAlertModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267EC4272A98C3D300F2C37E /* WXMAlertModifier.swift */; }; 267EC42A2A98C3D300F2C37E /* WXMAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267EC4282A98C3D300F2C37E /* WXMAlertView.swift */; }; 268028AA2AF298290031379C /* Indication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 268028A92AF298290031379C /* Indication.swift */; }; + 26862DD22E86CBD000B56C21 /* PublicHex+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26862DCE2E86CBBF00B56C21 /* PublicHex+.swift */; }; 268645982A6FA7C100EB85B1 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 268645972A6FA7C100EB85B1 /* Router.swift */; }; 268B5C372BCE7CF900E63655 /* NavigationTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 268B5C362BCE7CF900E63655 /* NavigationTitleView.swift */; }; 268B5C3A2BCE808200E63655 /* ForecastDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 268B5C392BCE808200E63655 /* ForecastDetailsView.swift */; }; @@ -1157,6 +1158,7 @@ 267EC4282A98C3D300F2C37E /* WXMAlertView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WXMAlertView.swift; sourceTree = ""; }; 267EDF5F2B67B5AC00C221B1 /* ci_post_xcodebuild.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = ci_post_xcodebuild.sh; sourceTree = ""; }; 268028A92AF298290031379C /* Indication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Indication.swift; sourceTree = ""; }; + 26862DCE2E86CBBF00B56C21 /* PublicHex+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicHex+.swift"; sourceTree = ""; }; 268645972A6FA7C100EB85B1 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; 268B5C362BCE7CF900E63655 /* NavigationTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationTitleView.swift; sourceTree = ""; }; 268B5C392BCE808200E63655 /* ForecastDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastDetailsView.swift; sourceTree = ""; }; @@ -2553,6 +2555,7 @@ 2672B87B2C89F19A002266BE /* BoostCode+.swift */, 2672B87D2C8A0155002266BE /* NetworkDeviceRewardsResponse+.swift */, 269EC1682E327EE60099DB78 /* StationAlert+.swift */, + 26862DCE2E86CBBF00B56C21 /* PublicHex+.swift */, ); path = DomainExtensions; sourceTree = ""; @@ -4229,6 +4232,7 @@ 267548DE29A91A87008BCF40 /* FailAPICodeEnum.swift in Sources */, 267547BF29A9181E008BCF40 /* FailedDeleteView.swift in Sources */, 26ACE8C72C86015300EA22DD /* LocalizableString+RewardAnalytics.swift in Sources */, + 26862DD22E86CBD000B56C21 /* PublicHex+.swift in Sources */, 260F8E552E2FC06700CBAEB2 /* ClaimDevicePhotosManager.swift in Sources */, 26A4118A29BA21E600A2C10B /* FailSuccessStateObject.swift in Sources */, 2645823B2A41C07000195C21 /* FailSuccessViewModifier.swift in Sources */, diff --git a/wxm-ios/DataLayer/DataLayer.xcodeproj/project.pbxproj b/wxm-ios/DataLayer/DataLayer.xcodeproj/project.pbxproj index 67f9a9624..a3841038a 100644 --- a/wxm-ios/DataLayer/DataLayer.xcodeproj/project.pbxproj +++ b/wxm-ios/DataLayer/DataLayer.xcodeproj/project.pbxproj @@ -44,6 +44,7 @@ 267EC3BF2CD0ECE60085B50A /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = 267EC3BE2CD0ECE60085B50A /* Pulse */; }; 267EC3C12CD0ECE60085B50A /* PulseProxy in Frameworks */ = {isa = PBXBuildFile; productRef = 267EC3C02CD0ECE60085B50A /* PulseProxy */; }; 2683A4C42ADECF3100D5C205 /* countries_information.json in Resources */ = {isa = PBXBuildFile; fileRef = 2683A4C32ADECBA800D5C205 /* countries_information.json */; }; + 26862DD42E8ACCC300B56C21 /* ExplorerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26862DD32E8ACCBB00B56C21 /* ExplorerService.swift */; }; 268ACD052C1B2A3500B30F83 /* DBBundle+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 268ACD032C1B2A3500B30F83 /* DBBundle+CoreDataClass.swift */; }; 268ACD062C1B2A3500B30F83 /* DBBundle+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 268ACD042C1B2A3500B30F83 /* DBBundle+CoreDataProperties.swift */; }; 2692D88B2A4DB2B50060CB3C /* DBExplorerAddress+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2692D8852A4DB2B50060CB3C /* DBExplorerAddress+CoreDataClass.swift */; }; @@ -151,6 +152,7 @@ 267CA6EE2C1314E200ABE599 /* get_user.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = get_user.json; sourceTree = ""; }; 267EC3BB2CD0E8BD0085B50A /* MainRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainRepositoryImpl.swift; sourceTree = ""; }; 2683A4C32ADECBA800D5C205 /* countries_information.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = countries_information.json; sourceTree = ""; }; + 26862DD32E8ACCBB00B56C21 /* ExplorerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExplorerService.swift; sourceTree = ""; }; 268ACD032C1B2A3500B30F83 /* DBBundle+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DBBundle+CoreDataClass.swift"; sourceTree = ""; }; 268ACD042C1B2A3500B30F83 /* DBBundle+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DBBundle+CoreDataProperties.swift"; sourceTree = ""; }; 2692D8852A4DB2B50060CB3C /* DBExplorerAddress+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DBExplorerAddress+CoreDataClass.swift"; sourceTree = ""; }; @@ -318,6 +320,7 @@ 26AB9F512A80112900855912 /* UserDevicesService.swift */, 262B02F72B1F3B5300C31FE7 /* UserInfoService.swift */, 26C71CDB2D27D89300D2F0F3 /* FileUploaderService.swift */, + 26862DD32E8ACCBB00B56C21 /* ExplorerService.swift */, ); name = Services; path = ..; @@ -661,6 +664,7 @@ 2692D88E2A4DB2B50060CB3C /* DBExplorerSearchEntity+CoreDataProperties.swift in Sources */, 26E88B3729E586B00023BBD5 /* DBProtocols.swift in Sources */, 26E88B3429E582070023BBD5 /* DBWeather+CoreDataClass.swift in Sources */, + 26862DD42E8ACCC300B56C21 /* ExplorerService.swift in Sources */, 26E88B3529E582070023BBD5 /* DBWeather+CoreDataProperties.swift in Sources */, 26A4117829B8973E00A2C10B /* DeviceInfoRepositoryImpl.swift in Sources */, 7480AF562835098A002758C3 /* DevicesApiRequestBuilder.swift in Sources */, diff --git a/wxm-ios/DataLayer/DataLayer/RepositoryImplementations/ExplorerRepositoryImpl.swift b/wxm-ios/DataLayer/DataLayer/RepositoryImplementations/ExplorerRepositoryImpl.swift index c88dfb33e..b5c5b2e0a 100644 --- a/wxm-ios/DataLayer/DataLayer/RepositoryImplementations/ExplorerRepositoryImpl.swift +++ b/wxm-ios/DataLayer/DataLayer/RepositoryImplementations/ExplorerRepositoryImpl.swift @@ -13,8 +13,11 @@ import Toolkit public struct ExplorerRepositoryImpl: ExplorerRepository { private let locationManager = WXMLocationManager() + private let service: ExplorerService - public init() {} + public init(service: ExplorerService) { + self.service = service + } public func getUserLocation() async -> Result { let res = await locationManager.getUserLocation() @@ -32,9 +35,7 @@ public struct ExplorerRepositoryImpl: ExplorerRepository { } public func getPublicHexes() throws -> AnyPublisher, Never> { - let builder = CellRequestBuilder.getCells - let urlRequest = try builder.asURLRequest() - return ApiClient.shared.requestCodable(urlRequest, mockFileName: builder.mockFileName) + try service.getPublicHexes() } public func getPublicDevicesOfHex(index: String) throws -> AnyPublisher, Never> { diff --git a/wxm-ios/DataLayer/ExplorerService.swift b/wxm-ios/DataLayer/ExplorerService.swift new file mode 100644 index 000000000..23f308248 --- /dev/null +++ b/wxm-ios/DataLayer/ExplorerService.swift @@ -0,0 +1,49 @@ +// +// ExplorerService.swift +// DataLayer +// +// Created by Pantelis Giazitsis on 29/9/25. +// + +import Foundation +import DomainLayer +import Combine +import Alamofire +import Toolkit + +public class ExplorerService { + private let cacheValidationInterval: TimeInterval = 3.0 * 60.0 // 3 minutes + private let cache: TimeValidationCache<[PublicHex]> + private let cacheKey = UserDefaults.GenericKey.explorerHexes.rawValue + + public init(cacheManager: PersistCacheManager) { + self.cache = .init(persistCacheManager: cacheManager, persistKey: cacheKey) + } + + func getPublicHexes() throws -> AnyPublisher, Never> { + if let cachedValue: [PublicHex] = cache.getValue(for: cacheKey) { + let response: DataResponse<[PublicHex], NetworkErrorResponse> = .init(request: nil, + response: nil, + data: nil, + metrics: nil, + serializationDuration: 0.0, + result: .success(cachedValue)) + return Just(response).eraseToAnyPublisher() + } + + let builder = CellRequestBuilder.getCells + let urlRequest = try builder.asURLRequest() + let publisher: AnyPublisher, Never> = ApiClient.shared.requestCodable(urlRequest, mockFileName: builder.mockFileName) + return publisher.flatMap { [weak self] response in + guard let self else { + return Just(response) + } + + if let value = response.value { + self.cache.insertValue(value, expire: self.cacheValidationInterval, for: self.cacheKey) + } + + return Just(response) + }.eraseToAnyPublisher() + } +} diff --git a/wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Cells/PublicHex.swift b/wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Cells/PublicHex.swift index 0d3fed5b5..14b38752a 100644 --- a/wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Cells/PublicHex.swift +++ b/wxm-ios/DomainLayer/DomainLayer/Entities/Codables/Cells/PublicHex.swift @@ -7,6 +7,7 @@ public struct PublicHex: Codable, Sendable, Equatable { public var index: String = "" + public var capacity: Int? public var deviceCount: Int? public var activeDeviceCount: Int? public var averageDataQuality: Int? @@ -15,6 +16,7 @@ public struct PublicHex: Codable, Sendable, Equatable { enum CodingKeys: String, CodingKey { case index + case capacity case deviceCount = "device_count" case activeDeviceCount = "active_device_count" case averageDataQuality = "avg_data_quality" @@ -22,8 +24,15 @@ public struct PublicHex: Codable, Sendable, Equatable { case polygon } - init(index: String = "", deviceCount: Int? = nil, activeDeviceCount: Int? = nil, averageDataQuality: Int? = nil, center: HexLocation = .init(), polygon: [HexLocation] = []) { + init(index: String = "", + capacity: Int? = nil, + deviceCount: Int? = nil, + activeDeviceCount: Int? = nil, + averageDataQuality: Int? = nil, + center: HexLocation = .init(), + polygon: [HexLocation] = []) { self.index = index + self.capacity = capacity self.deviceCount = deviceCount self.activeDeviceCount = activeDeviceCount self.averageDataQuality = averageDataQuality @@ -34,6 +43,7 @@ public struct PublicHex: Codable, Sendable, Equatable { public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.index = try container.decode(String.self, forKey: .index) + self.capacity = try container.decodeIfPresent(Int.self, forKey: .capacity) self.deviceCount = try container.decodeIfPresent(Int.self, forKey: .deviceCount) self.activeDeviceCount = try container.decodeIfPresent(Int.self, forKey: .activeDeviceCount) self.averageDataQuality = try? container.decodeIfPresent(Int.self, forKey: .averageDataQuality) diff --git a/wxm-ios/DomainLayer/DomainLayer/Extensions/UserDefaults+Constants.swift b/wxm-ios/DomainLayer/DomainLayer/Extensions/UserDefaults+Constants.swift index 1ffb06ad4..2418d2046 100644 --- a/wxm-ios/DomainLayer/DomainLayer/Extensions/UserDefaults+Constants.swift +++ b/wxm-ios/DomainLayer/DomainLayer/Extensions/UserDefaults+Constants.swift @@ -52,6 +52,7 @@ public extension UserDefaults { case savedLocations = "com.weatherxm.app.UserDefaults.Key.SavedLocations" case savedForecasts = "com.weatherxm.app.UserDefaults.Key.SavedForecasts" case onboardingIsShown = "com.weatherxm.app.UserDefaults.Key.OnboardingIsShown" + case explorerHexes = "com.weatherxm.app.UserDefaults.Key.ExplorerHexes" // MARK: - UserDefaultEntry diff --git a/wxm-ios/Resources/Localizable/Localizable.xcstrings b/wxm-ios/Resources/Localizable/Localizable.xcstrings index 9a830aceb..61c178912 100644 --- a/wxm-ios/Resources/Localizable/Localizable.xcstrings +++ b/wxm-ios/Resources/Localizable/Localizable.xcstrings @@ -792,6 +792,39 @@ } } }, + "claim_device_cell_capacity_reached_alert_text" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This cell has already reached its station limit based on its geospatial capacity. Only the top-ranked stations in this cell, determined by reward score and seniority, are eligible for rewards.\nIf you deploy here and your station ranks below the capacity threshold, it won’t receive any rewards.\nFor example, in a cell with a capacity of 5 stations, only the top 5 ranked devices are rewarded.\nTo maximize your earnings, consider deploying in a cell with available capacity." + } + } + } + }, + "claim_device_cell_capacity_reached_alert_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Watch out!" + } + } + } + }, + "claim_device_cell_capacity_reached_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cell already at max capacity. New stations may not be eligible for rewards." + } + } + } + }, "claim_device_claim_d1_title" : { "extractionState" : "manual", "localizations" : { @@ -1793,6 +1826,17 @@ } } }, + "claim_device_proceed_anyway" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proceed anyway" + } + } + } + }, "claim_device_pulse_title" : { "extractionState" : "manual", "localizations" : { @@ -1804,6 +1848,17 @@ } } }, + "claim_device_relocate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relocate" + } + } + } + }, "claim_device_reset_pulse_bullet_one" : { "extractionState" : "manual", "localizations" : { @@ -4483,7 +4538,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cell capacity is a parameter that is used to define the maximum number of stations that are eligible for rewards in a specific cell. Every cell has a predefined capacity that depends on its geospatial characteristics*.\n\nEvery station is ranked in its cell daily, based on its reward score and its seniority. As long as the station's ranking is above the capacity threshold, it will be rewarded, whereas getting below it will lead to zero rewards. For example, in a cell with a max capacity of 5 stations, if a station is ranked 3rd it will get rewarded, but if it is ranked 7th, it won’t receive any rewards.\n\nRead more about how our Cell Capacity algorithm works.\n\n* The cell capacity is currently set to the fixed value of 10 rewardable stations, for every cell." + "value" : "Cell capacity is a parameter that is used to define the maximum number of stations that are eligible for rewards in a specific cell. Every cell has a predefined capacity that depends on its geospatial characteristics*.\n\nEvery station is ranked in its cell daily, based on its reward score and its seniority. As long as the station's ranking is above the capacity threshold, it will be rewarded, whereas getting below it will lead to zero rewards. For example, in a cell with a max capacity of 5 stations, if a station is ranked 3rd it will get rewarded, but if it is ranked 7th, it won’t receive any rewards.\n\nRead more about how our Cell Capacity algorithm works." } } } @@ -7750,7 +7805,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "%d/10 stations present" + "value" : "%d/%d stations present" } } } @@ -8261,6 +8316,17 @@ } } }, + "read_more" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Read More" + } + } + } + }, "read_terms_and_privacy_policy" : { "extractionState" : "manual", "localizations" : { @@ -8641,7 +8707,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cell ranking is a measure of a station's performance relative to others within the same cell - a hexagonal area, covering approximately 5 square kilometers (2 square miles) on the planet.\n\nCell capacity is a parameter that is used to define the maximum number of stations that are eligible for rewards in a specific cell. Every cell has a predefined capacity that depends on its geospatial characteristics*.\n\nEvery station is ranked in its cell daily, based on its reward score and its seniority. As long as the station's ranking is above the capacity threshold, it will be rewarded, whereas getting below it will lead to zero rewards. For example, in a cell with a max capacity of 5 stations, if a station is ranked 3rd it will get rewarded, but if it is ranked 7th, it won’t receive any rewards.\n\nRead more about how our Cell Capacity algorithm works.

* The cell capacity is currently set to the fixed value of 10 rewardable stations, for every cell." + "value" : "Cell ranking is a measure of a station's performance relative to others within the same cell - a hexagonal area, covering approximately 5 square kilometers (2 square miles) on the planet.\n\nCell capacity is a parameter that is used to define the maximum number of stations that are eligible for rewards in a specific cell. Every cell has a predefined capacity that depends on its geospatial characteristics*.\n\nEvery station is ranked in its cell daily, based on its reward score and its seniority. As long as the station's ranking is above the capacity threshold, it will be rewarded, whereas getting below it will lead to zero rewards. For example, in a cell with a max capacity of 5 stations, if a station is ranked 3rd it will get rewarded, but if it is ranked 7th, it won’t receive any rewards.\n\nRead more about how our Cell Capacity algorithm works." } } } diff --git a/wxm-ios/Resources/Localizable/LocalizableConstants.swift b/wxm-ios/Resources/Localizable/LocalizableConstants.swift index 9e364ceae..1ba199a4b 100644 --- a/wxm-ios/Resources/Localizable/LocalizableConstants.swift +++ b/wxm-ios/Resources/Localizable/LocalizableConstants.swift @@ -192,12 +192,13 @@ enum LocalizableString: WXMLocalizable { case noActiveStations case activeStations(Int?) case activeStation(Int?) - case presentStations(Int?) + case presentStations(Int?,Int?) case stationInactive case home case myStations case explorer case profile + case readMore var localized: String { var localized = NSLocalizedString(self.key, comment: "") @@ -207,8 +208,7 @@ enum LocalizableString: WXMLocalizable { .following(let count), .issues(let count), .activeStations(let count), - .activeStation(let count), - .presentStations(let count): + .activeStation(let count): localized = String(format: localized, count ?? 0) case .unfollowAlertDescription(let text), .followAlertDescription(let text), @@ -224,6 +224,8 @@ enum LocalizableString: WXMLocalizable { localized = String(format: localized, text, link) case .percentage(let count): localized = String(format: localized, count) + case .presentStations(let count0, let count1): + localized = String(format: localized, count0 ?? 0, count1 ?? 0) default: break } @@ -616,6 +618,8 @@ extension LocalizableString { return "explorer" case .profile: return "profile" + case .readMore: + return "read_more" } } } diff --git a/wxm-ios/Resources/Localizable/LocalizableString+ClaimDevice.swift b/wxm-ios/Resources/Localizable/LocalizableString+ClaimDevice.swift index 68b97af44..1d313e9c5 100644 --- a/wxm-ios/Resources/Localizable/LocalizableString+ClaimDevice.swift +++ b/wxm-ios/Resources/Localizable/LocalizableString+ClaimDevice.swift @@ -166,6 +166,11 @@ extension LocalizableString { case photoVerificationText case uploadAndClaim case uploadAndProceed + case cellCapacityReachedMessage + case cellCapacityReachedAlertTitle + case cellCapacityReachedAlertText + case proceedAnyway + case relocate } } @@ -508,6 +513,16 @@ extension LocalizableString.ClaimDevice: WXMLocalizable { return "claim_device_upload_and_claim" case .uploadAndProceed: return "claim_device_upload_and_proceed" + case .cellCapacityReachedMessage: + return "claim_device_cell_capacity_reached_message" + case .cellCapacityReachedAlertTitle: + return "claim_device_cell_capacity_reached_alert_title" + case .cellCapacityReachedAlertText: + return "claim_device_cell_capacity_reached_alert_text" + case .proceedAnyway: + return "claim_device_proceed_anyway" + case .relocate: + return "claim_device_relocate" } } } diff --git a/wxm-ios/Swinject/SwinjectHelper.swift b/wxm-ios/Swinject/SwinjectHelper.swift index 4e94a444b..6db955b74 100644 --- a/wxm-ios/Swinject/SwinjectHelper.swift +++ b/wxm-ios/Swinject/SwinjectHelper.swift @@ -40,6 +40,11 @@ class SwinjectHelper: SwinjectInterface { } .inObjectScope(.container) + container.register(ExplorerService.self) { resolver in + ExplorerService(cacheManager: resolver.resolve(UserDefaultsService.self)!) + } + .inObjectScope(.container) + container.register(MainRepository.self) { _ in MainRepositoryImpl() } @@ -96,8 +101,8 @@ class SwinjectHelper: SwinjectInterface { } // MARK: - Cells DI - container.register(ExplorerRepository.self) { _ in - ExplorerRepositoryImpl() + container.register(ExplorerRepository.self) { resolver in + ExplorerRepositoryImpl(service: resolver.resolve(ExplorerService.self)!) } container.register(ExplorerUseCaseApi.self) { resolver in