remove some burning man references

This commit is contained in:
Jacob Powers 2025-07-21 21:42:36 +00:00
parent 42cd598c56
commit 7e0d37d76f
11 changed files with 1369 additions and 61 deletions

View file

@ -1381,6 +1381,40 @@
}
}
},
"%lld overlays" : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld sovrapposizioni"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lldオーバーレイ"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld slojeva"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld覆盖层"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld覆蓋層"
}
}
}
},
"%lld Readings Total" : {
"localizations" : {
"it" : {
@ -1545,6 +1579,9 @@
}
}
},
"•" : {
"shouldTranslate" : false
},
"• %@" : {
"shouldTranslate" : false
},
@ -5814,40 +5851,6 @@
}
}
},
"Burning Man" : {
"localizations" : {
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "ברנינג מן"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "バーニングマン"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Бернинг Мен"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "火人节"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "火人節"
}
}
}
},
"Button GPIO" : {
"localizations" : {
"it" : {
@ -20364,6 +20367,41 @@
}
}
},
"Manage custom map overlays" : {
"comment" : "Subtitle for map data management",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Gestisci sovrapposizioni mappa personalizzate"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "カスタムマップオーバーレイを管理"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Upravljaj prilagođenim slojevima mape"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "管理自定义地图覆盖层"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "管理自訂地圖覆蓋層"
}
}
}
},
"Managed Device" : {
"localizations" : {
"it" : {
@ -20462,6 +20500,40 @@
}
}
},
"Map Data" : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dati Mappa"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "マップデータ"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Podaci Mape"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "地图数据"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "地圖資料"
}
}
}
},
"Map Options" : {
"localizations" : {
"de" : {
@ -23083,6 +23155,41 @@
}
}
},
"No Data" : {
"comment" : "Data source label when no files are available",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nessun Dato"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "データなし"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bez Podataka"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "无数据"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "無資料"
}
}
}
},
"No device connected" : {
"localizations" : {
"de" : {
@ -23209,6 +23316,41 @@
}
}
},
"No files uploaded yet" : {
"comment" : "Empty state text when no files are uploaded",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nessun file caricato ancora"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "まだファイルがアップロードされていません"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Još uvek nema otpremljenih datoteka"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "尚未上传文件"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "尚未上傳檔案"
}
}
}
},
"No Interface" : {
"localizations" : {
"de" : {
@ -27726,6 +27868,40 @@
}
}
},
"Processing file..." : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Elaborazione file..."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ファイル処理中..."
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Obrada datoteke..."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在处理文件..."
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在處理檔案..."
}
}
}
},
"Project information" : {
"localizations" : {
"it" : {
@ -31740,6 +31916,41 @@
}
}
},
"Select Map Data File" : {
"comment" : "Button text for selecting map data file",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Seleziona File Dati Mappa"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "マップデータファイルを選択"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Izaberi Datoteku Podataka Mape"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "选择地图数据文件"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "選擇地圖資料檔案"
}
}
}
},
"Select Node" : {
"localizations" : {
"de" : {
@ -39875,6 +40086,213 @@
}
}
},
"Upload Error" : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Errore Caricamento"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "アップロードエラー"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Greška Otpremanja"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传错误"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "上傳錯誤"
}
}
}
},
"Upload GeoJSON files to display custom map overlays. Files are stored locally and can be up to 10MB." : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Carica file GeoJSON per visualizzare sovrapposizioni mappa personalizzate. I file sono memorizzati localmente e possono essere fino a 10MB."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "カスタムマップオーバーレイを表示するためにGeoJSONファイルをアップロードしてください。ファイルはローカルに保存され、最大10MBまでです。"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Otpremi GeoJSON datoteke da prikažeš prilagođene slojeve mape. Datoteke se čuvaju lokalno i mogu biti do 10MB."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传GeoJSON文件以显示自定义地图覆盖层。文件本地存储最大10MB。"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "上傳GeoJSON檔案以顯示自訂地圖覆蓋層。檔案本機儲存最大10MB。"
}
}
}
},
"Upload Map Data" : {
"comment" : "Title for map data upload screen",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Carica Dati Mappa"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "マップデータをアップロード"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Otpremi Podatke Mape"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传地图数据"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "上傳地圖資料"
}
}
}
},
"Upload map data to enable overlays" : {
"comment" : "Prompt to upload map data when none is available",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Carica dati mappa per abilitare sovrapposizioni"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "オーバーレイを有効にするにはマップデータをアップロード"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Otpremi podatke mape da omogućiš slojeve"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传地图数据以启用覆盖层"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "上傳地圖資料以啟用覆蓋層"
}
}
}
},
"Upload Success" : {
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Caricamento Riuscito"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "アップロード成功"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Uspešno Otpremanje"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传成功"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "上傳成功"
}
}
}
},
"Uploaded Files" : {
"comment" : "Section header for uploaded files",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "File Caricati"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "アップロードされたファイル"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Otpremljene Datoteke"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已上传文件"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "已上傳檔案"
}
}
}
},
"Uptime" : {
"localizations" : {
"it" : {
@ -40309,6 +40727,41 @@
}
}
},
"User Uploaded" : {
"comment" : "Data source label for user uploaded files",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Caricato dall'Utente"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ユーザーがアップロード"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Otpremio Korisnik"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "用户上传"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用者上傳"
}
}
}
},
"Username" : {
"localizations" : {
"de" : {
@ -40401,6 +40854,41 @@
}
}
},
"Using %@ data" : {
"comment" : "Shows which data source is being used",
"localizations" : {
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Utilizzo dati %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@データを使用"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Koristi %@ podatke"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用%@数据"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用%@資料"
}
}
}
},
"Utilizes the network connection on your phone to connect to MQTT." : {
"localizations" : {
"it" : {
@ -42325,4 +42813,4 @@
}
},
"version" : "1.0"
}
}

View file

@ -60,7 +60,8 @@
3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */; };
3D3417CB2E29D3B0006A988B /* Color+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C92E29D3B0006A988B /* Color+Hex.swift */; };
3D3417CC2E29D3B0006A988B /* Data+Gzip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417CA2E29D3B0006A988B /* Data+Gzip.swift */; };
3D3417CE2E29D5D6006A988B /* BurningManGeoJSONMapConfig.json.zlib in Resources */ = {isa = PBXBuildFile; fileRef = 3D3417CD2E29D5D6006A988B /* BurningManGeoJSONMapConfig.json.zlib */; };
3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D12E2DC260006A988B /* MapDataManager.swift */; };
3D3417D42E2DC293006A988B /* MapDataUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D32E2DC293006A988B /* MapDataUpload.swift */; };
6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; };
6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */; };
6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */; };
@ -335,7 +336,8 @@
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = "<group>"; };
3D3417C92E29D3B0006A988B /* Color+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Hex.swift"; sourceTree = "<group>"; };
3D3417CA2E29D3B0006A988B /* Data+Gzip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Gzip.swift"; sourceTree = "<group>"; };
3D3417CD2E29D5D6006A988B /* BurningManGeoJSONMapConfig.json.zlib */ = {isa = PBXFileReference; lastKnownFileType = file; path = BurningManGeoJSONMapConfig.json.zlib; sourceTree = "<group>"; };
3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = "<group>"; };
3D3417D32E2DC293006A988B /* MapDataUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataUpload.swift; sourceTree = "<group>"; };
6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = "<group>"; };
6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = "<group>"; };
6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = "<group>"; };
@ -797,6 +799,7 @@
DD4A911C2708C57100501B7E /* Settings */ = {
isa = PBXGroup;
children = (
3D3417D32E2DC293006A988B /* MapDataUpload.swift */,
DDD5BB0E2C285F92007E03CA /* Logs */,
DD93800C2BA74CE3008BEC06 /* Channels */,
DD61937A2863876A00E59241 /* Config */,
@ -1057,7 +1060,6 @@
DDC2E18926CE24F70042C5E4 /* Resources */ = {
isa = PBXGroup;
children = (
3D3417CD2E29D5D6006A988B /* BurningManGeoJSONMapConfig.json.zlib */,
DDB75A192A05EB67006ED576 /* alpha.png */,
DDC2E15B26CE248F0042C5E4 /* Assets.xcassets */,
DD0E21002B8A6BC500F2D100 /* DeviceHardware.json */,
@ -1110,6 +1112,7 @@
DDC2E1A526CEB32B0042C5E4 /* Helpers */ = {
isa = PBXGroup;
children = (
3D3417D12E2DC260006A988B /* MapDataManager.swift */,
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */,
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */,
DDD43FE12A78C86B0083A3E9 /* Mqtt */,
@ -1377,7 +1380,6 @@
DDDE5A1329AFEAB900490C6C /* Assets.xcassets in Resources */,
DDB75A1A2A05EB67006ED576 /* alpha.png in Resources */,
DDC2E15C26CE248F0042C5E4 /* Assets.xcassets in Resources */,
3D3417CE2E29D5D6006A988B /* BurningManGeoJSONMapConfig.json.zlib in Resources */,
DD0E21012B8A6F1300F2D100 /* DeviceHardware.json in Resources */,
DDDBC87B2BC62E4E001E8DF7 /* Settings.bundle in Resources */,
);
@ -1454,6 +1456,7 @@
DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */,
DDDB444C29F8AAA600EE2349 /* Color.swift in Sources */,
DDDFE73F2D0D48FF0044463C /* IgnoreNodeButton.swift in Sources */,
3D3417D42E2DC293006A988B /* MapDataUpload.swift in Sources */,
DDB8F4122A9EE5DD00230ECE /* UserList.swift in Sources */,
DDB75A0F2A05920E006ED576 /* FileManager.swift in Sources */,
DD3D17E02C3FB67200561584 /* LocalWeatherConditions.swift in Sources */,
@ -1601,6 +1604,7 @@
251926922C3CB52300249DF5 /* DeleteNodeButton.swift in Sources */,
DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */,
DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */,
3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */,
D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */,
DDF82CBD2D5BC69200DC25EC /* NavigateToButton.swift in Sources */,
8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */,

View file

@ -9,26 +9,20 @@ class GeoJSONOverlayManager {
private var configuration: GeoJSONOverlayConfiguration?
private var overlays: [String: [MKOverlay]] = [:]
/// Load and decompress the consolidated configuration
/// Load user-uploaded configuration only
func loadConfiguration() -> GeoJSONOverlayConfiguration? {
if let cached = configuration {
return cached
}
guard let url = Bundle.main.url(forResource: "BurningManGeoJSONMapConfig", withExtension: "json.zlib") else {
return nil
// Load user-uploaded configuration
if let userConfig = MapDataManager.shared.loadUserConfiguration() {
configuration = userConfig
return userConfig
}
do {
let compressedData = try Data(contentsOf: url)
let decompressedData = try compressedData.zlibDecompressed()
let config = try JSONDecoder().decode(GeoJSONOverlayConfiguration.self, from: decompressedData)
configuration = config
return config
} catch {
return nil
}
// No configuration available
return nil
}
/// Load overlays for a specific overlay ID
@ -111,4 +105,18 @@ class GeoJSONOverlayManager {
overlays.removeAll()
configuration = nil
}
/// Check if user-uploaded data is available
func hasUserData() -> Bool {
return MapDataManager.shared.getUploadedFiles().contains { $0.isActive }
}
/// Get the active data source name
func getActiveDataSource() -> String {
if hasUserData() {
return NSLocalizedString("User Uploaded", comment: "Data source label for user uploaded files")
} else {
return NSLocalizedString("No Data", comment: "Data source label when no files are available")
}
}
}

View file

@ -0,0 +1,378 @@
import Foundation
import MapKit
import OSLog
/// Manager for handling user-uploaded map data files
class MapDataManager {
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
private var uploadedFiles: [MapDataMetadata] = []
private var activeConfiguration: GeoJSONOverlayConfiguration?
// 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 {
Logger.services.info("📁 Processing uploaded file: \(sourceURL.lastPathComponent, privacy: .public)")
// 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
uploadedFiles.append(metadata)
try saveMetadata()
// 7. Clear cached configuration to force reload
activeConfiguration = nil
Logger.services.info("📁 Successfully processed file: \(newFilename, privacy: .public)")
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", "kml", "kmz", "gz", "zlib"]
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 (processedData, overlayCount) = try await withCheckedThrowingContinuation { continuation in
Task.detached {
do {
let data = try Data(contentsOf: url)
let processedData = try self.processData(data, filename: url.lastPathComponent)
let overlayCount = try self.getOverlayCount(from: processedData)
continuation.resume(returning: (processedData, overlayCount))
} catch {
continuation.resume(throwing: error)
}
}
}
// 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
)
}
/// Process data (decompress if needed)
private func processData(_ data: Data, filename: String) throws -> Data {
let fileExtension = filename.components(separatedBy: ".").last?.lowercased() ?? ""
switch fileExtension {
case "gz", "zlib":
return try data.zlibDecompressed()
default:
return data
}
}
/// Get overlay count from processed data
private func getOverlayCount(from data: Data) throws -> Int {
do {
let config = try JSONDecoder().decode(GeoJSONOverlayConfiguration.self, from: data)
return config.overlays.count
} catch {
// Try parsing as raw GeoJSON
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let features = json["features"] as? [[String: Any]] {
return features.count
}
throw MapDataError.invalidContent
}
}
// MARK: - Configuration Loading
/// Load user configuration (priority over bundled)
func loadUserConfiguration() -> GeoJSONOverlayConfiguration? {
if let cached = activeConfiguration {
return cached
}
// Find active user files
let activeFiles = uploadedFiles.filter { $0.isActive }
guard let activeFile = activeFiles.first else {
return nil
}
guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(activeFile.filename) else {
return nil
}
do {
let data = try Data(contentsOf: fileURL)
let processedData = try processData(data, filename: activeFile.filename)
let config = try JSONDecoder().decode(GeoJSONOverlayConfiguration.self, from: processedData)
activeConfiguration = config
return config
} catch {
Logger.services.error("📁 Failed to load user configuration: \(error.localizedDescription, privacy: .public)")
return nil
}
}
// MARK: - File Management
/// Get all uploaded files
func getUploadedFiles() -> [MapDataMetadata] {
return uploadedFiles
}
/// Delete uploaded file
func deleteFile(_ metadata: MapDataMetadata) throws {
guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(metadata.filename) else {
throw MapDataError.fileNotFound
}
try FileManager.default.removeItem(at: fileURL)
if let index = uploadedFiles.firstIndex(where: { $0.filename == metadata.filename }) {
uploadedFiles.remove(at: index)
}
try saveMetadata()
// Clear cache if this was the active file
if activeConfiguration != nil {
activeConfiguration = nil
}
Logger.services.info("🗑️ Deleted file: \(metadata.filename, privacy: .public)")
}
/// Toggle file active status
func toggleFileActive(_ metadata: MapDataMetadata) throws {
if let index = uploadedFiles.firstIndex(where: { $0.filename == metadata.filename }) {
let newActiveState = !uploadedFiles[index].isActive
// If making this file active, deactivate all others (only one can be active)
if newActiveState {
for i in uploadedFiles.indices {
uploadedFiles[i].isActive = (i == index)
}
} else {
// Just deactivate this file
uploadedFiles[index].isActive = false
}
try saveMetadata()
// Clear cache to force reload
activeConfiguration = nil
}
}
// 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
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 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
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."
}
}
}

View file

@ -18,6 +18,39 @@
<string>gvh.MeshtasticApple.mbtiles</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>GeoJSON Map Data</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.json</string>
<string>gvh.MeshtasticApple.geojson</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>KML Map Data</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.xml</string>
<string>gvh.MeshtasticApple.kml</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>KMZ Map Data</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.zip-archive</string>
<string>gvh.MeshtasticApple.kmz</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
@ -138,6 +171,63 @@
</array>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.json</string>
</array>
<key>UTTypeDescription</key>
<string>GeoJSON Map Data</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>gvh.MeshtasticApple.geojson</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>geojson</string>
</array>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.xml</string>
</array>
<key>UTTypeDescription</key>
<string>KML Map Data</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>gvh.MeshtasticApple.kml</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>kml</string>
</array>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.zip-archive</string>
</array>
<key>UTTypeDescription</key>
<string>KMZ Map Data</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>gvh.MeshtasticApple.kmz</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>kmz</string>
</array>
</dict>
</dict>
</array>
<key>com.apple.developer.carplay-communication</key>
<true/>

View file

@ -67,6 +67,9 @@ struct MeshtasticAppleApp: App {
self.persistenceController = persistenceController
// Wire up router
self.appDelegate.router = appState.router
// Initialize map data manager
MapDataManager.shared.initialize()
#if DEBUG
// Show tips in development
try? Tips.resetDatastore()

View file

@ -29,8 +29,8 @@ struct MeshMapContent: MapContent {
@AppStorage("enableMapWaypoints") private var showWaypoints = true
@Binding var selectedWaypoint: WaypointEntity?
// Burning Man GeoJSON overlays
@AppStorage("burningManShowAll") private var showBurningMan = false
// Map overlays
@AppStorage("mapOverlaysEnabled") private var showMapOverlays = false
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .easeIn)
var positions: FetchedResults<PositionEntity>
@ -232,7 +232,7 @@ struct MeshMapContent: MapContent {
}
/// GeoJSON Overlays (Configuration-Driven)
if showBurningMan {
if showMapOverlays {
let overlayManager = GeoJSONOverlayManager.shared
let availableOverlays = overlayManager.getAvailableOverlayIds()

View file

@ -116,19 +116,56 @@ struct MapSettingsForm: View {
}
}
Section(header: Text("Map Overlays")) {
Section(header: Text("Map Overlays")) {
let hasUserData = GeoJSONOverlayManager.shared.hasUserData()
// Master toggle for map overlays
Toggle(isOn: Binding(
get: { UserDefaults.standard.bool(forKey: "burningManShowAll") },
set: { UserDefaults.standard.set($0, forKey: "burningManShowAll") }
get: { hasUserData && UserDefaults.standard.bool(forKey: "mapOverlaysEnabled") },
set: { UserDefaults.standard.set($0, forKey: "mapOverlaysEnabled") }
)) {
Label {
Text("Burning Man")
VStack(alignment: .leading) {
Text("Map Overlays")
Text(GeoJSONOverlayManager.shared.getActiveDataSource())
.font(.caption)
.foregroundColor(.secondary)
}
} icon: {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
Image(systemName: "map")
.foregroundColor(hasUserData ? .accentColor : .secondary)
}
}
.tint(.accentColor)
.disabled(!hasUserData)
// Show data source info or upload prompt
if hasUserData && UserDefaults.standard.bool(forKey: "mapOverlaysEnabled") {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.secondary)
Text(String(format: NSLocalizedString("Using %@ data", comment: "Shows which data source is being used"), GeoJSONOverlayManager.shared.getActiveDataSource()))
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
.padding(.leading, 35)
} else if !hasUserData {
NavigationLink(destination: MapDataUpload()) {
HStack {
Image(systemName: "arrow.up.doc")
.foregroundColor(.accentColor)
Text(NSLocalizedString("Upload map data to enable overlays", comment: "Prompt to upload map data when none is available"))
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(.leading, 35)
}
}
}

View file

@ -25,6 +25,33 @@ struct AppData: View {
GPSStatus()
}
Divider()
// Map Data Section
Section(header: Text("Map Data")) {
NavigationLink(destination: MapDataUpload()) {
HStack {
Image(systemName: "map")
.symbolRenderingMode(.hierarchical)
.font(idiom == .phone ? .callout : .title)
.frame(width: 35)
VStack(alignment: .leading) {
Text(NSLocalizedString("Upload Map Data", comment: "Title for map data upload screen"))
.font(.headline)
Text(NSLocalizedString("Manage custom map overlays", comment: "Subtitle for map data management"))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Divider()
}
List(files, id: \.self) { file in

View file

@ -0,0 +1,273 @@
import SwiftUI
import UniformTypeIdentifiers
import OSLog
struct MapDataUpload: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var bleManager: BLEManager
@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 = ""
private let mapDataManager = MapDataManager.shared
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)
// 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)
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)
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) {
toggleFileActive(file)
} onDelete: {
deleteFile(file)
}
}
}
.padding(.horizontal)
}
}
}
Spacer()
}
.navigationTitle("Map Data")
.navigationBarTitleDisplayMode(.inline)
.fileImporter(
isPresented: $isShowingFilePicker,
allowedContentTypes: [
UTType.json,
UTType(filenameExtension: "geojson") ?? UTType.json,
UTType(filenameExtension: "kml") ?? UTType.xml,
UTType(filenameExtension: "kmz") ?? UTType.zip,
UTType(filenameExtension: "gz") ?? UTType.data,
UTType(filenameExtension: "zlib") ?? UTType.data
],
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
private func handleFileSelection(_ result: Result<[URL], Error>) {
do {
guard let selectedFile = try result.get().first else { return }
// Start processing
isProcessing = true
processingProgress = 0.0
// Process file asynchronously
Task {
do {
// Simulate progress
await simulateProgress()
let metadata = try await mapDataManager.processUploadedFile(from: selectedFile)
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
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 toggleFileActive(_ file: MapDataMetadata) {
do {
try mapDataManager.toggleFileActive(file)
} catch {
errorMessage = "Failed to toggle file: \(error.localizedDescription)"
showError = true
}
}
private func deleteFile(_ file: MapDataMetadata) {
do {
try mapDataManager.deleteFile(file)
} catch {
errorMessage = "Failed to delete file: \(error.localizedDescription)"
showError = true
}
}
}
// MARK: - Supporting Views
struct MapDataFileRow: View {
let file: MapDataMetadata
let onToggle: () -> Void
let onDelete: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(file.originalName)
.font(.headline)
.lineLimit(1)
Spacer()
Toggle("", isOn: Binding(
get: { file.isActive },
set: { _ in onToggle() }
))
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
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("")
.font(.caption)
.foregroundColor(.secondary)
Text("\(file.overlayCount) overlays")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
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)
}
}
#Preview {
NavigationView {
MapDataUpload()
}
}