diff --git a/Localizable.xcstrings b/Localizable.xcstrings index e4d800ba..ad47c8c8 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1346,6 +1346,9 @@ } } } + }, + "%lld features" : { + }, "%lld or less hops away" : { "localizations" : { @@ -1381,6 +1384,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 +1582,9 @@ } } }, + "•" : { + "shouldTranslate" : false + }, "• %@" : { "shouldTranslate" : false }, @@ -14398,6 +14438,9 @@ } } }, + "Files Available" : { + "comment" : "Data source label when files exist but none are active" + }, "Find a contact" : { "localizations" : { "de" : { @@ -20177,6 +20220,44 @@ } } }, + "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" : "管理自訂地圖覆蓋層" + } + } + } + }, + "Manage map data" : { + "comment" : "Link to manage uploaded map data" + }, "Managed Device" : { "localizations" : { "it" : { @@ -20275,6 +20356,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" : { @@ -20315,6 +20430,70 @@ } } }, + "Map Overlays" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karten-Overlays" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Superpositions de cartes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שכבות מפה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sovrapposizioni mappa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マップオーバーレイ" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nakładki map" + } + }, + "se" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kartöverlägg" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Преклапања мапе" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "地图覆盖层" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "地圖覆蓋層" + } + } + } + }, "Map Publish Interval" : { "localizations" : { "it" : { @@ -22765,6 +22944,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" : { @@ -22891,6 +23105,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" : { @@ -22955,6 +23204,9 @@ } } }, + "No map data files uploaded" : { + "comment" : "Message when no files are uploaded" + }, "No PAX Counter Logs" : { "localizations" : { "it" : { @@ -27283,6 +27535,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" : { @@ -31297,6 +31583,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" : { @@ -39312,6 +39633,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" : { @@ -39746,6 +40274,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" : { @@ -39838,6 +40401,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" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 9182e24a..d20cd22e 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -56,6 +56,11 @@ 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; }; 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; }; 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; }; + 3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; }; + 3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */; }; + 3D3417CB2E29D3B0006A988B /* Color+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C92E29D3B0006A988B /* Color+Hex.swift */; }; + 3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D12E2DC260006A988B /* MapDataManager.swift */; }; + 3D3417D42E2DC293006A988B /* MapDataFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D32E2DC293006A988B /* MapDataFiles.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 */; }; @@ -326,6 +331,11 @@ 25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; + 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; }; + 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = ""; }; + 3D3417C92E29D3B0006A988B /* Color+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Hex.swift"; sourceTree = ""; }; + 3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = ""; }; + 3D3417D32E2DC293006A988B /* MapDataFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataFiles.swift; sourceTree = ""; }; 6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = ""; }; 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = ""; }; 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = ""; }; @@ -788,6 +798,7 @@ DD4A911C2708C57100501B7E /* Settings */ = { isa = PBXGroup; children = ( + 3D3417D32E2DC293006A988B /* MapDataManager.swift */, DDD5BB0E2C285F92007E03CA /* Logs */, DD93800C2BA74CE3008BEC06 /* Channels */, DD61937A2863876A00E59241 /* Config */, @@ -1100,6 +1111,8 @@ DDC2E1A526CEB32B0042C5E4 /* Helpers */ = { isa = PBXGroup; children = ( + 3D3417D12E2DC260006A988B /* MapDataManager.swift */, + 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */, BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */, DDD43FE12A78C86B0083A3E9 /* Mqtt */, DDAF8C5226EB1DF10058C060 /* BLEManager.swift */, @@ -1109,6 +1122,7 @@ DD964FBC296E6B01007C176F /* EmojiOnlyTextField.swift */, DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */, 6D825E612C34786C008DBEE4 /* CommonRegex.swift */, + 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */, ); path = Helpers; sourceTree = ""; @@ -1167,6 +1181,7 @@ DDDB443E29F79A9400EE2349 /* Extensions */ = { isa = PBXGroup; children = ( + 3D3417C92E29D3B0006A988B /* Color+Hex.swift */, DD007BB12AA59B9A00F5FA12 /* CoreData */, DDFFA7462B3A7F3C004730DB /* Bundle.swift */, DDDB444529F8A96500EE2349 /* Character.swift */, @@ -1439,6 +1454,7 @@ DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */, DDDB444C29F8AAA600EE2349 /* Color.swift in Sources */, DDDFE73F2D0D48FF0044463C /* IgnoreNodeButton.swift in Sources */, + 3D3417D42E2DC293006A988B /* MapDataManager.swift in Sources */, DDB8F4122A9EE5DD00230ECE /* UserList.swift in Sources */, DDB75A0F2A05920E006ED576 /* FileManager.swift in Sources */, DD3D17E02C3FB67200561584 /* LocalWeatherConditions.swift in Sources */, @@ -1477,6 +1493,8 @@ DDDB445429F8AD1600EE2349 /* Data.swift in Sources */, DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */, DDE5B4042B2279A700FCDD05 /* TraceRouteLog.swift in Sources */, + 3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */, + 3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */, 237B46962DC8F1C100B22D99 /* RateLimitedButton.swift in Sources */, DD6193792863875F00E59241 /* SerialConfig.swift in Sources */, DDDB263F2AABEE20003AFCB7 /* NodeList.swift in Sources */, @@ -1574,6 +1592,7 @@ DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */, DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */, DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */, + 3D3417CB2E29D3B0006A988B /* Color+Hex.swift in Sources */, DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */, BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */, DDB75A212A12B954006ED576 /* LoRaSignalStrength.swift in Sources */, @@ -1582,6 +1601,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 */, diff --git a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..3b339cec --- /dev/null +++ b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "2569905853aec088d5bac6b540eac77f78963f88b406e8dd95a88c40623cc8b4", + "pins" : [ + { + "identity" : "cocoamqtt", + "kind" : "remoteSourceControl", + "location" : "https://github.com/emqx/CocoaMQTT", + "state" : { + "revision" : "22b98acc75bdca77917a1093bd3e1b45ef6e9718", + "version" : "2.1.9" + } + }, + { + "identity" : "dd-sdk-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DataDog/dd-sdk-ios.git", + "state" : { + "revision" : "d0a42d8067665cb6ee86af51251ccc071f62bd54", + "version" : "2.29.0" + } + }, + { + "identity" : "mqttcocoaasyncsocket", + "kind" : "remoteSourceControl", + "location" : "https://github.com/leeway1208/MqttCocoaAsyncSocket", + "state" : { + "revision" : "ce3e18607fd01079495f86ff6195d8a3ca469f73", + "version" : "1.0.8" + } + }, + { + "identity" : "opentelemetry-swift-packages", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DataDog/opentelemetry-swift-packages.git", + "state" : { + "revision" : "4a7295600d4ebb9525a23c11586c5fdb74ae8b7e", + "version" : "1.13.1" + } + }, + { + "identity" : "plcrashreporter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/microsoft/plcrashreporter.git", + "state" : { + "revision" : "8c61e5e38e9f737dd68512ed1ea5ab081244ad65", + "version" : "1.12.0" + } + }, + { + "identity" : "starscream", + "kind" : "remoteSourceControl", + "location" : "https://github.com/daltoniam/Starscream.git", + "state" : { + "revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a", + "version" : "4.0.8" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "102a647b573f60f73afdce5613a51d71349fe507", + "version" : "1.30.0" + } + } + ], + "version" : 3 +} diff --git a/Meshtastic/Extensions/Color+Hex.swift b/Meshtastic/Extensions/Color+Hex.swift new file mode 100644 index 00000000..c1fe99a7 --- /dev/null +++ b/Meshtastic/Extensions/Color+Hex.swift @@ -0,0 +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) + + 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 + ) + } +} diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index 9f0bca41..0da552ed 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -19,11 +19,11 @@ struct UserDefault { var wrappedValue: T { get { - if defaultValue as? any RawRepresentable != nil { + if defaultValue is any RawRepresentable { let storedValue = UserDefaults.standard.object(forKey: key.rawValue) guard let storedValue, - let jsonString = (storedValue as? String != nil) ? "\"\(storedValue)\"" : "\(storedValue)", + let jsonString = (storedValue is String) ? "\"\(storedValue)\"" : "\(storedValue)", let data = jsonString.data(using: .utf8), let value = (try? JSONDecoder().decode(T.self, from: data)) else { return defaultValue } diff --git a/Meshtastic/Helpers/GeoJSONOverlayConfig.swift b/Meshtastic/Helpers/GeoJSONOverlayConfig.swift new file mode 100644 index 00000000..8b301a66 --- /dev/null +++ b/Meshtastic/Helpers/GeoJSONOverlayConfig.swift @@ -0,0 +1,343 @@ +import Foundation +import MapKit +import SwiftUI +import CoreLocation +import OSLog + +// MARK: - Raw GeoJSON Support Only + +struct GeoJSONFeatureCollection: Codable { + 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]? + + // 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 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 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 + } + + // MARK: - Point/Marker Styling + + 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 markerSymbol: String? { + if case .string(let value) = properties?["marker-symbol"] { + return value + } + return nil + } + + // MARK: - Stroke/Line Styling + + 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 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 + } + + // MARK: - Fill Styling + + 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 + } + + // MARK: - Computed Rendering Properties + + /// 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" + } + + /// 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 + + /// 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 + + let geojsonData = try JSONSerialization.data(withJSONObject: featureDict) + let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData) + + // 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 + } + + /// 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 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 +} + +// 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 + + 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")) + } + } + + 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) + } + } + + // 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 + + 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 + } + + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + return nil + } +} diff --git a/Meshtastic/Helpers/GeoJSONOverlayManager.swift b/Meshtastic/Helpers/GeoJSONOverlayManager.swift new file mode 100644 index 00000000..82801db0 --- /dev/null +++ b/Meshtastic/Helpers/GeoJSONOverlayManager.swift @@ -0,0 +1,148 @@ +import SwiftUI +import MapKit +import OSLog + +/// Manager for loading and managing raw GeoJSON feature collections with embedded styling +class GeoJSONOverlayManager { + static let shared = GeoJSONOverlayManager() + private init() {} + + private var featureCollection: GeoJSONFeatureCollection? + + /// 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 + } + + return nil + } + + /// Load styled features for specific enabled configs + func loadStyledFeaturesForConfigs(_ enabledConfigs: Set) -> [GeoJSONStyledFeature] { + // Get files that match the enabled configs + let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) } + + guard !enabledFiles.isEmpty else { + return [] + } + + // Load feature collection from enabled files only + guard let collection = MapDataManager.shared.loadFeatureCollectionForFiles(enabledFiles) else { + return [] + } + + var styledFeatures: [GeoJSONStyledFeature] = [] + + 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) + } + + return styledFeatures + } + + /// Load styled features for direct rendering (legacy method) + func loadStyledFeatures() -> [GeoJSONStyledFeature] { + guard let collection = loadFeatureCollection() else { + return [] + } + + var styledFeatures: [GeoJSONStyledFeature] = [] + + 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) + } + + return styledFeatures + } + + /// Get all features grouped by layer ID + func getFeaturesByLayer() -> [String: [GeoJSONFeature]] { + guard let collection = loadFeatureCollection() else { return [:] } + + var featuresByLayer: [String: [GeoJSONFeature]] = [:] + + for feature in collection.features { + let layerId = feature.layerId ?? "default" + if featuresByLayer[layerId] == nil { + featuresByLayer[layerId] = [] + } + featuresByLayer[layerId]?.append(feature) + } + + 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() + } + + /// 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 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") + } + } + + // MARK: - File-based Filtering + + /// 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() + } +} diff --git a/Meshtastic/Helpers/MapDataManager.swift b/Meshtastic/Helpers/MapDataManager.swift new file mode 100644 index 00000000..42fec2e0 --- /dev/null +++ b/Meshtastic/Helpers/MapDataManager.swift @@ -0,0 +1,463 @@ +import Foundation +import MapKit +import OSLog +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() + } +} + +// 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." + } + } +} + +// MARK: - Notification Names +extension Foundation.Notification.Name { + static let mapDataFileDeleted = Foundation.Notification.Name("mapDataFileDeleted") +} diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 24a577b2..4223bdf2 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -1027,7 +1027,7 @@ func textMessageAppPacket( subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", content: messageText!, target: "messages", - path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)", + path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)", messageId: newMessage.messageId, channel: newMessage.channel, userNum: Int64(packet.from), diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index a0552164..3c6cc130 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -18,6 +18,39 @@ gvh.MeshtasticApple.mbtiles + + CFBundleTypeName + GeoJSON Map Data + LSHandlerRank + Default + LSItemContentTypes + + public.json + gvh.MeshtasticApple.geojson + + + + CFBundleTypeName + KML Map Data + LSHandlerRank + Default + LSItemContentTypes + + public.xml + gvh.MeshtasticApple.kml + + + + CFBundleTypeName + KMZ Map Data + LSHandlerRank + Default + LSItemContentTypes + + public.zip-archive + gvh.MeshtasticApple.kmz + + CFBundleExecutable $(EXECUTABLE_NAME) @@ -138,6 +171,63 @@ + + UTTypeConformsTo + + public.json + + UTTypeDescription + GeoJSON Map Data + UTTypeIconFiles + + UTTypeIdentifier + gvh.MeshtasticApple.geojson + UTTypeTagSpecification + + public.filename-extension + + geojson + + + + + UTTypeConformsTo + + public.xml + + UTTypeDescription + KML Map Data + UTTypeIconFiles + + UTTypeIdentifier + gvh.MeshtasticApple.kml + UTTypeTagSpecification + + public.filename-extension + + kml + + + + + UTTypeConformsTo + + public.zip-archive + + UTTypeDescription + KMZ Map Data + UTTypeIconFiles + + UTTypeIdentifier + gvh.MeshtasticApple.kmz + UTTypeTagSpecification + + public.filename-extension + + kmz + + + com.apple.developer.carplay-communication diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index fece999f..4134f551 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -74,6 +74,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() diff --git a/Meshtastic/Views/Helpers/ConnectedDevice.swift b/Meshtastic/Views/Helpers/ConnectedDevice.swift index a42d3877..98d2d50e 100644 --- a/Meshtastic/Views/Helpers/ConnectedDevice.swift +++ b/Meshtastic/Views/Helpers/ConnectedDevice.swift @@ -6,79 +6,79 @@ A view draws the indicator used in the upper right corner for views using BLE import SwiftUI struct ConnectedDevice: View { - var bluetoothOn: Bool - var deviceConnected: Bool - var name: String + var bluetoothOn: Bool + var deviceConnected: Bool + var name: String - var mqttProxyConnected: Bool = false - var mqttUplinkEnabled: Bool = false - var mqttDownlinkEnabled: Bool = false - var mqttTopic: String = "" - var phoneOnly: Bool = false + var mqttProxyConnected: Bool = false + var mqttUplinkEnabled: Bool = false + var mqttDownlinkEnabled: Bool = false + var mqttTopic: String = "" + var phoneOnly: Bool = false - var body: some View { - HStack { - if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly { - if bluetoothOn { - if deviceConnected { - // Create an HStack for connected state with proper accessibility - HStack { - if mqttUplinkEnabled || mqttDownlinkEnabled { - MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic) - .accessibilityHidden(true) - } - Image(systemName: "antenna.radiowaves.left.and.right.circle.fill") - .imageScale(.large) - .foregroundColor(.green) - .symbolRenderingMode(.hierarchical) - .accessibilityHidden(true) - Text(name.addingVariationSelectors) - .font(name.isEmoji() ? .title : .callout) - .foregroundColor(.gray) - .accessibilityHidden(true) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Connected to Bluetooth device".localized + ", " + name.formatNodeNameForVoiceOver()) - } else { - // Create a container for disconnected state - HStack { - Image(systemName: "antenna.radiowaves.left.and.right.slash") - .imageScale(.medium) - .foregroundColor(.red) - .symbolRenderingMode(.hierarchical) - .accessibilityHidden(true) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("No Bluetooth device connected".localized) - } - } else { - // Create a container for Bluetooth off state - HStack { - Text("Bluetooth is off".localized) - .font(.subheadline) - .foregroundColor(.red) - .accessibilityHidden(true) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Bluetooth is off".localized) - } - } - } - } + var body: some View { + HStack { + if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly { + if bluetoothOn { + if deviceConnected { + // Create an HStack for connected state with proper accessibility + HStack { + if mqttUplinkEnabled || mqttDownlinkEnabled { + MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic) + .accessibilityHidden(true) + } + Image(systemName: "antenna.radiowaves.left.and.right.circle.fill") + .imageScale(.large) + .foregroundColor(.green) + .symbolRenderingMode(.hierarchical) + .accessibilityHidden(true) + Text(name.addingVariationSelectors) + .font(name.isEmoji() ? .title : .callout) + .foregroundColor(.gray) + .accessibilityHidden(true) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Connected to Bluetooth device".localized + ", " + name.formatNodeNameForVoiceOver()) + } else { + // Create a container for disconnected state + HStack { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .imageScale(.medium) + .foregroundColor(.red) + .symbolRenderingMode(.hierarchical) + .accessibilityHidden(true) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("No Bluetooth device connected".localized) + } + } else { + // Create a container for Bluetooth off state + HStack { + Text("Bluetooth is off".localized) + .font(.subheadline) + .foregroundColor(.red) + .accessibilityHidden(true) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Bluetooth is off".localized) + } + } + } + } } struct ConnectedDevice_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .trailing) { - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: true, mqttTopic: "msh/US/2/e/#") - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: false, name: "MEMO", mqttProxyConnected: false) - }.previewLayout(.fixed(width: 150, height: 275)) - } + static var previews: some View { + VStack(alignment: .trailing) { + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: true) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: true, mqttTopic: "msh/US/2/e/#") + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) + ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true) + ConnectedDevice(bluetoothOn: true, deviceConnected: false, name: "MEMO", mqttProxyConnected: false) + }.previewLayout(.fixed(width: 150, height: 275)) + } } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 832ae9c6..8af740a9 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -7,6 +7,13 @@ import SwiftUI import MapKit +import CoreLocation +import OSLog + +struct IdentifiableOverlay: Identifiable { + let overlay: MKOverlay + var id: ObjectIdentifier { ObjectIdentifier(overlay as AnyObject) } +} struct MeshMapContent: MapContent { @@ -24,6 +31,10 @@ struct MeshMapContent: MapContent { @AppStorage("enableMapWaypoints") private var showWaypoints = true @Binding var selectedWaypoint: WaypointEntity? + // Map overlays + @AppStorage("mapOverlaysEnabled") private var showMapOverlays = false + @Binding var enabledOverlayConfigs: Set + @FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .easeIn) var positions: FetchedResults @@ -222,11 +233,54 @@ struct MeshMapContent: MapContent { .foregroundStyle(.indigo.opacity(0.4)) } } + + /// GeoJSON Overlays with embedded styling + if showMapOverlays { + overlayContent + } + positionAnnotations routeAnnotations waypointAnnotations } + var overlayContent: some MapContent { + // Get all features but filter by enabled configs + let allStyledFeatures = GeoJSONOverlayManager.shared.loadStyledFeaturesForConfigs(enabledOverlayConfigs) + + return Group { + ForEach(0.. var body: some View { @@ -114,6 +118,124 @@ struct MapSettingsForm: View { self.pointsOfInterest.toggle() UserDefaults.enableMapPointsOfInterest = self.pointsOfInterest } + } + + Section(header: Text("Map Overlays")) { + let hasUserData = GeoJSONOverlayManager.shared.hasUserData() + + // Master toggle for map overlays + Toggle(isOn: $mapOverlaysEnabled) { + Label { + VStack(alignment: .leading) { + Text("Map Overlays") + Text(GeoJSONOverlayManager.shared.getActiveDataSource()) + .font(.caption) + .foregroundColor(.secondary) + } + } icon: { + Image(systemName: "map") + .foregroundColor(hasUserData ? .accentColor : .secondary) + } + } + .tint(.accentColor) + .disabled(!hasUserData) + + // Show individual file toggles when overlays are enabled + if mapOverlaysEnabled && hasUserData { + if !mapDataManager.getUploadedFiles().isEmpty { + // Data source info + 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) + + // Individual file toggles + ForEach(mapDataManager.getUploadedFiles()) { file in + Toggle(isOn: Binding( + get: { + return enabledOverlayConfigs.contains(file.id) + }, + set: { newValue in + if newValue { + enabledOverlayConfigs.insert(file.id) + } else { + enabledOverlayConfigs.remove(file.id) + } + } + )) { + Label { + VStack(alignment: .leading) { + Text(file.originalName) + .font(.subheadline) + HStack { + Text("\(file.overlayCount) features") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .file)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } icon: { + let isEnabled = enabledOverlayConfigs.contains(file.id) + Image(systemName: isEnabled ? "doc.fill" : "doc") + .foregroundColor(isEnabled ? .accentColor : .secondary) + } + } + .tint(.accentColor) + .padding(.leading, 35) + } + + // Manage data link + NavigationLink(destination: MapDataFiles()) { + HStack { + Image(systemName: "folder") + .foregroundColor(.accentColor) + Text(NSLocalizedString("Manage map data", comment: "Link to manage uploaded map data")) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(.leading, 35) + } else { + // No files uploaded + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + Text(NSLocalizedString("No map data files uploaded", comment: "Message when no files are uploaded")) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .padding(.leading, 35) + } + } else if !hasUserData { + // Upload prompt when no data available + NavigationLink(destination: MapDataFiles()) { + 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) + } } } @@ -134,6 +256,10 @@ Spacer() .presentationContentInteraction(.scrolls) .presentationDragIndicator(.visible) .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + .onAppear { + // Initialize map data manager + mapDataManager.initialize() + } } } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift index f5a2f84c..34054376 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift @@ -30,6 +30,7 @@ struct NodeMapSwiftUI: View { @State var isShowingAltitude = false @State var isEditingSettings = false @State var isMeshMap = false + @State var enabledOverlayConfigs: Set = Set() @State private var mapRegion = MKCoordinateRegion.init() @@ -40,165 +41,191 @@ struct NodeMapSwiftUI: View { private var waypoints: FetchedResults var body: some View { - var mostRecent = node.positions?.lastObject as? PositionEntity - if node.hasPositions { + mapWithNavigation + } else { + ContentUnavailableView("No Positions", systemImage: "mappin.slash") + } + } + + private var mapWithNavigation: some View { + ZStack { + MapReader { _ in + configuredMap + } + } + .navigationBarTitle(String((node.user?.shortName ?? "Unknown".localized) + (" \(node.positions?.count ?? 0) points")), displayMode: .inline) + .navigationBarItems(trailing: ZStack { - MapReader { _ in - Map(position: $position, bounds: MapCameraBounds(minimumDistance: 0, maximumDistance: .infinity), scope: mapScope) { - NodeMapContent(node: node) - } - .mapScope(mapScope) - .mapStyle(mapStyle) - .mapControls { - MapScaleView(scope: mapScope) - .mapControlVisibility(.visible) - if showUserLocation { - MapUserLocationButton(scope: mapScope) - .mapControlVisibility(.visible) - } - MapPitchToggle(scope: mapScope) - .mapControlVisibility(.visible) - MapCompass(scope: mapScope) - .mapControlVisibility(.visible) - } - .controlSize(.regular) - .overlay(alignment: .bottom) { - if scene != nil && isLookingAround { - LookAroundPreview(initialScene: scene) - .frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 20) - } - } - .overlay(alignment: .bottom) { - if !isLookingAround && isShowingAltitude { - PositionAltitudeChart(node: node) - .frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 20) - } - } - .sheet(isPresented: $isEditingSettings) { - MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap) - .onChange(of: (selectedMapLayer)) { _, newMapLayer in - switch selectedMapLayer { - case .standard: - UserDefaults.mapLayer = newMapLayer - mapStyle = MapStyle.standard(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .hybrid: - UserDefaults.mapLayer = newMapLayer - mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .satellite: - UserDefaults.mapLayer = newMapLayer - mapStyle = MapStyle.imagery(elevation: .flat) - case .offline: - return - } - } - } - .onChange(of: node) { - isLookingAround = false - isShowingAltitude = false - mostRecent = node.positions?.lastObject as? PositionEntity - if node.positions?.count ?? 0 > 1 { - position = .automatic - } else { - position = .camera(MapCamera(centerCoordinate: mostRecent!.coordinate, distance: distance, heading: 0, pitch: 0)) - } - if let mostRecent { - Task { - scene = try? await fetchScene(for: mostRecent.coordinate) - } - } - } - .onAppear { - UIApplication.shared.isIdleTimerDisabled = true - switch selectedMapLayer { - case .standard: - mapStyle = MapStyle.standard(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .hybrid: - mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .satellite: - mapStyle = MapStyle.imagery(elevation: .flat) - case .offline: - mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - } - mostRecent = node.positions?.lastObject as? PositionEntity - if node.positions?.count ?? 0 > 1 { - position = .automatic - } else { - if let mrCoord = mostRecent?.coordinate { - position = .camera(MapCamera(centerCoordinate: mrCoord, distance: distance, heading: 0, pitch: 0)) - } - } - if self.scene == nil { - Task { - scene = try? await fetchScene(for: mostRecent!.coordinate) - } - } - } - .safeAreaInset(edge: .bottom, alignment: .trailing) { - HStack { - Button(action: { - withAnimation { - isEditingSettings = !isEditingSettings - } - }) { - Image(systemName: isEditingSettings ? "info.circle.fill" : "info.circle") - .padding(.vertical, 5) - } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) - /// Look Around Button - if self.scene != nil { - Button(action: { - if isShowingAltitude { - isShowingAltitude = false - } - isLookingAround = !isLookingAround - }) { - Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars") - .padding(.vertical, 5) - } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) - } - /// Altitude Button - if node.positions?.count ?? 0 > 1 { - Button(action: { - if isLookingAround { - isLookingAround = false - } - isShowingAltitude = !isShowingAltitude - }) { - Image(systemName: isShowingAltitude ? "mountain.2.fill" : "mountain.2") - .padding(.vertical, 5) - } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) - } - } - .controlSize(.regular) - .padding(5) - } - .onDisappear { - UIApplication.shared.isIdleTimerDisabled = false - } - }} - .navigationBarTitle(String((node.user?.shortName ?? "Unknown".localized) + (" \(node.positions?.count ?? 0) points")), displayMode: .inline) - .navigationBarItems(trailing: - ZStack { ConnectedDevice( bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) - } else { - ContentUnavailableView("No Positions", systemImage: "mappin.slash") + } + + private var configuredMap: some View { + baseMap + .overlay(alignment: .bottom) { + lookAroundView + } + .overlay(alignment: .bottom) { + altitudeView + } + .sheet(isPresented: $isEditingSettings) { + MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs) + } + .onChange(of: selectedMapLayer) { _, newMapLayer in + updateMapStyle(for: newMapLayer) + } + .onChange(of: node) { + handleNodeChange() + } + .onAppear { + handleAppear() + } + .safeAreaInset(edge: .bottom, alignment: .trailing) { + controlButtons + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } + } + + private var baseMap: some View { + Map(position: $position, bounds: MapCameraBounds(minimumDistance: 0, maximumDistance: .infinity), scope: mapScope) { + NodeMapContent(node: node) + } + .mapScope(mapScope) + .mapStyle(mapStyle) + .mapControls { + MapScaleView(scope: mapScope) + .mapControlVisibility(.visible) + if showUserLocation { + MapUserLocationButton(scope: mapScope) + .mapControlVisibility(.visible) + } + MapPitchToggle(scope: mapScope) + .mapControlVisibility(.visible) + MapCompass(scope: mapScope) + .mapControlVisibility(.visible) + } + .controlSize(.regular) + } + + private var lookAroundView: some View { + Group { + if scene != nil && isLookingAround { + LookAroundPreview(initialScene: scene) + .frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 20) + } + } + } + + private var altitudeView: some View { + Group { + if !isLookingAround && isShowingAltitude { + PositionAltitudeChart(node: node) + .frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 20) + } + } + } + + private var controlButtons: some View { + HStack { + Button(action: { + withAnimation { + isEditingSettings = !isEditingSettings + } + }) { + Image(systemName: isEditingSettings ? "info.circle.fill" : "info.circle") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + + if scene != nil { + Button(action: { + if isShowingAltitude { + isShowingAltitude = false + } + isLookingAround = !isLookingAround + }) { + Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + } + + if node.positions?.count ?? 0 > 1 { + Button(action: { + if isLookingAround { + isLookingAround = false + } + isShowingAltitude = !isShowingAltitude + }) { + Image(systemName: isShowingAltitude ? "mountain.2.fill" : "mountain.2") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + } + } + .controlSize(.regular) + .padding(5) + } + + private func updateMapStyle(for layer: MapLayer) { + UserDefaults.mapLayer = layer + switch layer { + case .standard: + mapStyle = MapStyle.standard(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) + case .hybrid: + mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) + case .satellite: + mapStyle = MapStyle.imagery(elevation: .flat) + case .offline: + break + } + } + + private func handleNodeChange() { + isLookingAround = false + isShowingAltitude = false + let newMostRecent = node.positions?.lastObject as? PositionEntity + if node.positions?.count ?? 0 > 1 { + position = .automatic + } else if let mrCoord = newMostRecent?.coordinate { + position = .camera(MapCamera(centerCoordinate: mrCoord, distance: distance, heading: 0, pitch: 0)) + } + if let newMostRecent { + Task { + scene = try? await fetchScene(for: newMostRecent.coordinate) + } + } + } + + private func handleAppear() { + UIApplication.shared.isIdleTimerDisabled = true + updateMapStyle(for: selectedMapLayer) + let mostRecent = node.positions?.lastObject as? PositionEntity + if node.positions?.count ?? 0 > 1 { + position = .automatic + } else if let mrCoord = mostRecent?.coordinate { + position = .camera(MapCamera(centerCoordinate: mrCoord, distance: distance, heading: 0, pitch: 0)) + } + if scene == nil, let mrCoord = mostRecent?.coordinate { + Task { + scene = try? await fetchScene(for: mrCoord) + } } } /// Get the look around scene diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index a0650c41..c72bab05 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -26,6 +26,8 @@ struct MeshMap: View { @AppStorage("enableMapTraffic") private var showTraffic: Bool = false @AppStorage("enableMapPointsOfInterest") private var showPointsOfInterest: Bool = false @AppStorage("mapLayer") private var selectedMapLayer: MapLayer = .standard + /// Map overlay configs + @State private var enabledOverlayConfigs: Set = [] // Map Configuration @Namespace var mapScope @State var mapStyle: MapStyle = MapStyle.standard(elevation: .flat, emphasis: MapStyle.StandardEmphasis.muted, pointsOfInterest: .excludingAll, showsTraffic: false) @@ -70,7 +72,8 @@ struct MeshMap: View { showPointsOfInterest: $showPointsOfInterest, selectedMapLayer: $selectedMapLayer, selectedPosition: $selectedPosition, - selectedWaypoint: $selectedWaypoint + selectedWaypoint: $selectedWaypoint, + enabledOverlayConfigs: $enabledOverlayConfigs ) } .mapScope(mapScope) @@ -134,7 +137,7 @@ struct MeshMap: View { .padding() } .sheet(isPresented: $editingSettings) { - MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap) + MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs) } .onChange(of: router.navigationState) { guard case .map = router.navigationState.selectedTab else { return } @@ -196,6 +199,10 @@ struct MeshMap: View { .onFirstAppear { UIApplication.shared.isIdleTimerDisabled = true + // Initialize enabled overlay configs with all active files + let activeFiles = GeoJSONOverlayManager.shared.getUploadedFilesWithState().filter { $0.isActive } + enabledOverlayConfigs = Set(activeFiles.map { $0.id }) + // let wayPointEntity = getWaypoint(id: Int64(deepLinkManager.waypointId) ?? -1, context: context) // if wayPointEntity.id > 0 { // position = .camera(MapCamera(centerCoordinate: wayPointEntity.coordinate, distance: 1000, heading: 0, pitch: 60)) @@ -213,6 +220,11 @@ struct MeshMap: View { .onDisappear(perform: { UIApplication.shared.isIdleTimerDisabled = false }) + .onReceive(NotificationCenter.default.publisher(for: Foundation.Notification.Name.mapDataFileDeleted)) { notification in + if let deletedFileId = notification.object as? UUID { + enabledOverlayConfigs.remove(deletedFileId) + } + } } // moves the map to a new coordinate diff --git a/Meshtastic/Views/Settings/AppData.swift b/Meshtastic/Views/Settings/AppData.swift index e5ff252c..4706b62e 100644 --- a/Meshtastic/Views/Settings/AppData.swift +++ b/Meshtastic/Views/Settings/AppData.swift @@ -25,6 +25,33 @@ struct AppData: View { GPSStatus() } Divider() + + // Map Data Section + Section(header: Text("Map Data")) { + NavigationLink(destination: MapDataFiles()) { + 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 diff --git a/Meshtastic/Views/Settings/Config/SecurityConfig.swift b/Meshtastic/Views/Settings/Config/SecurityConfig.swift index 0153bb49..410c0b0d 100644 --- a/Meshtastic/Views/Settings/Config/SecurityConfig.swift +++ b/Meshtastic/Views/Settings/Config/SecurityConfig.swift @@ -230,7 +230,7 @@ struct SecurityConfig: View { name: "\(bleManager.connectedPeripheral?.shortName ?? "?")" ) }) - .onChange(of: node) { _, newNode in + .onChange(of: node) { _, _ in setSecurityValues() } .onChange(of: isManaged) { _, newIsManaged in diff --git a/Meshtastic/Views/Settings/MapDataFiles.swift b/Meshtastic/Views/Settings/MapDataFiles.swift new file mode 100644 index 00000000..0dbc204d --- /dev/null +++ b/Meshtastic/Views/Settings/MapDataFiles.swift @@ -0,0 +1,254 @@ +import SwiftUI +import UniformTypeIdentifiers +import OSLog + +struct MapDataFiles: View { + @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 = "" + + 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) { + 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() + } + } + + // 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 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 + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(file.originalName) + .font(.headline) + .lineLimit(1) + + Spacer() + } + + 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 { + MapDataFiles() + } +}