spaces -> tabs

This commit is contained in:
Jacob Powers 2025-07-23 20:58:46 +00:00
parent 11b95dca4d
commit 8d65aacbd8
6 changed files with 1132 additions and 1132 deletions

View file

@ -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
)
}
}

View file

@ -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
}
}

View file

@ -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<UUID>) -> [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<UUID>) -> [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()
}
}

View file

@ -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")
}

View file

@ -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))
}
}

View file

@ -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()
}
}