mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
commit
223097372d
24 changed files with 2324 additions and 263 deletions
|
|
@ -1347,6 +1347,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"%lld %@" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$lld %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld or less hops away" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
|
|
@ -14398,6 +14408,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Files Available" : {
|
||||
"comment" : "Data source label when files exist but none are active"
|
||||
},
|
||||
"Find a contact" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
|
|
@ -20177,6 +20190,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 +20326,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 +20400,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 +22914,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" : {
|
||||
|
|
@ -22890,6 +23074,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No files uploaded" : {
|
||||
|
||||
},
|
||||
"No Interface" : {
|
||||
"localizations" : {
|
||||
|
|
@ -22955,6 +23142,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"No map data files uploaded" : {
|
||||
"comment" : "Message when no files are uploaded"
|
||||
},
|
||||
"No PAX Counter Logs" : {
|
||||
"localizations" : {
|
||||
"it" : {
|
||||
|
|
@ -24074,6 +24264,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ok" : {
|
||||
|
||||
},
|
||||
"OK" : {
|
||||
"localizations" : {
|
||||
|
|
@ -27283,6 +27476,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" : {
|
||||
|
|
@ -31296,6 +31523,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select Map File" : {
|
||||
|
||||
},
|
||||
"Select Node" : {
|
||||
"localizations" : {
|
||||
|
|
@ -39311,6 +39541,184 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 Map Overlays" : {
|
||||
|
||||
},
|
||||
"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 Map Overlays" : {
|
||||
|
||||
},
|
||||
"Uptime" : {
|
||||
"localizations" : {
|
||||
|
|
@ -39746,6 +40154,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" : {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@
|
|||
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 */; };
|
||||
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 +330,10 @@
|
|||
25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = "<group>"; };
|
||||
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = "<group>"; };
|
||||
3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = "<group>"; };
|
||||
3D3417D32E2DC293006A988B /* MapDataFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataFiles.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>"; };
|
||||
|
|
@ -795,16 +803,16 @@
|
|||
DDD5BB152C28B1E4007E03CA /* AppData.swift */,
|
||||
DDD5BB082C285DDC007E03CA /* AppLog.swift */,
|
||||
DD4A911D2708C65400501B7E /* AppSettings.swift */,
|
||||
DDAB580C2B0DAA9E00147258 /* Routes.swift */,
|
||||
DDE9659B2B1C3B6A00531070 /* RouteRecorder.swift */,
|
||||
DDA0B6B1294CDC55001356EC /* Channels.swift */,
|
||||
DDD6EEAE29BC024700383354 /* Firmware.swift */,
|
||||
DD33DB612B3D27C7003E1EA0 /* FirmwareApi.swift */,
|
||||
DD1B8F3F2B35E2F10022AABC /* GPSStatus.swift */,
|
||||
DDAB580C2B0DAA9E00147258 /* Routes.swift */,
|
||||
DDE9659B2B1C3B6A00531070 /* RouteRecorder.swift */,
|
||||
DD86D40B287F401000BAEB7A /* SaveChannelQRCode.swift */,
|
||||
DD3501882852FC3B000FC853 /* Settings.swift */,
|
||||
DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */,
|
||||
DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */,
|
||||
DD1B8F3F2B35E2F10022AABC /* GPSStatus.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -952,6 +960,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
DDDC22362BA9232C002C44F1 /* MapContent */,
|
||||
3D3417D32E2DC293006A988B /* MapDataFiles.swift */,
|
||||
DD94B73F2ACCE3BE00DCD1D1 /* MapSettingsForm.swift */,
|
||||
DDB6CCFA2AAF805100945AF6 /* NodeMapSwiftUI.swift */,
|
||||
DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */,
|
||||
|
|
@ -1077,6 +1086,7 @@
|
|||
children = (
|
||||
233E99B42D849C2D00CC3A77 /* Compact Widgets */,
|
||||
DD6F65772C6EAB860053C113 /* Help */,
|
||||
DD5E523D298F5A7D00D21B61 /* Weather */,
|
||||
DD3CC6BD28E4CD9800FA9159 /* BatteryGauge.swift */,
|
||||
DD3CC24B2C498D6C001BD3A2 /* BatteryCompact.swift */,
|
||||
DD457187293C7E63000C49FB /* BLESignalStrengthIndicator.swift */,
|
||||
|
|
@ -1089,7 +1099,6 @@
|
|||
DDB75A1D2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift */,
|
||||
DD97E96528EFD9820056DDA4 /* MeshtasticLogo.swift */,
|
||||
DDF45C332BC1A48E005ED5F2 /* MQTTIcon.swift */,
|
||||
DD5E523D298F5A7D00D21B61 /* Weather */,
|
||||
DD6F65712C6AB8EC0053C113 /* SecureInput.swift */,
|
||||
8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */,
|
||||
237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */,
|
||||
|
|
@ -1100,6 +1109,8 @@
|
|||
DDC2E1A526CEB32B0042C5E4 /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3D3417D12E2DC260006A988B /* MapDataManager.swift */,
|
||||
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */,
|
||||
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */,
|
||||
DDD43FE12A78C86B0083A3E9 /* Mqtt */,
|
||||
DDAF8C5226EB1DF10058C060 /* BLEManager.swift */,
|
||||
|
|
@ -1109,6 +1120,7 @@
|
|||
DD964FBC296E6B01007C176F /* EmojiOnlyTextField.swift */,
|
||||
DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */,
|
||||
6D825E612C34786C008DBEE4 /* CommonRegex.swift */,
|
||||
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1439,6 +1451,7 @@
|
|||
DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */,
|
||||
DDDB444C29F8AAA600EE2349 /* Color.swift in Sources */,
|
||||
DDDFE73F2D0D48FF0044463C /* IgnoreNodeButton.swift in Sources */,
|
||||
3D3417D42E2DC293006A988B /* MapDataFiles.swift in Sources */,
|
||||
DDB8F4122A9EE5DD00230ECE /* UserList.swift in Sources */,
|
||||
DDB75A0F2A05920E006ED576 /* FileManager.swift in Sources */,
|
||||
DD3D17E02C3FB67200561584 /* LocalWeatherConditions.swift in Sources */,
|
||||
|
|
@ -1477,6 +1490,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 */,
|
||||
|
|
@ -1582,6 +1597,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 */,
|
||||
|
|
@ -1859,7 +1875,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.15;
|
||||
MARKETING_VERSION = 2.6.16;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -1892,7 +1908,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.15;
|
||||
MARKETING_VERSION = 2.6.16;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -1923,7 +1939,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.15;
|
||||
MARKETING_VERSION = 2.6.16;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1955,7 +1971,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.15;
|
||||
MARKETING_VERSION = 2.6.16;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// Color.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Garth Vander Houwen on 4/25/23.
|
||||
// Copyright Garth Vander Houwen 4/25/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
|
@ -10,6 +10,33 @@ import SwiftUI
|
|||
import UIKit
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
/// Returns a boolean for a SwiftUI Color to determine what color of text to use
|
||||
/// - Returns: true if the color is light
|
||||
func isLight() -> Bool {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ extension UserEntity {
|
|||
return "HELTECMESHNODET114"
|
||||
case "HELTECV3":
|
||||
return "HELTECV3"
|
||||
case "HELTECMESHPOCKET":
|
||||
return "HELTECMESHPOCKET"
|
||||
case "HELTECVISIONMASTERE213":
|
||||
return "HELTECVISIONMASTERE213"
|
||||
case "HELTECVISIONMASTERE290":
|
||||
|
|
@ -69,7 +71,7 @@ extension UserEntity {
|
|||
return "TLORAC6"
|
||||
case "TLORAT3S3EPAPER":
|
||||
return "TLORAT3S3EPAPER"
|
||||
case "TLORAT3S3V1":
|
||||
case "TLORAT3S3V1", "TLORAT3S3" :
|
||||
return "TLORAT3S3V1"
|
||||
case "TLORAV211P6":
|
||||
return "TLORAV211P6"
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@ extension Int {
|
|||
|
||||
extension UInt32 {
|
||||
func toHex() -> String {
|
||||
return String(format: "!%2X", self).lowercased()
|
||||
return String(format: "!%08X", self).lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
extension Int64 {
|
||||
func toHex() -> String {
|
||||
return String(format: "!%2X", self).lowercased()
|
||||
return String(format: "!%08X", self).lowercased()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ struct UserDefault<T: Decodable> {
|
|||
|
||||
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,
|
||||
|
|
@ -71,6 +71,7 @@ extension UserDefaults {
|
|||
case channelMessageNotifications
|
||||
case modemPreset
|
||||
case firmwareVersion
|
||||
case hardwareModel
|
||||
case environmentEnableWeatherKit
|
||||
case enableAdministration
|
||||
case mapReportingOptIn
|
||||
|
|
@ -149,6 +150,9 @@ extension UserDefaults {
|
|||
|
||||
@UserDefault(.firmwareVersion, defaultValue: "0.0.0")
|
||||
static var firmwareVersion: String
|
||||
|
||||
@UserDefault(.hardwareModel, defaultValue: "Unset")
|
||||
static var hardwareModel: String
|
||||
|
||||
@UserDefault(.environmentEnableWeatherKit, defaultValue: true)
|
||||
static var environmentEnableWeatherKit: Bool
|
||||
|
|
|
|||
|
|
@ -795,6 +795,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
|
|||
if nodeInfo.user != nil {
|
||||
connectedPeripheral.shortName = nodeInfo.user?.shortName ?? "?"
|
||||
connectedPeripheral.longName = nodeInfo.user?.longName ?? "Unknown".localized
|
||||
UserDefaults.hardwareModel = nodeInfo.user?.hwModel ?? "Unset".localized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
348
Meshtastic/Helpers/GeoJSONOverlayConfig.swift
Normal file
348
Meshtastic/Helpers/GeoJSONOverlayConfig.swift
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
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? {
|
||||
// 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() } ?? [:]
|
||||
]
|
||||
|
||||
do {
|
||||
// Serialize feature dictionary to JSON data
|
||||
let geojsonData = try JSONSerialization.data(withJSONObject: featureDict)
|
||||
do {
|
||||
// Decode GeoJSON data into MKGeoJSONFeature objects
|
||||
let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData)
|
||||
if let mkFeature = mkFeatures.first as? MKGeoJSONFeature {
|
||||
// Extract geometry and create overlay
|
||||
if let geometry = mkFeature.geometry.first as? MKOverlay {
|
||||
// Successfully created overlay
|
||||
return geometry
|
||||
} else {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to create overlay - Geometry is not an MKOverlay.")
|
||||
}
|
||||
} else {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON - No valid MKGeoJSONFeature found.")
|
||||
}
|
||||
} catch {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON data: \(error.localizedDescription)")
|
||||
}
|
||||
} catch {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to serialize feature dictionary to JSON: \(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
|
||||
}
|
||||
}
|
||||
148
Meshtastic/Helpers/GeoJSONOverlayManager.swift
Normal file
148
Meshtastic/Helpers/GeoJSONOverlayManager.swift
Normal file
|
|
@ -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<UUID>) -> [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()
|
||||
}
|
||||
}
|
||||
469
Meshtastic/Helpers/MapDataManager.swift
Normal file
469
Meshtastic/Helpers/MapDataManager.swift
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
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 (data, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate GeoJSON schema
|
||||
let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
|
||||
guard let geoJSON = jsonObject as? [String: Any] else {
|
||||
throw NSError(domain: "MapDataManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid GeoJSON format"])
|
||||
}
|
||||
|
||||
// Check required properties
|
||||
guard let type = geoJSON["type"] as? String, type == "FeatureCollection",
|
||||
let features = geoJSON["features"] as? [[String: Any]] else {
|
||||
throw NSError(domain: "MapDataManager", code: 2, userInfo: [NSLocalizedDescriptionKey: "GeoJSON must be a FeatureCollection with features"])
|
||||
}
|
||||
|
||||
// Validate each feature
|
||||
for feature in features {
|
||||
guard let geometry = feature["geometry"] as? [String: Any],
|
||||
let coordinates = geometry["coordinates"] as? [Any],
|
||||
let geometryType = geometry["type"] as? String else {
|
||||
throw NSError(domain: "MapDataManager", code: 3, userInfo: [NSLocalizedDescriptionKey: "Invalid feature structure in GeoJSON"])
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ struct MeshtasticAppleApp: App {
|
|||
let clientToken = "pub4427bea20dbdb08a6af68034de22cd3b"
|
||||
let environment = "testflight"
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
Datadog.initialize(
|
||||
with: Datadog.Configuration(
|
||||
clientToken: clientToken,
|
||||
|
|
@ -56,17 +57,26 @@ struct MeshtasticAppleApp: App {
|
|||
RUM.enable(
|
||||
with: RUM.Configuration(
|
||||
applicationID: appID,
|
||||
uiKitViewsPredicate: DefaultUIKitRUMViewsPredicate(),
|
||||
uiKitActionsPredicate: DefaultUIKitRUMActionsPredicate(),
|
||||
swiftUIViewsPredicate: DefaultSwiftUIRUMViewsPredicate(),
|
||||
swiftUIActionsPredicate: DefaultSwiftUIRUMActionsPredicate(isLegacyDetectionEnabled: true),
|
||||
trackBackgroundEvents: true
|
||||
)
|
||||
)
|
||||
let attributes: [String: Encodable] = [
|
||||
"firmware_version": UserDefaults.firmwareVersion,
|
||||
"hardware_model": UserDefaults.hardwareModel
|
||||
]
|
||||
RUMMonitor.shared().addAttributes(attributes)
|
||||
#endif
|
||||
self._appState = ObservedObject(wrappedValue: appState)
|
||||
// Initialize the BLEManager singleton with the necessary dependencies
|
||||
BLEManager.setup(appState: appState, context: persistenceController.container.viewContext)
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -1,84 +1,83 @@
|
|||
/*
|
||||
Abstract:
|
||||
A view draws the indicator used in the upper right corner for views using BLE
|
||||
*/
|
||||
Abstract:
|
||||
A view that 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 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 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 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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UUID>
|
||||
|
||||
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .easeIn)
|
||||
var positions: FetchedResults<PositionEntity>
|
||||
|
||||
|
|
@ -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..<allStyledFeatures.count, id: \.self) { index in
|
||||
let styledFeature = allStyledFeatures[index]
|
||||
let feature = styledFeature.feature
|
||||
let geometryType = feature.geometry.type
|
||||
|
||||
if geometryType == "Point" {
|
||||
if let coordinate = feature.geometry.coordinates.toCoordinate() {
|
||||
Annotation(feature.name, coordinate: coordinate) {
|
||||
Circle()
|
||||
.fill(styledFeature.fillColor)
|
||||
.stroke(styledFeature.strokeColor, style: styledFeature.strokeStyle)
|
||||
.frame(width: feature.markerRadius * 2, height: feature.markerRadius * 2)
|
||||
}
|
||||
.annotationTitles(.automatic)
|
||||
.annotationSubtitles(.hidden)
|
||||
}
|
||||
} else if geometryType == "LineString" {
|
||||
if let overlay = styledFeature.createOverlay() as? MKPolyline {
|
||||
MapPolyline(overlay)
|
||||
.stroke(styledFeature.strokeColor, style: styledFeature.strokeStyle)
|
||||
}
|
||||
} else if geometryType == "Polygon" {
|
||||
if let overlay = styledFeature.createOverlay() as? MKPolygon {
|
||||
MapPolygon(overlay)
|
||||
.foregroundStyle(styledFeature.fillColor)
|
||||
.stroke(styledFeature.strokeColor, style: styledFeature.strokeStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MapContentBuilder
|
||||
var body: some MapContent {
|
||||
meshMap
|
||||
|
|
|
|||
222
Meshtastic/Views/Nodes/Helpers/Map/MapDataFiles.swift
Normal file
222
Meshtastic/Views/Nodes/Helpers/Map/MapDataFiles.swift
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
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 {
|
||||
Form {
|
||||
Section(header: Text("Upload Map Overlays")) {
|
||||
Text("Upload GeoJSON files to display custom map overlays. Files are stored locally and can be up to 10MB.")
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
// Upload Button
|
||||
Button(action: {
|
||||
isShowingFilePicker = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "doc.badge.plus")
|
||||
.font(.title2)
|
||||
Text("Select Map 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(header: Text("Uploaded Map Overlays")) {
|
||||
|
||||
let uploadedFiles = mapDataManager.getUploadedFiles()
|
||||
|
||||
if uploadedFiles.isEmpty {
|
||||
ContentUnavailableView ("No files uploaded", systemImage: "doc.text")
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack() {
|
||||
ForEach(Array(uploadedFiles.enumerated()), id: \.offset) { index, file in
|
||||
MapDataFileRow(file: file, showDivider: index < uploadedFiles.count - 1) {
|
||||
deleteFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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".localized
|
||||
showSuccess = true
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
processingProgress = 0.0
|
||||
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Failed to access file: \(error.localizedDescription)".localized
|
||||
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)".localized
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Views
|
||||
|
||||
struct MapDataFileRow: View {
|
||||
let file: MapDataMetadata
|
||||
let showDivider: Bool
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack {
|
||||
HStack {
|
||||
Text(file.originalName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
Text(file.fileSizeString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.small)
|
||||
}
|
||||
HStack {
|
||||
Text(file.format.uppercased())
|
||||
.font(.caption2)
|
||||
.fixedSize()
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.secondary.opacity(0.2))
|
||||
.cornerRadius(4)
|
||||
|
||||
Text("\(file.overlayCount) \(file.overlayCount > 1 ? "features".localized : "feature".localized)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(file.uploadDate.formatted())
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if showDivider {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
MapDataFiles()
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
import OSLog
|
||||
|
||||
struct MapSettingsForm: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
|
@ -16,14 +17,17 @@ struct MapSettingsForm: View {
|
|||
@AppStorage("enableMapConvexHull") private var convexHull = false
|
||||
@AppStorage("enableMapWaypoints") private var enableMapWaypoints = true
|
||||
@AppStorage("enableMapShowFavorites") private var enableMapShowFavorites = false
|
||||
@AppStorage("mapOverlaysEnabled") private var mapOverlaysEnabled = false
|
||||
@ObservedObject private var mapDataManager = MapDataManager.shared
|
||||
@Binding var traffic: Bool
|
||||
@Binding var pointsOfInterest: Bool
|
||||
@Binding var mapLayer: MapLayer
|
||||
@AppStorage("meshMapDistance") private var meshMapDistance: Double = 800000
|
||||
@Binding var meshMap: Bool
|
||||
|
||||
@Binding var enabledOverlayConfigs: Set<UUID>
|
||||
|
||||
var body: some View {
|
||||
|
||||
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("Map Options")) {
|
||||
|
|
@ -115,25 +119,111 @@ struct MapSettingsForm: View {
|
|||
UserDefaults.enableMapPointsOfInterest = self.pointsOfInterest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Spacer()
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Close", systemImage: "xmark")
|
||||
|
||||
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")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
}
|
||||
}
|
||||
.tint(.accentColor)
|
||||
.disabled(!hasUserData && !mapOverlaysEnabled)
|
||||
|
||||
// Show individual file toggles when overlays are enabled
|
||||
if mapOverlaysEnabled && hasUserData {
|
||||
if !mapDataManager.getUploadedFiles().isEmpty {
|
||||
// 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) \(file.overlayCount > 1 ? "features".localized : "feature".localized)")
|
||||
.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)
|
||||
}
|
||||
NavigationLink(destination: MapDataFiles()) {
|
||||
Label {
|
||||
Text("Manage map data")
|
||||
} icon: {
|
||||
Image(systemName: "folder")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView ("No map data files uploaded", systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
} else if !hasUserData {
|
||||
// Upload prompt when no data available
|
||||
NavigationLink(destination: MapDataFiles()) {
|
||||
Label {
|
||||
Text("Upload map data to enable overlays")
|
||||
} icon: {
|
||||
Image(systemName: "arrow.up.doc")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
Spacer()
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Close", systemImage: "xmark")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding(.bottom)
|
||||
#endif
|
||||
}
|
||||
.presentationDetents([.medium, .large], selection: $currentDetent)
|
||||
.presentationContentInteraction(.scrolls)
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
|
||||
|
||||
.onAppear {
|
||||
// Initialize map data manager
|
||||
mapDataManager.initialize()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ struct NodeMapSwiftUI: View {
|
|||
@State var isShowingAltitude = false
|
||||
@State var isEditingSettings = false
|
||||
@State var isMeshMap = false
|
||||
@State var enabledOverlayConfigs: Set<UUID> = Set()
|
||||
|
||||
@State private var mapRegion = MKCoordinateRegion.init()
|
||||
|
||||
|
|
@ -40,165 +41,191 @@ struct NodeMapSwiftUI: View {
|
|||
private var waypoints: FetchedResults<WaypointEntity>
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<UUID> = []
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -339,7 +339,10 @@ struct NodeList: View {
|
|||
}
|
||||
}
|
||||
.onChange(of: selectedNode) {
|
||||
if selectedNode == nil {
|
||||
if selectedNode != nil {
|
||||
columnVisibility = .doubleColumn
|
||||
} else {
|
||||
columnVisibility = .all
|
||||
router.navigationState.nodeListSelectedNodeNum = nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 816595c8bbdfc3b4388e11348ccd043294d58705
|
||||
Subproject commit 27fac39141d99fe727a0a1824c5397409b1aea75
|
||||
Loading…
Add table
Add a link
Reference in a new issue