diff --git a/Meshtastic/Extensions/Color+Hex.swift b/Meshtastic/Extensions/Color+Hex.swift index 79f0f4b5..c1fe99a7 100644 --- a/Meshtastic/Extensions/Color+Hex.swift +++ b/Meshtastic/Extensions/Color+Hex.swift @@ -1,30 +1,30 @@ import SwiftUI extension Color { - /// Initialize a Color from a hex string (e.g., "#FF0000" or "FF0000") - init(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) + /// Initialize a Color from a hex string (e.g., "#FF0000" or "FF0000") + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) - let a, r, g, b: UInt64 - switch hex.count { - case 3: // RGB (12-bit) - (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: // RGB (24-bit) - (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: // ARGB (32-bit) - (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) - default: - (a, r, g, b) = (255, 0, 0, 0) - } + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } - self.init( - .sRGB, - red: Double(r) / 255, - green: Double(g) / 255, - blue: Double(b) / 255, - opacity: Double(a) / 255 - ) - } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } } diff --git a/Meshtastic/Helpers/GeoJSONOverlayConfig.swift b/Meshtastic/Helpers/GeoJSONOverlayConfig.swift index ddbc30bd..8b301a66 100644 --- a/Meshtastic/Helpers/GeoJSONOverlayConfig.swift +++ b/Meshtastic/Helpers/GeoJSONOverlayConfig.swift @@ -7,337 +7,337 @@ import OSLog // MARK: - Raw GeoJSON Support Only struct GeoJSONFeatureCollection: Codable { - let type: String // Always "FeatureCollection" - let features: [GeoJSONFeature] + let type: String // Always "FeatureCollection" + let features: [GeoJSONFeature] } struct GeoJSONFeature: Codable { - let type: String // Always "Feature" - let id: Int? - let geometry: GeoJSONGeometry - let properties: [String: AnyCodableValue]? + let type: String // Always "Feature" + let id: Int? + let geometry: GeoJSONGeometry + let properties: [String: AnyCodableValue]? - // MARK: - GeoJSON Styling Properties + // MARK: - GeoJSON Styling Properties - /// Extract feature name from properties, defaulting to empty string - var name: String { - // Check for "NAME" first (uppercase), then "name" (lowercase) - if case .string(let value) = properties?["NAME"] { - return value - } - if case .string(let value) = properties?["name"] { - return value - } - return "" - } + /// Extract feature name from properties, defaulting to empty string + var name: String { + // Check for "NAME" first (uppercase), then "name" (lowercase) + if case .string(let value) = properties?["NAME"] { + return value + } + if case .string(let value) = properties?["name"] { + return value + } + return "" + } - /// Extract layer metadata from properties - var layerId: String? { - if case .string(let value) = properties?["layer_id"] { - return value - } - return nil - } + /// Extract layer metadata from properties + var layerId: String? { + if case .string(let value) = properties?["layer_id"] { + return value + } + return nil + } - var layerName: String? { - if case .string(let value) = properties?["layer_name"] { - return value - } - return nil - } + var layerName: String? { + if case .string(let value) = properties?["layer_name"] { + return value + } + return nil + } - var layerDescription: String? { - if case .string(let value) = properties?["description"] { - return value - } - return nil - } + var layerDescription: String? { + if case .string(let value) = properties?["description"] { + return value + } + return nil + } - var isVisible: Bool { - if case .bool(let value) = properties?["visible"] { - return value - } - return true // Default to visible - } + var isVisible: Bool { + if case .bool(let value) = properties?["visible"] { + return value + } + return true // Default to visible + } - // MARK: - Point/Marker Styling + // MARK: - Point/Marker Styling - var markerColor: String? { - if case .string(let value) = properties?["marker-color"] { - return value - } - return nil - } + var markerColor: String? { + if case .string(let value) = properties?["marker-color"] { + return value + } + return nil + } - var markerSize: String? { - if case .string(let value) = properties?["marker-size"] { - return value - } - return "medium" // Default size - } + var markerSize: String? { + if case .string(let value) = properties?["marker-size"] { + return value + } + return "medium" // Default size + } - var markerSymbol: String? { - if case .string(let value) = properties?["marker-symbol"] { - return value - } - return nil - } + var markerSymbol: String? { + if case .string(let value) = properties?["marker-symbol"] { + return value + } + return nil + } - // MARK: - Stroke/Line Styling + // MARK: - Stroke/Line Styling - var strokeColor: String? { - if case .string(let value) = properties?["stroke"] { - return value - } - return nil - } + var strokeColor: String? { + if case .string(let value) = properties?["stroke"] { + return value + } + return nil + } - var strokeWidth: Double { - if case .double(let value) = properties?["stroke-width"] { - return value - } else if case .int(let value) = properties?["stroke-width"] { - return Double(value) - } - return 1.0 // Default width - } + var strokeWidth: Double { + if case .double(let value) = properties?["stroke-width"] { + return value + } else if case .int(let value) = properties?["stroke-width"] { + return Double(value) + } + return 1.0 // Default width + } - var strokeOpacity: Double { - if case .double(let value) = properties?["stroke-opacity"] { - return value - } else if case .int(let value) = properties?["stroke-opacity"] { - return Double(value) - } - return 1.0 // Default opacity - } + var strokeOpacity: Double { + if case .double(let value) = properties?["stroke-opacity"] { + return value + } else if case .int(let value) = properties?["stroke-opacity"] { + return Double(value) + } + return 1.0 // Default opacity + } - var lineDashArray: [Double]? { - if case .array(let values) = properties?["line-dasharray"] { - return values.compactMap { value in - switch value { - case .double(let d): return d - case .int(let i): return Double(i) - default: return nil - } - } - } - return nil - } + var lineDashArray: [Double]? { + if case .array(let values) = properties?["line-dasharray"] { + return values.compactMap { value in + switch value { + case .double(let d): return d + case .int(let i): return Double(i) + default: return nil + } + } + } + return nil + } - // MARK: - Fill Styling + // MARK: - Fill Styling - var fillColor: String? { - if case .string(let value) = properties?["fill"] { - return value - } - return nil - } + var fillColor: String? { + if case .string(let value) = properties?["fill"] { + return value + } + return nil + } - var fillOpacity: Double { - if case .double(let value) = properties?["fill-opacity"] { - return value - } else if case .int(let value) = properties?["fill-opacity"] { - return Double(value) - } - return 0.0 // Default to no fill - } + var fillOpacity: Double { + if case .double(let value) = properties?["fill-opacity"] { + return value + } else if case .int(let value) = properties?["fill-opacity"] { + return Double(value) + } + return 0.0 // Default to no fill + } - // MARK: - Computed Rendering Properties + // MARK: - Computed Rendering Properties - /// Get effective stroke color (fallback to marker color for points) - var effectiveStrokeColor: String { - return strokeColor ?? markerColor ?? "#000000" - } + /// Get effective stroke color (fallback to marker color for points) + var effectiveStrokeColor: String { + return strokeColor ?? markerColor ?? "#000000" + } - /// Get effective fill color (fallback to stroke color if fill opacity > 0) - var effectiveFillColor: String { - if fillOpacity > 0 { - return fillColor ?? effectiveStrokeColor - } - return "#000000" - } + /// Get effective fill color (fallback to stroke color if fill opacity > 0) + var effectiveFillColor: String { + if fillOpacity > 0 { + return fillColor ?? effectiveStrokeColor + } + return "#000000" + } - /// Convert marker size to point radius - var markerRadius: CGFloat { - switch markerSize { - case "small": return 4.0 - case "medium": return 8.0 - case "large": return 12.0 - default: return 4.0 - } - } + /// Convert marker size to point radius + var markerRadius: CGFloat { + switch markerSize { + case "small": return 4.0 + case "medium": return 8.0 + case "large": return 12.0 + default: return 4.0 + } + } } // MARK: - Styled Feature Wrapper /// Wrapper for a GeoJSON feature with its styling properties and metadata struct GeoJSONStyledFeature: Identifiable { - let id = UUID() - let feature: GeoJSONFeature - let overlayId: String + let id = UUID() + let feature: GeoJSONFeature + let overlayId: String - /// Create MKOverlay from this styled feature - func createOverlay() -> MKOverlay? { - do { - // Convert feature to standard GeoJSON format for MKGeoJSONDecoder - let featureDict: [String: Any] = [ - "type": feature.type, - "geometry": [ - "type": feature.geometry.type, - "coordinates": feature.geometry.coordinates.toAnyObject() - ], - "properties": feature.properties?.mapValues { $0.toAnyObject() } ?? [:] - ] + /// Create MKOverlay from this styled feature + func createOverlay() -> MKOverlay? { + do { + // Convert feature to standard GeoJSON format for MKGeoJSONDecoder + let featureDict: [String: Any] = [ + "type": feature.type, + "geometry": [ + "type": feature.geometry.type, + "coordinates": feature.geometry.coordinates.toAnyObject() + ], + "properties": feature.properties?.mapValues { $0.toAnyObject() } ?? [:] + ] - // Creating overlay for geometry + // Creating overlay for geometry - let geojsonData = try JSONSerialization.data(withJSONObject: featureDict) - let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData) + let geojsonData = try JSONSerialization.data(withJSONObject: featureDict) + let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData) - // MKGeoJSONDecoder processing + // MKGeoJSONDecoder processing - if let mkFeature = mkFeatures.first as? MKGeoJSONFeature { - // Processing geometry objects - if let geometry = mkFeature.geometry.first as? MKOverlay { - // Successfully created overlay - return geometry - } - } - } catch { - Logger.services.error("πŸ—ΊοΈ GeoJSONStyledFeature: Failed to convert feature to overlay: \(error.localizedDescription)") - } - return nil - } + if let mkFeature = mkFeatures.first as? MKGeoJSONFeature { + // Processing geometry objects + if let geometry = mkFeature.geometry.first as? MKOverlay { + // Successfully created overlay + return geometry + } + } + } catch { + Logger.services.error("πŸ—ΊοΈ GeoJSONStyledFeature: Failed to convert feature to overlay: \(error.localizedDescription)") + } + return nil + } - /// Get stroke style for this feature - var strokeStyle: StrokeStyle { - let dashArray = feature.lineDashArray - if let dashArray = dashArray, !dashArray.isEmpty { - return StrokeStyle( - lineWidth: feature.strokeWidth, - lineCap: .round, - lineJoin: .round, - dash: dashArray.map { CGFloat($0) } - ) - } else { - return StrokeStyle( - lineWidth: feature.strokeWidth, - lineCap: .round, - lineJoin: .round - ) - } - } + /// Get stroke style for this feature + var strokeStyle: StrokeStyle { + let dashArray = feature.lineDashArray + if let dashArray = dashArray, !dashArray.isEmpty { + return StrokeStyle( + lineWidth: feature.strokeWidth, + lineCap: .round, + lineJoin: .round, + dash: dashArray.map { CGFloat($0) } + ) + } else { + return StrokeStyle( + lineWidth: feature.strokeWidth, + lineCap: .round, + lineJoin: .round + ) + } + } - /// Get stroke color with opacity - var strokeColor: Color { - return Color(hex: feature.effectiveStrokeColor).opacity(feature.strokeOpacity) - } + /// Get stroke color with opacity + var strokeColor: Color { + return Color(hex: feature.effectiveStrokeColor).opacity(feature.strokeOpacity) + } - /// Get fill color with opacity - var fillColor: Color { - return Color(hex: feature.effectiveFillColor).opacity(feature.fillOpacity) - } + /// Get fill color with opacity + var fillColor: Color { + return Color(hex: feature.effectiveFillColor).opacity(feature.fillOpacity) + } } struct GeoJSONGeometry: Codable { - let type: String // "Point", "LineString", "Polygon", etc. - let coordinates: AnyCodableValue // Flexible coordinate structure + let type: String // "Point", "LineString", "Polygon", etc. + let coordinates: AnyCodableValue // Flexible coordinate structure } // MARK: - Flexible JSON Value Type enum AnyCodableValue: Codable { - case string(String) - case int(Int) - case double(Double) - case bool(Bool) - case array([AnyCodableValue]) - case object([String: AnyCodableValue]) - case null + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([AnyCodableValue]) + case object([String: AnyCodableValue]) + case null - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else if let value = try? container.decode(Int.self) { - self = .int(value) - } else if let value = try? container.decode(Double.self) { - self = .double(value) - } else if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode([AnyCodableValue].self) { - self = .array(value) - } else if let value = try? container.decode([String: AnyCodableValue].self) { - self = .object(value) - } else { - throw DecodingError.typeMismatch(AnyCodableValue.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unable to decode AnyCodableValue")) - } - } + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([AnyCodableValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: AnyCodableValue].self) { + self = .object(value) + } else { + throw DecodingError.typeMismatch(AnyCodableValue.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unable to decode AnyCodableValue")) + } + } - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() - switch self { - case .null: - try container.encodeNil() - case .bool(let value): - try container.encode(value) - case .int(let value): - try container.encode(value) - case .double(let value): - try container.encode(value) - case .string(let value): - try container.encode(value) - case .array(let value): - try container.encode(value) - case .object(let value): - try container.encode(value) - } - } + switch self { + case .null: + try container.encodeNil() + case .bool(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } - // Helper to convert coordinates to the format expected by MKGeoJSONDecoder - func toAnyObject() -> Any { - switch self { - case .null: - return NSNull() - case .bool(let value): - return value - case .int(let value): - return value - case .double(let value): - return value - case .string(let value): - return value - case .array(let values): - return values.map { $0.toAnyObject() } - case .object(let dict): - return dict.mapValues { $0.toAnyObject() } - } - } + // Helper to convert coordinates to the format expected by MKGeoJSONDecoder + func toAnyObject() -> Any { + switch self { + case .null: + return NSNull() + case .bool(let value): + return value + case .int(let value): + return value + case .double(let value): + return value + case .string(let value): + return value + case .array(let values): + return values.map { $0.toAnyObject() } + case .object(let dict): + return dict.mapValues { $0.toAnyObject() } + } + } - // Helper to convert Point coordinates to CLLocationCoordinate2D - func toCoordinate() -> CLLocationCoordinate2D? { - if case .array(let coords) = self, - coords.count >= 2 { - let lon: Double - let lat: Double + // Helper to convert Point coordinates to CLLocationCoordinate2D + func toCoordinate() -> CLLocationCoordinate2D? { + if case .array(let coords) = self, + coords.count >= 2 { + let lon: Double + let lat: Double - switch coords[0] { - case .double(let d): lon = d - case .int(let i): lon = Double(i) - default: return nil - } + switch coords[0] { + case .double(let d): lon = d + case .int(let i): lon = Double(i) + default: return nil + } - switch coords[1] { - case .double(let d): lat = d - case .int(let i): lat = Double(i) - default: return nil - } + switch coords[1] { + case .double(let d): lat = d + case .int(let i): lat = Double(i) + default: return nil + } - return CLLocationCoordinate2D(latitude: lat, longitude: lon) - } - return nil - } + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + return nil + } } diff --git a/Meshtastic/Helpers/GeoJSONOverlayManager.swift b/Meshtastic/Helpers/GeoJSONOverlayManager.swift index a77df91b..82801db0 100644 --- a/Meshtastic/Helpers/GeoJSONOverlayManager.swift +++ b/Meshtastic/Helpers/GeoJSONOverlayManager.swift @@ -4,145 +4,145 @@ import OSLog /// Manager for loading and managing raw GeoJSON feature collections with embedded styling class GeoJSONOverlayManager { - static let shared = GeoJSONOverlayManager() - private init() {} + static let shared = GeoJSONOverlayManager() + private init() {} - private var featureCollection: GeoJSONFeatureCollection? + private var featureCollection: GeoJSONFeatureCollection? - /// Load raw GeoJSON feature collection from user uploads - func loadFeatureCollection() -> GeoJSONFeatureCollection? { - if let cached = featureCollection { - return cached - } + /// Load raw GeoJSON feature collection from user uploads + func loadFeatureCollection() -> GeoJSONFeatureCollection? { + if let cached = featureCollection { + return cached + } - // Load user-uploaded feature collection - if let userFeatures = MapDataManager.shared.loadFeatureCollection() { - featureCollection = userFeatures - return userFeatures - } + // Load user-uploaded feature collection + if let userFeatures = MapDataManager.shared.loadFeatureCollection() { + featureCollection = userFeatures + return userFeatures + } - return nil - } + return nil + } - /// Load styled features for specific enabled configs - func loadStyledFeaturesForConfigs(_ enabledConfigs: Set) -> [GeoJSONStyledFeature] { - // Get files that match the enabled configs - let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) } + /// Load styled features for specific enabled configs + func loadStyledFeaturesForConfigs(_ enabledConfigs: Set) -> [GeoJSONStyledFeature] { + // Get files that match the enabled configs + let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) } - guard !enabledFiles.isEmpty else { - return [] - } + guard !enabledFiles.isEmpty else { + return [] + } - // Load feature collection from enabled files only - guard let collection = MapDataManager.shared.loadFeatureCollectionForFiles(enabledFiles) else { - return [] - } + // Load feature collection from enabled files only + guard let collection = MapDataManager.shared.loadFeatureCollectionForFiles(enabledFiles) else { + return [] + } - var styledFeatures: [GeoJSONStyledFeature] = [] + var styledFeatures: [GeoJSONStyledFeature] = [] - for feature in collection.features { - // Skip invisible features - guard feature.isVisible else { - continue - } + for feature in collection.features { + // Skip invisible features + guard feature.isVisible else { + continue + } - let layerId = feature.layerId ?? "default" - let styledFeature = GeoJSONStyledFeature( - feature: feature, - overlayId: layerId - ) - styledFeatures.append(styledFeature) - } + let layerId = feature.layerId ?? "default" + let styledFeature = GeoJSONStyledFeature( + feature: feature, + overlayId: layerId + ) + styledFeatures.append(styledFeature) + } - return styledFeatures - } + return styledFeatures + } - /// Load styled features for direct rendering (legacy method) - func loadStyledFeatures() -> [GeoJSONStyledFeature] { - guard let collection = loadFeatureCollection() else { - return [] - } + /// Load styled features for direct rendering (legacy method) + func loadStyledFeatures() -> [GeoJSONStyledFeature] { + guard let collection = loadFeatureCollection() else { + return [] + } - var styledFeatures: [GeoJSONStyledFeature] = [] + var styledFeatures: [GeoJSONStyledFeature] = [] - for feature in collection.features { - // Skip invisible features - guard feature.isVisible else { - continue - } + for feature in collection.features { + // Skip invisible features + guard feature.isVisible else { + continue + } - let layerId = feature.layerId ?? "default" - let styledFeature = GeoJSONStyledFeature( - feature: feature, - overlayId: layerId - ) - styledFeatures.append(styledFeature) - } + let layerId = feature.layerId ?? "default" + let styledFeature = GeoJSONStyledFeature( + feature: feature, + overlayId: layerId + ) + styledFeatures.append(styledFeature) + } - return styledFeatures - } + return styledFeatures + } - /// Get all features grouped by layer ID - func getFeaturesByLayer() -> [String: [GeoJSONFeature]] { - guard let collection = loadFeatureCollection() else { return [:] } + /// Get all features grouped by layer ID + func getFeaturesByLayer() -> [String: [GeoJSONFeature]] { + guard let collection = loadFeatureCollection() else { return [:] } - var featuresByLayer: [String: [GeoJSONFeature]] = [:] + var featuresByLayer: [String: [GeoJSONFeature]] = [:] - for feature in collection.features { - let layerId = feature.layerId ?? "default" - if featuresByLayer[layerId] == nil { - featuresByLayer[layerId] = [] - } - featuresByLayer[layerId]?.append(feature) - } + for feature in collection.features { + let layerId = feature.layerId ?? "default" + if featuresByLayer[layerId] == nil { + featuresByLayer[layerId] = [] + } + featuresByLayer[layerId]?.append(feature) + } - return featuresByLayer - } + return featuresByLayer + } - /// Get all available layer IDs from features - func getAvailableLayerIds() -> [String] { - guard let collection = loadFeatureCollection() else { return [] } - let layerIds = Set(collection.features.compactMap { $0.layerId ?? "default" }) - return Array(layerIds).sorted() - } + /// Get all available layer IDs from features + func getAvailableLayerIds() -> [String] { + guard let collection = loadFeatureCollection() else { return [] } + let layerIds = Set(collection.features.compactMap { $0.layerId ?? "default" }) + return Array(layerIds).sorted() + } - /// Clear cached data (useful for testing or memory management) - func clearCache() { - featureCollection = nil - } + /// Clear cached data (useful for testing or memory management) + func clearCache() { + featureCollection = nil + } - /// Check if user-uploaded data is available (regardless of active state) - func hasUserData() -> Bool { - return !MapDataManager.shared.getUploadedFiles().isEmpty - } + /// Check if user-uploaded data is available (regardless of active state) + func hasUserData() -> Bool { + return !MapDataManager.shared.getUploadedFiles().isEmpty + } - /// Check if there are any active files - func hasActiveData() -> Bool { - return MapDataManager.shared.getUploadedFiles().contains { $0.isActive } - } + /// Check if there are any active files + func hasActiveData() -> Bool { + return MapDataManager.shared.getUploadedFiles().contains { $0.isActive } + } - /// Get the active data source name - func getActiveDataSource() -> String { - if hasActiveData() { - return NSLocalizedString("User Uploaded", comment: "Data source label for user uploaded files") - } else if hasUserData() { - return NSLocalizedString("Files Available", comment: "Data source label when files exist but none are active") - } else { - return NSLocalizedString("No Data", comment: "Data source label when no files are available") - } - } + /// Get the active data source name + func getActiveDataSource() -> String { + if hasActiveData() { + return NSLocalizedString("User Uploaded", comment: "Data source label for user uploaded files") + } else if hasUserData() { + return NSLocalizedString("Files Available", comment: "Data source label when files exist but none are active") + } else { + return NSLocalizedString("No Data", comment: "Data source label when no files are available") + } + } - // MARK: - File-based Filtering + // MARK: - File-based Filtering - /// Get all uploaded files with their active states for UI display - func getUploadedFilesWithState() -> [MapDataMetadata] { - return MapDataManager.shared.getUploadedFiles() - } + /// Get all uploaded files with their active states for UI display + func getUploadedFilesWithState() -> [MapDataMetadata] { + return MapDataManager.shared.getUploadedFiles() + } - /// Toggle the active state of an uploaded file - func toggleFileActive(_ fileId: UUID) { - MapDataManager.shared.toggleFileActive(fileId) - // Clear cache to force reload with new file states - clearCache() - } + /// Toggle the active state of an uploaded file + func toggleFileActive(_ fileId: UUID) { + MapDataManager.shared.toggleFileActive(fileId) + // Clear cache to force reload with new file states + clearCache() + } } diff --git a/Meshtastic/Helpers/MapDataManager.swift b/Meshtastic/Helpers/MapDataManager.swift index bad659af..42fec2e0 100644 --- a/Meshtastic/Helpers/MapDataManager.swift +++ b/Meshtastic/Helpers/MapDataManager.swift @@ -5,459 +5,459 @@ import Combine /// Manager for handling user-uploaded map data files class MapDataManager: ObservableObject { - static let shared = MapDataManager() - private init() {} - - // MARK: - Constants - private let maxFileSize: Int64 = 10 * 1024 * 1024 // 10MB - private let mapDataDirectory = "MapData" - private let userUploadedDirectory = "user_uploaded" - private let metadataFileName = "upload_history.json" - - // MARK: - Properties - @Published private var uploadedFiles: [MapDataMetadata] = [] - private var activeFeatureCollection: GeoJSONFeatureCollection? - - // MARK: - File Management - - /// Get the base URL for map data storage - private func getMapDataDirectory() -> URL? { - guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - Logger.services.error("πŸ—‚οΈ Could not access documents directory") - return nil - } - return documentsURL.appendingPathComponent(mapDataDirectory) - } - - /// Get the URL for user uploaded files - private func getUserUploadedDirectory() -> URL? { - guard let baseURL = getMapDataDirectory() else { return nil } - return baseURL.appendingPathComponent(userUploadedDirectory) - } - - /// Get the URL for metadata file - private func getMetadataFileURL() -> URL? { - guard let baseURL = getMapDataDirectory() else { return nil } - return baseURL.appendingPathComponent(metadataFileName) - } - - /// Create necessary directories - private func createDirectoriesIfNeeded() -> Bool { - guard let userDir = getUserUploadedDirectory() else { return false } - - do { - try FileManager.default.createDirectory(at: userDir, withIntermediateDirectories: true) - return true - } catch { - Logger.services.error("πŸ—‚οΈ Failed to create directories: \(error.localizedDescription, privacy: .public)") - return false - } - } - - // MARK: - File Upload & Processing - - /// Process and store an uploaded file - func processUploadedFile(from sourceURL: URL) async throws -> MapDataMetadata { - - // 1. Start accessing security-scoped resource - let isAccessing = sourceURL.startAccessingSecurityScopedResource() - defer { - if isAccessing { - sourceURL.stopAccessingSecurityScopedResource() - } - } - - // 2. Validate file - try validateFile(at: sourceURL) - - // 2. Create directories if needed - guard createDirectoriesIfNeeded() else { - throw MapDataError.directoryCreationFailed - } - - // 3. Generate destination filename - let timestamp = Date().timeIntervalSince1970 - let originalName = sourceURL.deletingPathExtension().lastPathComponent - let fileExtension = sourceURL.pathExtension - let newFilename = "\(originalName)_\(Int(timestamp)).\(fileExtension)" - - guard let destURL = getUserUploadedDirectory()?.appendingPathComponent(newFilename) else { - throw MapDataError.invalidDestination - } - - // 4. Copy file to app storage - try FileManager.default.copyItem(at: sourceURL, to: destURL) - - // 5. Process and validate content - let metadata = try await processFileContent(at: destURL, originalName: originalName) - - // 6. Save metadata and update UI on main thread - await MainActor.run { - uploadedFiles.append(metadata) - // Clear cached configuration to force reload - activeFeatureCollection = nil - } - try saveMetadata() - - return metadata - } - - /// Validate uploaded file - private func validateFile(at url: URL) throws { - let fileAttributes = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) - - // Check file size - guard let fileSize = fileAttributes.fileSize, fileSize <= maxFileSize else { - throw MapDataError.fileTooLarge - } - - // Check if it's a regular file - guard fileAttributes.isRegularFile == true else { - throw MapDataError.invalidFileType - } - - // Check file extension - let allowedExtensions = ["json", "geojson"] - let fileExtension = url.pathExtension.lowercased() - guard allowedExtensions.contains(fileExtension) else { - throw MapDataError.unsupportedFormat - } - } - - /// Process file content and extract metadata - private func processFileContent(at url: URL, originalName: String) async throws -> MapDataMetadata { - let fileAttributes = try url.resourceValues(forKeys: [.fileSizeKey, .creationDateKey]) - let fileSize = fileAttributes.fileSize ?? 0 - let uploadDate = fileAttributes.creationDate ?? Date() - - // Read and process file content on background queue - let (_, overlayCount) = try await withCheckedThrowingContinuation { continuation in - Task.detached { - do { - let data = try Data(contentsOf: url) - let overlayCount = try self.getOverlayCount(from: data) - continuation.resume(returning: (data, overlayCount)) - } catch { - continuation.resume(throwing: error) - } - } - } - - // TODO: Add proper GeoJSON schema validation here - // - Validate required properties (type, features) - // - Validate geometry types and coordinates - // - Validate feature structure - // - Consider using JSONSchema validation - // - Ensure coordinates are within valid ranges (lat: -90 to 90, lon: -180 to 180) - // - Validate that feature properties follow expected patterns - - // If this is the first file uploaded, make it active by default - let isFirstFile = uploadedFiles.isEmpty - - return MapDataMetadata( - filename: url.lastPathComponent, - originalName: originalName, - uploadDate: uploadDate, - fileSize: Int64(fileSize), - format: url.pathExtension.lowercased(), - license: nil, // Will be extracted from content if available - attribution: nil, // Will be extracted from content if available - overlayCount: overlayCount, - isActive: isFirstFile - ) - } - - /// Get overlay count from raw GeoJSON data - private func getOverlayCount(from data: Data) throws -> Int { - // Parse as raw GeoJSON FeatureCollection - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let features = json["features"] as? [[String: Any]] { - return features.count - } - throw MapDataError.invalidContent - } - - /// Load feature collection from a single file - private func loadFeatureCollectionFromFile(_ file: MapDataMetadata) throws -> GeoJSONFeatureCollection? { - guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(file.filename) else { - throw MapDataError.fileNotFound - } - - let data = try Data(contentsOf: fileURL) - return try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: data) - } - - // MARK: - Configuration Loading - - /// Load combined feature collection from specific files - func loadFeatureCollectionForFiles(_ files: [MapDataMetadata]) -> GeoJSONFeatureCollection? { - guard !files.isEmpty else { - return nil - } - - var allFeatures: [GeoJSONFeature] = [] - - for file in files { - do { - if let featureCollection = try loadFeatureCollectionFromFile(file) { - allFeatures.append(contentsOf: featureCollection.features) - } - } catch { - Logger.services.error("πŸ“ MapDataManager: Failed to load feature collection from \(file.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") - continue - } - } - - guard !allFeatures.isEmpty else { - return nil - } - return GeoJSONFeatureCollection(type: "FeatureCollection", features: allFeatures) - } - - /// Load and combine raw GeoJSON feature collections from all active files - func loadFeatureCollection() -> GeoJSONFeatureCollection? { - if let cached = activeFeatureCollection { - return cached - } - - // Find active user files - let activeFiles = uploadedFiles.filter { $0.isActive } - - guard !activeFiles.isEmpty else { - return nil - } - - var allFeatures: [GeoJSONFeature] = [] - - // Load features from all active files - for activeFile in activeFiles { - - guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(activeFile.filename) else { - Logger.services.error("πŸ“ MapDataManager: Could not construct file URL for: \(activeFile.filename, privacy: .public)") - continue - } - - // Check if file exists before trying to load it - if !FileManager.default.fileExists(atPath: fileURL.path) { - Logger.services.error("πŸ“ MapDataManager: Active file does not exist at path: \(fileURL.path, privacy: .public)") - - // Remove the missing file from our metadata - if let index = uploadedFiles.firstIndex(where: { $0.filename == activeFile.filename }) { - uploadedFiles.remove(at: index) - do { - try saveMetadata() - } catch { - Logger.services.error("πŸ“ MapDataManager: Failed to save cleaned metadata: \(error.localizedDescription, privacy: .public)") - } - } - continue - } - - do { - let data = try Data(contentsOf: fileURL) - let featureCollection = try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: data) - - allFeatures.append(contentsOf: featureCollection.features) - } catch { - Logger.services.error("πŸ“ MapDataManager: Failed to load feature collection from \(activeFile.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - - // Create combined feature collection - let combinedCollection = GeoJSONFeatureCollection( - type: "FeatureCollection", - features: allFeatures - ) - - activeFeatureCollection = combinedCollection - return combinedCollection - } - - // MARK: - File Management - - /// Get all uploaded files - func getUploadedFiles() -> [MapDataMetadata] { - return uploadedFiles - } - - /// Toggle the active state of an uploaded file - func toggleFileActive(_ fileId: UUID) { - if let index = uploadedFiles.firstIndex(where: { $0.id == fileId }) { - uploadedFiles[index].isActive.toggle() - - // Save metadata changes - do { - try saveMetadata() - // Clear cached data to force reload - activeFeatureCollection = nil - } catch { - Logger.services.error("🚨 MapDataManager: FAILED to save metadata after toggling file: \(error.localizedDescription)") - } - } - } - - /// Delete uploaded file - func deleteFile(_ metadata: MapDataMetadata) async throws { - - guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(metadata.filename) else { - Logger.services.error("πŸ—‘οΈ MapDataManager: Could not construct file URL for: \(metadata.filename, privacy: .public)") - throw MapDataError.fileNotFound - } - - // Check if file exists before trying to delete - if !FileManager.default.fileExists(atPath: fileURL.path) { - Logger.services.warning("πŸ—‘οΈ MapDataManager: File does not exist at path: \(fileURL.path, privacy: .public)") - } - - do { - try FileManager.default.removeItem(at: fileURL) - } catch { - Logger.services.error("πŸ—‘οΈ MapDataManager: Failed to remove file: \(error.localizedDescription, privacy: .public)") - throw error - } - - // Update UI-related properties on main thread - await MainActor.run { - if let index = uploadedFiles.firstIndex(where: { $0.filename == metadata.filename }) { - uploadedFiles.remove(at: index) - } else { - Logger.services.warning("πŸ—‘οΈ MapDataManager: File not found in uploadedFiles array") - } - } - - do { - try saveMetadata() - } catch { - Logger.services.error("πŸ—‘οΈ MapDataManager: Failed to save metadata: \(error.localizedDescription, privacy: .public)") - throw error - } - - // Clear cache if this was the active file - await MainActor.run { - if activeFeatureCollection != nil { - activeFeatureCollection = nil - } - } - - // Clear GeoJSON overlay manager cache - GeoJSONOverlayManager.shared.clearCache() - - // Notify UI components that a file was deleted - await MainActor.run { - NotificationCenter.default.post(name: Foundation.Notification.Name.mapDataFileDeleted, object: metadata.id) - } - - } - - // MARK: - Metadata Persistence - - /// Load metadata from disk - func loadMetadata() { - guard let metadataURL = getMetadataFileURL(), - let data = try? Data(contentsOf: metadataURL), - let files = try? JSONDecoder().decode([MapDataMetadata].self, from: data) else { - uploadedFiles = [] - return - } - - uploadedFiles = files - } - - /// Save metadata to disk - private func saveMetadata() throws { - guard let metadataURL = getMetadataFileURL() else { - throw MapDataError.invalidDestination - } - - let data = try JSONEncoder().encode(uploadedFiles) - try data.write(to: metadataURL) - } - - // MARK: - Initialization - - /// Initialize the manager - func initialize() { - loadMetadata() - } + static let shared = MapDataManager() + private init() {} + + // MARK: - Constants + private let maxFileSize: Int64 = 10 * 1024 * 1024 // 10MB + private let mapDataDirectory = "MapData" + private let userUploadedDirectory = "user_uploaded" + private let metadataFileName = "upload_history.json" + + // MARK: - Properties + @Published private var uploadedFiles: [MapDataMetadata] = [] + private var activeFeatureCollection: GeoJSONFeatureCollection? + + // MARK: - File Management + + /// Get the base URL for map data storage + private func getMapDataDirectory() -> URL? { + guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + Logger.services.error("πŸ—‚οΈ Could not access documents directory") + return nil + } + return documentsURL.appendingPathComponent(mapDataDirectory) + } + + /// Get the URL for user uploaded files + private func getUserUploadedDirectory() -> URL? { + guard let baseURL = getMapDataDirectory() else { return nil } + return baseURL.appendingPathComponent(userUploadedDirectory) + } + + /// Get the URL for metadata file + private func getMetadataFileURL() -> URL? { + guard let baseURL = getMapDataDirectory() else { return nil } + return baseURL.appendingPathComponent(metadataFileName) + } + + /// Create necessary directories + private func createDirectoriesIfNeeded() -> Bool { + guard let userDir = getUserUploadedDirectory() else { return false } + + do { + try FileManager.default.createDirectory(at: userDir, withIntermediateDirectories: true) + return true + } catch { + Logger.services.error("πŸ—‚οΈ Failed to create directories: \(error.localizedDescription, privacy: .public)") + return false + } + } + + // MARK: - File Upload & Processing + + /// Process and store an uploaded file + func processUploadedFile(from sourceURL: URL) async throws -> MapDataMetadata { + + // 1. Start accessing security-scoped resource + let isAccessing = sourceURL.startAccessingSecurityScopedResource() + defer { + if isAccessing { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + // 2. Validate file + try validateFile(at: sourceURL) + + // 2. Create directories if needed + guard createDirectoriesIfNeeded() else { + throw MapDataError.directoryCreationFailed + } + + // 3. Generate destination filename + let timestamp = Date().timeIntervalSince1970 + let originalName = sourceURL.deletingPathExtension().lastPathComponent + let fileExtension = sourceURL.pathExtension + let newFilename = "\(originalName)_\(Int(timestamp)).\(fileExtension)" + + guard let destURL = getUserUploadedDirectory()?.appendingPathComponent(newFilename) else { + throw MapDataError.invalidDestination + } + + // 4. Copy file to app storage + try FileManager.default.copyItem(at: sourceURL, to: destURL) + + // 5. Process and validate content + let metadata = try await processFileContent(at: destURL, originalName: originalName) + + // 6. Save metadata and update UI on main thread + await MainActor.run { + uploadedFiles.append(metadata) + // Clear cached configuration to force reload + activeFeatureCollection = nil + } + try saveMetadata() + + return metadata + } + + /// Validate uploaded file + private func validateFile(at url: URL) throws { + let fileAttributes = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + + // Check file size + guard let fileSize = fileAttributes.fileSize, fileSize <= maxFileSize else { + throw MapDataError.fileTooLarge + } + + // Check if it's a regular file + guard fileAttributes.isRegularFile == true else { + throw MapDataError.invalidFileType + } + + // Check file extension + let allowedExtensions = ["json", "geojson"] + let fileExtension = url.pathExtension.lowercased() + guard allowedExtensions.contains(fileExtension) else { + throw MapDataError.unsupportedFormat + } + } + + /// Process file content and extract metadata + private func processFileContent(at url: URL, originalName: String) async throws -> MapDataMetadata { + let fileAttributes = try url.resourceValues(forKeys: [.fileSizeKey, .creationDateKey]) + let fileSize = fileAttributes.fileSize ?? 0 + let uploadDate = fileAttributes.creationDate ?? Date() + + // Read and process file content on background queue + let (_, overlayCount) = try await withCheckedThrowingContinuation { continuation in + Task.detached { + do { + let data = try Data(contentsOf: url) + let overlayCount = try self.getOverlayCount(from: data) + continuation.resume(returning: (data, overlayCount)) + } catch { + continuation.resume(throwing: error) + } + } + } + + // TODO: Add proper GeoJSON schema validation here + // - Validate required properties (type, features) + // - Validate geometry types and coordinates + // - Validate feature structure + // - Consider using JSONSchema validation + // - Ensure coordinates are within valid ranges (lat: -90 to 90, lon: -180 to 180) + // - Validate that feature properties follow expected patterns + + // If this is the first file uploaded, make it active by default + let isFirstFile = uploadedFiles.isEmpty + + return MapDataMetadata( + filename: url.lastPathComponent, + originalName: originalName, + uploadDate: uploadDate, + fileSize: Int64(fileSize), + format: url.pathExtension.lowercased(), + license: nil, // Will be extracted from content if available + attribution: nil, // Will be extracted from content if available + overlayCount: overlayCount, + isActive: isFirstFile + ) + } + + /// Get overlay count from raw GeoJSON data + private func getOverlayCount(from data: Data) throws -> Int { + // Parse as raw GeoJSON FeatureCollection + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let features = json["features"] as? [[String: Any]] { + return features.count + } + throw MapDataError.invalidContent + } + + /// Load feature collection from a single file + private func loadFeatureCollectionFromFile(_ file: MapDataMetadata) throws -> GeoJSONFeatureCollection? { + guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(file.filename) else { + throw MapDataError.fileNotFound + } + + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: data) + } + + // MARK: - Configuration Loading + + /// Load combined feature collection from specific files + func loadFeatureCollectionForFiles(_ files: [MapDataMetadata]) -> GeoJSONFeatureCollection? { + guard !files.isEmpty else { + return nil + } + + var allFeatures: [GeoJSONFeature] = [] + + for file in files { + do { + if let featureCollection = try loadFeatureCollectionFromFile(file) { + allFeatures.append(contentsOf: featureCollection.features) + } + } catch { + Logger.services.error("πŸ“ MapDataManager: Failed to load feature collection from \(file.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") + continue + } + } + + guard !allFeatures.isEmpty else { + return nil + } + return GeoJSONFeatureCollection(type: "FeatureCollection", features: allFeatures) + } + + /// Load and combine raw GeoJSON feature collections from all active files + func loadFeatureCollection() -> GeoJSONFeatureCollection? { + if let cached = activeFeatureCollection { + return cached + } + + // Find active user files + let activeFiles = uploadedFiles.filter { $0.isActive } + + guard !activeFiles.isEmpty else { + return nil + } + + var allFeatures: [GeoJSONFeature] = [] + + // Load features from all active files + for activeFile in activeFiles { + + guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(activeFile.filename) else { + Logger.services.error("πŸ“ MapDataManager: Could not construct file URL for: \(activeFile.filename, privacy: .public)") + continue + } + + // Check if file exists before trying to load it + if !FileManager.default.fileExists(atPath: fileURL.path) { + Logger.services.error("πŸ“ MapDataManager: Active file does not exist at path: \(fileURL.path, privacy: .public)") + + // Remove the missing file from our metadata + if let index = uploadedFiles.firstIndex(where: { $0.filename == activeFile.filename }) { + uploadedFiles.remove(at: index) + do { + try saveMetadata() + } catch { + Logger.services.error("πŸ“ MapDataManager: Failed to save cleaned metadata: \(error.localizedDescription, privacy: .public)") + } + } + continue + } + + do { + let data = try Data(contentsOf: fileURL) + let featureCollection = try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: data) + + allFeatures.append(contentsOf: featureCollection.features) + } catch { + Logger.services.error("πŸ“ MapDataManager: Failed to load feature collection from \(activeFile.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + // Create combined feature collection + let combinedCollection = GeoJSONFeatureCollection( + type: "FeatureCollection", + features: allFeatures + ) + + activeFeatureCollection = combinedCollection + return combinedCollection + } + + // MARK: - File Management + + /// Get all uploaded files + func getUploadedFiles() -> [MapDataMetadata] { + return uploadedFiles + } + + /// Toggle the active state of an uploaded file + func toggleFileActive(_ fileId: UUID) { + if let index = uploadedFiles.firstIndex(where: { $0.id == fileId }) { + uploadedFiles[index].isActive.toggle() + + // Save metadata changes + do { + try saveMetadata() + // Clear cached data to force reload + activeFeatureCollection = nil + } catch { + Logger.services.error("🚨 MapDataManager: FAILED to save metadata after toggling file: \(error.localizedDescription)") + } + } + } + + /// Delete uploaded file + func deleteFile(_ metadata: MapDataMetadata) async throws { + + guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(metadata.filename) else { + Logger.services.error("πŸ—‘οΈ MapDataManager: Could not construct file URL for: \(metadata.filename, privacy: .public)") + throw MapDataError.fileNotFound + } + + // Check if file exists before trying to delete + if !FileManager.default.fileExists(atPath: fileURL.path) { + Logger.services.warning("πŸ—‘οΈ MapDataManager: File does not exist at path: \(fileURL.path, privacy: .public)") + } + + do { + try FileManager.default.removeItem(at: fileURL) + } catch { + Logger.services.error("πŸ—‘οΈ MapDataManager: Failed to remove file: \(error.localizedDescription, privacy: .public)") + throw error + } + + // Update UI-related properties on main thread + await MainActor.run { + if let index = uploadedFiles.firstIndex(where: { $0.filename == metadata.filename }) { + uploadedFiles.remove(at: index) + } else { + Logger.services.warning("πŸ—‘οΈ MapDataManager: File not found in uploadedFiles array") + } + } + + do { + try saveMetadata() + } catch { + Logger.services.error("πŸ—‘οΈ MapDataManager: Failed to save metadata: \(error.localizedDescription, privacy: .public)") + throw error + } + + // Clear cache if this was the active file + await MainActor.run { + if activeFeatureCollection != nil { + activeFeatureCollection = nil + } + } + + // Clear GeoJSON overlay manager cache + GeoJSONOverlayManager.shared.clearCache() + + // Notify UI components that a file was deleted + await MainActor.run { + NotificationCenter.default.post(name: Foundation.Notification.Name.mapDataFileDeleted, object: metadata.id) + } + + } + + // MARK: - Metadata Persistence + + /// Load metadata from disk + func loadMetadata() { + guard let metadataURL = getMetadataFileURL(), + let data = try? Data(contentsOf: metadataURL), + let files = try? JSONDecoder().decode([MapDataMetadata].self, from: data) else { + uploadedFiles = [] + return + } + + uploadedFiles = files + } + + /// Save metadata to disk + private func saveMetadata() throws { + guard let metadataURL = getMetadataFileURL() else { + throw MapDataError.invalidDestination + } + + let data = try JSONEncoder().encode(uploadedFiles) + try data.write(to: metadataURL) + } + + // MARK: - Initialization + + /// Initialize the manager + func initialize() { + loadMetadata() + } } // MARK: - Supporting Types /// Metadata for uploaded map data files struct MapDataMetadata: Codable, Identifiable { - let id: UUID - let filename: String - let originalName: String - let uploadDate: Date - let fileSize: Int64 - let format: String - let license: String? - let attribution: String? - let overlayCount: Int - var isActive: Bool + let id: UUID + let filename: String + let originalName: String + let uploadDate: Date + let fileSize: Int64 + let format: String + let license: String? + let attribution: String? + let overlayCount: Int + var isActive: Bool - init(filename: String, originalName: String, uploadDate: Date, fileSize: Int64, format: String, license: String?, attribution: String?, overlayCount: Int, isActive: Bool) { - self.id = UUID() - self.filename = filename - self.originalName = originalName - self.uploadDate = uploadDate - self.fileSize = fileSize - self.format = format - self.license = license - self.attribution = attribution - self.overlayCount = overlayCount - self.isActive = isActive - } + init(filename: String, originalName: String, uploadDate: Date, fileSize: Int64, format: String, license: String?, attribution: String?, overlayCount: Int, isActive: Bool) { + self.id = UUID() + self.filename = filename + self.originalName = originalName + self.uploadDate = uploadDate + self.fileSize = fileSize + self.format = format + self.license = license + self.attribution = attribution + self.overlayCount = overlayCount + self.isActive = isActive + } - var fileSizeString: String { - let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useKB, .useMB] - formatter.countStyle = .file - return formatter.string(fromByteCount: fileSize) - } + var fileSizeString: String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB] + formatter.countStyle = .file + return formatter.string(fromByteCount: fileSize) + } - var uploadDateString: String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter.string(from: uploadDate) - } + var uploadDateString: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: uploadDate) + } } /// Errors that can occur during map data operations enum MapDataError: Error, LocalizedError { - case fileTooLarge - case invalidFileType - case unsupportedFormat - case invalidContent - case directoryCreationFailed - case invalidDestination - case fileNotFound - case saveFailed + case fileTooLarge + case invalidFileType + case unsupportedFormat + case invalidContent + case directoryCreationFailed + case invalidDestination + case fileNotFound + case saveFailed - var errorDescription: String? { - switch self { - case .fileTooLarge: - return "File is too large. Maximum size is 10MB." - case .invalidFileType: - return "Invalid file type. Please select a regular file." - case .unsupportedFormat: - return "Unsupported file format. Supported formats: JSON, GeoJSON, KML, KMZ, GZ, ZLIB." - case .invalidContent: - return "Invalid file content. Please check the file format." - case .directoryCreationFailed: - return "Failed to create storage directory." - case .invalidDestination: - return "Invalid destination path." - case .fileNotFound: - return "File not found." - case .saveFailed: - return "Failed to save file." - } - } + var errorDescription: String? { + switch self { + case .fileTooLarge: + return "File is too large. Maximum size is 10MB." + case .invalidFileType: + return "Invalid file type. Please select a regular file." + case .unsupportedFormat: + return "Unsupported file format. Supported formats: JSON, GeoJSON, KML, KMZ, GZ, ZLIB." + case .invalidContent: + return "Invalid file content. Please check the file format." + case .directoryCreationFailed: + return "Failed to create storage directory." + case .invalidDestination: + return "Invalid destination path." + case .fileNotFound: + return "File not found." + case .saveFailed: + return "Failed to save file." + } + } } // MARK: - Notification Names extension Foundation.Notification.Name { - static let mapDataFileDeleted = Foundation.Notification.Name("mapDataFileDeleted") + static let mapDataFileDeleted = Foundation.Notification.Name("mapDataFileDeleted") } diff --git a/Meshtastic/Views/Helpers/ConnectedDevice.swift b/Meshtastic/Views/Helpers/ConnectedDevice.swift index a42d3877..98d2d50e 100644 --- a/Meshtastic/Views/Helpers/ConnectedDevice.swift +++ b/Meshtastic/Views/Helpers/ConnectedDevice.swift @@ -6,79 +6,79 @@ A view draws the indicator used in the upper right corner for views using BLE import SwiftUI struct ConnectedDevice: View { - var bluetoothOn: Bool - var deviceConnected: Bool - var name: String + var bluetoothOn: Bool + var deviceConnected: Bool + var name: String - var mqttProxyConnected: Bool = false - var mqttUplinkEnabled: Bool = false - var mqttDownlinkEnabled: Bool = false - var mqttTopic: String = "" - var phoneOnly: Bool = false + var mqttProxyConnected: Bool = false + var mqttUplinkEnabled: Bool = false + var mqttDownlinkEnabled: Bool = false + var mqttTopic: String = "" + var phoneOnly: Bool = false - var body: some View { - HStack { - if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly { - if bluetoothOn { - if deviceConnected { - // Create an HStack for connected state with proper accessibility - HStack { - if mqttUplinkEnabled || mqttDownlinkEnabled { - MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic) - .accessibilityHidden(true) - } - Image(systemName: "antenna.radiowaves.left.and.right.circle.fill") - .imageScale(.large) - .foregroundColor(.green) - .symbolRenderingMode(.hierarchical) - .accessibilityHidden(true) - Text(name.addingVariationSelectors) - .font(name.isEmoji() ? .title : .callout) - .foregroundColor(.gray) - .accessibilityHidden(true) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Connected to Bluetooth device".localized + ", " + name.formatNodeNameForVoiceOver()) - } else { - // Create a container for disconnected state - HStack { - Image(systemName: "antenna.radiowaves.left.and.right.slash") - .imageScale(.medium) - .foregroundColor(.red) - .symbolRenderingMode(.hierarchical) - .accessibilityHidden(true) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("No Bluetooth device connected".localized) - } - } else { - // Create a container for Bluetooth off state - HStack { - Text("Bluetooth is off".localized) - .font(.subheadline) - .foregroundColor(.red) - .accessibilityHidden(true) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Bluetooth is off".localized) - } - } - } - } + var body: some View { + HStack { + if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly { + if bluetoothOn { + if deviceConnected { + // Create an HStack for connected state with proper accessibility + HStack { + if mqttUplinkEnabled || mqttDownlinkEnabled { + MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic) + .accessibilityHidden(true) + } + Image(systemName: "antenna.radiowaves.left.and.right.circle.fill") + .imageScale(.large) + .foregroundColor(.green) + .symbolRenderingMode(.hierarchical) + .accessibilityHidden(true) + Text(name.addingVariationSelectors) + .font(name.isEmoji() ? .title : .callout) + .foregroundColor(.gray) + .accessibilityHidden(true) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Connected to Bluetooth device".localized + ", " + name.formatNodeNameForVoiceOver()) + } else { + // Create a container for disconnected state + HStack { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .imageScale(.medium) + .foregroundColor(.red) + .symbolRenderingMode(.hierarchical) + .accessibilityHidden(true) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("No Bluetooth device connected".localized) + } + } else { + // Create a container for Bluetooth off state + HStack { + Text("Bluetooth is off".localized) + .font(.subheadline) + .foregroundColor(.red) + .accessibilityHidden(true) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Bluetooth is off".localized) + } + } + } + } } struct ConnectedDevice_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .trailing) { - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: true, mqttTopic: "msh/US/2/e/#") - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: false, name: "MEMO", mqttProxyConnected: false) - }.previewLayout(.fixed(width: 150, height: 275)) - } + static var previews: some View { + VStack(alignment: .trailing) { + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: true) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: true, mqttTopic: "msh/US/2/e/#") + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true) + ConnectedDevice(bluetoothOn: true, deviceConnected: false, name: "MEMO", mqttProxyConnected: false) + }.previewLayout(.fixed(width: 150, height: 275)) + } } diff --git a/Meshtastic/Views/Settings/MapDataFiles.swift b/Meshtastic/Views/Settings/MapDataFiles.swift index 7651aa30..0dbc204d 100644 --- a/Meshtastic/Views/Settings/MapDataFiles.swift +++ b/Meshtastic/Views/Settings/MapDataFiles.swift @@ -3,252 +3,252 @@ import UniformTypeIdentifiers import OSLog struct MapDataFiles: View { - @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager - @ObservedObject private var mapDataManager = MapDataManager.shared + @Environment(\.managedObjectContext) var context + @EnvironmentObject var bleManager: BLEManager + @ObservedObject private var mapDataManager = MapDataManager.shared - @State private var isShowingFilePicker = false - @State private var isProcessing = false - @State private var processingProgress: Double = 0.0 - @State private var showError = false - @State private var errorMessage = "" - @State private var showSuccess = false - @State private var successMessage = "" + @State private var isShowingFilePicker = false + @State private var isProcessing = false + @State private var processingProgress: Double = 0.0 + @State private var showError = false + @State private var errorMessage = "" + @State private var showSuccess = false + @State private var successMessage = "" - var body: some View { - VStack(spacing: 20) { - // Header - VStack(alignment: .leading, spacing: 8) { - Text(NSLocalizedString("Upload Map Data", comment: "Title for map data upload screen")) - .font(.title2) - .fontWeight(.bold) + var body: some View { + VStack(spacing: 20) { + // Header + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("Upload Map Data", comment: "Title for map data upload screen")) + .font(.title2) + .fontWeight(.bold) - Text("Upload GeoJSON files to display custom map overlays. Files are stored locally and can be up to 10MB.") - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) + Text("Upload GeoJSON files to display custom map overlays. Files are stored locally and can be up to 10MB.") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) - // Upload Button - Button(action: { - isShowingFilePicker = true - }) { - HStack { - Image(systemName: "doc.badge.plus") - .font(.title2) - Text(NSLocalizedString("Select Map Data File", comment: "Button text for selecting map data file")) - .fontWeight(.medium) - } - .frame(maxWidth: .infinity) - .padding() - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(isProcessing) - .padding(.horizontal) + // Upload Button + Button(action: { + isShowingFilePicker = true + }) { + HStack { + Image(systemName: "doc.badge.plus") + .font(.title2) + Text(NSLocalizedString("Select Map Data File", comment: "Button text for selecting map data file")) + .fontWeight(.medium) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(isProcessing) + .padding(.horizontal) - // Processing Indicator - if isProcessing { - VStack(spacing: 12) { - ProgressView(value: processingProgress) - .progressViewStyle(LinearProgressViewStyle()) - .padding(.horizontal) + // Processing Indicator + if isProcessing { + VStack(spacing: 12) { + ProgressView(value: processingProgress) + .progressViewStyle(LinearProgressViewStyle()) + .padding(.horizontal) - Text("Processing file...") - .font(.caption) - .foregroundColor(.secondary) - } - } + Text("Processing file...") + .font(.caption) + .foregroundColor(.secondary) + } + } - // Current Files Section - VStack(alignment: .leading, spacing: 12) { - Text(NSLocalizedString("Uploaded Files", comment: "Section header for uploaded files")) - .font(.headline) - .padding(.horizontal) + // Current Files Section + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("Uploaded Files", comment: "Section header for uploaded files")) + .font(.headline) + .padding(.horizontal) - let uploadedFiles = mapDataManager.getUploadedFiles() + let uploadedFiles = mapDataManager.getUploadedFiles() - if uploadedFiles.isEmpty { - VStack(spacing: 8) { - Image(systemName: "doc.text") - .font(.title) - .foregroundColor(.secondary) - Text(NSLocalizedString("No files uploaded yet", comment: "Empty state text when no files are uploaded")) - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else { - ScrollView { - LazyVStack(spacing: 8) { - ForEach(uploadedFiles) { file in - MapDataFileRow(file: file) { - deleteFile(file) - } - } - } - .padding(.horizontal) - } - } - } + if uploadedFiles.isEmpty { + VStack(spacing: 8) { + Image(systemName: "doc.text") + .font(.title) + .foregroundColor(.secondary) + Text(NSLocalizedString("No files uploaded yet", comment: "Empty state text when no files are uploaded")) + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(uploadedFiles) { file in + MapDataFileRow(file: file) { + deleteFile(file) + } + } + } + .padding(.horizontal) + } + } + } - Spacer() - } - .navigationTitle("Map Data") - .navigationBarTitleDisplayMode(.inline) - .fileImporter( - isPresented: $isShowingFilePicker, - allowedContentTypes: [ - UTType.json, - UTType(filenameExtension: "geojson") ?? UTType.json - ], - allowsMultipleSelection: false - ) { result in - handleFileSelection(result) - } - .alert("Upload Error", isPresented: $showError) { - Button("OK") { } - } message: { - Text(errorMessage) - } - .alert("Upload Success", isPresented: $showSuccess) { - Button("OK") { } - } message: { - Text(successMessage) - } - .onAppear { - // Initialize map data manager if needed - mapDataManager.initialize() - } - } + Spacer() + } + .navigationTitle("Map Data") + .navigationBarTitleDisplayMode(.inline) + .fileImporter( + isPresented: $isShowingFilePicker, + allowedContentTypes: [ + UTType.json, + UTType(filenameExtension: "geojson") ?? UTType.json + ], + allowsMultipleSelection: false + ) { result in + handleFileSelection(result) + } + .alert("Upload Error", isPresented: $showError) { + Button("OK") { } + } message: { + Text(errorMessage) + } + .alert("Upload Success", isPresented: $showSuccess) { + Button("OK") { } + } message: { + Text(successMessage) + } + .onAppear { + // Initialize map data manager if needed + mapDataManager.initialize() + } + } - // MARK: - File Handling + // MARK: - File Handling - private func handleFileSelection(_ result: Result<[URL], Error>) { - do { - guard let selectedFile = try result.get().first else { return } + private func handleFileSelection(_ result: Result<[URL], Error>) { + do { + guard let selectedFile = try result.get().first else { return } - // Start processing - isProcessing = true - processingProgress = 0.0 + // Start processing + isProcessing = true + processingProgress = 0.0 - // Process file asynchronously - Task { - do { - // Simulate progress - await simulateProgress() + // Process file asynchronously + Task { + do { + // Simulate progress + await simulateProgress() - let metadata = try await mapDataManager.processUploadedFile(from: selectedFile) + let metadata = try await mapDataManager.processUploadedFile(from: selectedFile) - await MainActor.run { - isProcessing = false - processingProgress = 1.0 + await MainActor.run { + isProcessing = false + processingProgress = 1.0 - successMessage = "Successfully uploaded '\(metadata.originalName)' with \(metadata.overlayCount) overlays" - showSuccess = true - } - } catch { - await MainActor.run { - isProcessing = false - processingProgress = 0.0 + successMessage = "Successfully uploaded '\(metadata.originalName)' with \(metadata.overlayCount) overlays" + showSuccess = true + } + } catch { + await MainActor.run { + isProcessing = false + processingProgress = 0.0 - errorMessage = error.localizedDescription - showError = true - } - } - } - } catch { - errorMessage = "Failed to access file: \(error.localizedDescription)" - showError = true - } - } + errorMessage = error.localizedDescription + showError = true + } + } + } + } catch { + errorMessage = "Failed to access file: \(error.localizedDescription)" + showError = true + } + } - private func simulateProgress() async { - for i in 1...10 { - await MainActor.run { - processingProgress = Double(i) / 10.0 - } - try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds - } - } + private func simulateProgress() async { + for i in 1...10 { + await MainActor.run { + processingProgress = Double(i) / 10.0 + } + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + } + } - private func deleteFile(_ file: MapDataMetadata) { - Task { - do { - try await mapDataManager.deleteFile(file) - } catch { - await MainActor.run { - errorMessage = "Failed to delete file: \(error.localizedDescription)" - showError = true - } - } - } - } + private func deleteFile(_ file: MapDataMetadata) { + Task { + do { + try await mapDataManager.deleteFile(file) + } catch { + await MainActor.run { + errorMessage = "Failed to delete file: \(error.localizedDescription)" + showError = true + } + } + } + } } // MARK: - Supporting Views struct MapDataFileRow: View { - let file: MapDataMetadata - let onDelete: () -> Void + let file: MapDataMetadata + let onDelete: () -> Void - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(file.originalName) - .font(.headline) - .lineLimit(1) + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(file.originalName) + .font(.headline) + .lineLimit(1) - Spacer() - } + Spacer() + } - HStack { - Text(file.format.uppercased()) - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color.secondary.opacity(0.2)) - .cornerRadius(4) + HStack { + Text(file.format.uppercased()) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.2)) + .cornerRadius(4) - Text(file.fileSizeString) - .font(.caption) - .foregroundColor(.secondary) + Text(file.fileSizeString) + .font(.caption) + .foregroundColor(.secondary) - Text("β€’") - .font(.caption) - .foregroundColor(.secondary) + Text("β€’") + .font(.caption) + .foregroundColor(.secondary) - Text("\(file.overlayCount) overlays") - .font(.caption) - .foregroundColor(.secondary) + Text("\(file.overlayCount) overlays") + .font(.caption) + .foregroundColor(.secondary) - Spacer() + Spacer() - Text(file.uploadDateString) - .font(.caption) - .foregroundColor(.secondary) - } - } + Text(file.uploadDateString) + .font(.caption) + .foregroundColor(.secondary) + } + } - Button(action: onDelete) { - Image(systemName: "trash") - .foregroundColor(.red) - } - .buttonStyle(BorderlessButtonStyle()) - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(8) - .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) - } + Button(action: onDelete) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(BorderlessButtonStyle()) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(8) + .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) + } } #Preview { - NavigationView { - MapDataFiles() - } + NavigationView { + MapDataFiles() + } }