From a9c5665efd20372a813294c0ac47f13042b5f5a0 Mon Sep 17 00:00:00 2001 From: RZR-UA Date: Thu, 21 May 2026 13:38:04 +0200 Subject: [PATCH 01/47] Fix build for native getOpenFilesSnapshot() (LLM) --- Sources/Controllers/Map/Helpers/OAHeightDataLoader.mm | 10 +++++----- .../MissingMapsCalculator/MissingMapsCalculator.mm | 4 +++- Sources/Router/OARouteProvider.mm | 4 +++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Sources/Controllers/Map/Helpers/OAHeightDataLoader.mm b/Sources/Controllers/Map/Helpers/OAHeightDataLoader.mm index c68e581a0d..ea6c99291c 100644 --- a/Sources/Controllers/Map/Helpers/OAHeightDataLoader.mm +++ b/Sources/Controllers/Map/Helpers/OAHeightDataLoader.mm @@ -26,7 +26,7 @@ @implementation OAHeightDataLoader { std::map> _loadedSubregions; - std::map> _readers; + BinaryMapFiles _readers; int64_t _osmId; std::map > _results; @@ -46,11 +46,11 @@ - (instancetype)init initBinaryMapFile(resource->localPath.toStdString(), false, true); } - std::vector readers = getOpenMapFiles(); - for (const auto& r : readers) + const auto readers = getOpenFilesSnapshot(); + for (const auto& reader : readers) { std::vector subregions; - std::vector> routingIndexes = r->routingIndexes; + std::vector> routingIndexes = reader->routingIndexes; for (const auto& rInd : routingIndexes) { @@ -63,7 +63,7 @@ - (instancetype)init } } - _readers[r] = subregions; + _readers.push_back(reader); } } diff --git a/Sources/Helpers/MissingMapsCalculator/MissingMapsCalculator.mm b/Sources/Helpers/MissingMapsCalculator/MissingMapsCalculator.mm index 61be88f935..1d9c0c4c29 100644 --- a/Sources/Helpers/MissingMapsCalculator/MissingMapsCalculator.mm +++ b/Sources/Helpers/MissingMapsCalculator/MissingMapsCalculator.mm @@ -84,8 +84,10 @@ - (BOOL)checkIfThereAreMissingMaps:(std::shared_ptr)ctx string profile = profileToString(ctx->config->router->getProfile()); NSMutableDictionary *knownMaps = [NSMutableDictionary new]; - for (auto* file : getOpenMapFiles()) + const auto openFilesSnapshot = getOpenFilesSnapshot(); + for (const auto& fileRef : openFilesSnapshot) { + auto* file = fileRef.get(); NSString *regionName = [NSString stringWithCString:file->inputName.c_str() encoding:[NSString defaultCStringEncoding]]; NSString *downloadName = regionName.lastPathComponent; diff --git a/Sources/Router/OARouteProvider.mm b/Sources/Router/OARouteProvider.mm index 19e4dd7d3a..7b796cfd01 100644 --- a/Sources/Router/OARouteProvider.mm +++ b/Sources/Router/OARouteProvider.mm @@ -868,8 +868,10 @@ - (void)checkInitialized:(int)zoom leftX:(int)leftX rightX:(int)rightX bottomY:( } writeMapFilesCache(app.routingMapsCachePath.UTF8String); - for (const auto* file : getOpenMapFiles()) + const auto openFilesSnapshot = getOpenFilesSnapshot(); + for (const auto& fileRef : openFilesSnapshot) { + const auto* file = fileRef.get(); BOOL hasLocal = NO; for (const auto& resource : localResources) { From 82c277819c35df8ff1037d38e42daf83a0e01cbd Mon Sep 17 00:00:00 2001 From: dmpr0 Date: Mon, 15 Jun 2026 17:03:25 +0300 Subject: [PATCH 02/47] Rename icon --- .../Contents.json | 0 .../ic_custom_sort_rises-1.svg | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename Resources/Images.xcassets/Icons/Astronomy/{ic_custom_sort_rises-1.imageset => ic_custom_sort_sets.imageset}/Contents.json (100%) rename Resources/Images.xcassets/Icons/Astronomy/{ic_custom_sort_rises-1.imageset => ic_custom_sort_sets.imageset}/ic_custom_sort_rises-1.svg (100%) diff --git a/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_rises-1.imageset/Contents.json b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_sets.imageset/Contents.json similarity index 100% rename from Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_rises-1.imageset/Contents.json rename to Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_sets.imageset/Contents.json diff --git a/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_rises-1.imageset/ic_custom_sort_rises-1.svg b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_sets.imageset/ic_custom_sort_rises-1.svg similarity index 100% rename from Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_rises-1.imageset/ic_custom_sort_rises-1.svg rename to Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_sets.imageset/ic_custom_sort_rises-1.svg From 99bbd1981ec6599c013185122cf5030ed2c5c718 Mon Sep 17 00:00:00 2001 From: kensvin Date: Mon, 15 Jun 2026 05:03:11 +0200 Subject: [PATCH 03/47] Translated using Weblate (Indonesian) Currently translated at 93.1% (3754 of 4028 strings) --- Resources/Localizations/id.lproj/Localizable.strings | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Resources/Localizations/id.lproj/Localizable.strings b/Resources/Localizations/id.lproj/Localizable.strings index efa259a5aa..d41f5ac1e4 100644 --- a/Resources/Localizations/id.lproj/Localizable.strings +++ b/Resources/Localizations/id.lproj/Localizable.strings @@ -3853,3 +3853,9 @@ "liter_single" = "Liter"; "download_roads_only_maps" = "Peta jalan saja"; "obd_alt_battery_voltage_desc" = "Tampilkan tingkat voltase pada adapter OBD"; +"sort_lowest_first" = "Terendah dulu"; +"set_step_size" = "Atur ukuran langkah"; +"set_step_size_summary" = "Pilih ukuran langkah untuk mengelompokkan trek."; +"shared_string_step" = "Langkah"; +"shared_string_unlock" = "Buka"; +"shared_string_url" = "URL"; From 8854fe6d1cadeb06a1f25c89e51efbc0d7ef0413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C8=98tefan=20Zaharia?= Date: Mon, 15 Jun 2026 14:26:28 +0200 Subject: [PATCH 04/47] Translated using Weblate (Romanian) Currently translated at 91.6% (3691 of 4028 strings) --- Resources/Localizations/ro-RO.lproj/Localizable.strings | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Resources/Localizations/ro-RO.lproj/Localizable.strings b/Resources/Localizations/ro-RO.lproj/Localizable.strings index 49f823ff9b..0a53d50187 100644 --- a/Resources/Localizations/ro-RO.lproj/Localizable.strings +++ b/Resources/Localizations/ro-RO.lproj/Localizable.strings @@ -3783,3 +3783,6 @@ "rendering_attr_showSkiSlopes_description" = "Afișează pârtiile de schi simplificat"; "trip_recording_max_speed_widget_description" = "Afișează vitezi maximă a călătoriei înregistrată curentă. Atingeți pentru a comuta viteza maximă generală sau viteza maximă în timpul ultimei urcări sau coborâri."; "x" = "X (fostul Twitter)"; +"open_from_short" = "De la"; +"open_till_short" = "Până la"; +"shared_string_unlock" = "Deblocare"; From d09ab751ff521d7df23601773dfb144c80d5d51c Mon Sep 17 00:00:00 2001 From: Supaplex Date: Mon, 15 Jun 2026 12:10:45 +0200 Subject: [PATCH 05/47] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (4028 of 4028 strings) --- .../zh-Hant.lproj/Localizable.strings | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/Resources/Localizations/zh-Hant.lproj/Localizable.strings b/Resources/Localizations/zh-Hant.lproj/Localizable.strings index 5bf70b3e35..a4af5de23e 100644 --- a/Resources/Localizations/zh-Hant.lproj/Localizable.strings +++ b/Resources/Localizations/zh-Hant.lproj/Localizable.strings @@ -4043,3 +4043,63 @@ "add_palette" = "新增色盤"; "dismiss_changes_descr" = "所有變動都會被放棄。"; "gpx_logging_no_data" = "沒有資料"; +"gradient_no_data_point_summary" = "套用的顏色因為資料遺失或是未定議。"; +"gradient_input_value_too_low_warn" = "最少允許值為 %@。"; +"gradient_input_value_too_high_warn" = "最多允許值為 %@。"; +"unexpected_error_occurred_warn" = "這一值已經存在色盤了。"; +"relative_gradient_point_m_summary" = "有在這一軌跡找到色彩的最大值。"; +"relative_gradient_point_avg_summary" = "這一軌跡的平均值色彩。"; +"relative_gradient_point_min_summary" = "有在這一軌跡找到色彩的最小值。"; +"shared_string_minimum" = "最小值"; +"shared_string_percentage" = "百分比"; +"remove_step" = "移除階梯"; +"shared_string_value" = "值"; +"gradient_range_type_fixed_summary" = "顏色已經指定到特定絕對數值 (例如 50 公里/小時)。"; +"gradient_range_type_fixed" = "固定值"; +"gradient_range_type_relative_summary" = "色彩尺度自動對應軌跡的最小/最大範圍。"; +"gradient_range_type_relative" = "相關"; +"edit_palette" = "編輯色盤"; +"delete_palette" = "刪除色盤"; +"delete_colors_palette_dialog_summary" = "您確定要刪除 %@ 色盤嗎?"; +"folder_one_track" = "1 條軌跡"; +"delete_changes" = "刪除變動"; +"osm_edits_delete_item_confirmation" = "您確定要刪除 %ld 編輯嗎?"; +"osm_edits_delete_items_confirmation" = "您確定要刪除 %ld 編輯嗎?"; +"show_touches" = "顯示觸控"; +"ios_release_5_4" = "* 新增「地形陰影」視覺狀態\n* 完整重新設計「我的地點」使用者界面,新增收藏子目錄以及其他方便操作\n* 新增只有道路的地圖支援\n* 擴充新增所有興趣點圖示的支援來自訂個人檔案\n* 新增自訂單車道寬度來避免行駛窄小的單車道\n* 新增 OBD-II 轉接器的伏特小工具\n* 改進管理大量收藏數量的效能\n* 新增「暫停語言」選項在導航指引時暫停 podcast 或是有聲書的播放\n"; +"private_access_routing_req_short" = "您的目的地位於私人地方,您確定要行駛私人道路嗎?"; +"view_on_phone" = "在手機上檢視"; +"missing_maps_header" = "遺漏或是過時的地圖"; +"missing_maps_description" = "導航時需要的離線地圖遺漏或是過時,請上傳或是更新地圖。"; +"shared_string_allow" = "允許"; +"map_variant_duplicate_title" = "重覆的地圖"; +"gradient_input_value_duplicate_warn" = "色盤中此值已經存在了。"; +"gradient_input_invalid_value_warn" = "請輸入有效數字。"; +"value_cannot_be_edited_warn" = "無法編輯這一值。"; +"map_variant_has_standard_download_road_message" = "您已經有 %@ 的標準地品了。\n\n下載只有道路的地圖會造成重覆資料並且降低效能。"; +"map_variant_has_road_download_standard_message" = "您已經有 %@ 的只有道路地圖了。\n\n下載標準地圖會造成重覆資料並且降低效能。"; +"map_variant_replace_with_road_only" = "以只有道路地圖取代"; +"map_variant_replace_with_standard" = "以標準地圖取代"; +"organize_by" = "由…整理"; +"organize_by_summary" = "根據您選擇的參數如活動類型、速度或地點,自動將您的軌跡整合為不同群組。"; +"group_general" = "一般"; +"group_date_time" = "日期與時間"; +"group_altitude_elevation" = "海拔高度"; +"group_sensors" = "感測器"; +"shared_string_length" = "長度"; +"year_of_creation" = "創建年份"; +"month_year_creation" = "創建年月"; +"nearest_city" = "最近的城市"; +"organize_by_max_speed" = "最大速度"; +"avg_speed" = "平均速度"; +"organize_by_max_altitude" = "最高高度"; +"shared_string_avg_altitude" = "平均高度"; +"shared_string_uphill" = "上坡"; +"shared_string_downhill" = "下坡"; +"sort_highest_first" = "最高的最先"; +"sort_lowest_first" = "最低的最先"; +"set_step_size" = "設定階梯大小"; +"set_step_size_summary" = "選擇階梯大小來群組您的軌跡。"; +"shared_string_step" = "階梯"; +"shared_string_unlock" = "解鎖"; +"shared_string_url" = "連結"; From 5c91707c689c026286acba1c484b4b06b447b2fc Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Mon, 15 Jun 2026 19:42:13 +0200 Subject: [PATCH 06/47] [WIP] 1323 --- OsmAnd.xcodeproj/project.pbxproj | 16 +- .../en.lproj/Localizable.strings | 10 + .../Panels/OAMapPanelViewController.mm | 2 +- .../PlanRoute/PlanRouteButtonFactory.swift | 76 +++ .../PlanRouteContainerViewController.swift | 435 ++++++++++++++++++ .../PlanRoute/PlanRouteModels.swift | 177 +++++++ .../PlanRoute/PlanRouteStubDataProvider.swift | 58 +++ .../PlanRoute/PlanRouteToolbarsView.swift | 176 +++++++ .../PlanRoute/PlanRouteTopPartView.swift | 132 ++++++ .../Tabs/PlanRouteAnalyzeViewController.swift | 50 ++ .../Tabs/PlanRoutePoiViewController.swift | 50 ++ .../Tabs/PlanRouteRouteViewController.swift | 276 +++++++++++ ...lRoutePlanningBottomSheetViewController.mm | 15 +- .../PlanRoute/PlanRouteButtonFactory.swift | 76 +++ .../PlanRouteContainerViewController.swift | 435 ++++++++++++++++++ .../PlanRoute/PlanRouteModels.swift | 177 +++++++ .../PlanRoute/PlanRouteStubDataProvider.swift | 58 +++ .../PlanRoute/PlanRouteToolbarsView.swift | 176 +++++++ .../PlanRoute/PlanRouteTopPartView.swift | 132 ++++++ .../Tabs/PlanRouteAnalyzeViewController.swift | 50 ++ .../Tabs/PlanRoutePoiViewController.swift | 50 ++ .../Tabs/PlanRouteRouteViewController.swift | 276 +++++++++++ Sources/QuickAction/Actions/RouteAction.swift | 2 +- 23 files changed, 2888 insertions(+), 17 deletions(-) create mode 100644 Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift create mode 100644 Sources/Controllers/PlanRoute/PlanRouteContainerViewController.swift create mode 100644 Sources/Controllers/PlanRoute/PlanRouteModels.swift create mode 100644 Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift create mode 100644 Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift create mode 100644 Sources/Controllers/PlanRoute/PlanRouteTopPartView.swift create mode 100644 Sources/Controllers/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift create mode 100644 Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift create mode 100644 Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift create mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteButtonFactory.swift create mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteContainerViewController.swift create mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteModels.swift create mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteStubDataProvider.swift create mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteToolbarsView.swift create mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteTopPartView.swift create mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift create mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRoutePoiViewController.swift create mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteRouteViewController.swift diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index 37e215932f..b96a743e57 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -1630,13 +1630,13 @@ CE8A82A92FCFE11F00EADFD8 /* MapVariantReplacementManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8A82A82FCFE11F00EADFD8 /* MapVariantReplacementManager.swift */; }; D1A0B0012F50000100A0B001 /* OpeningHoursParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0AFFE2F50000100A0B001 /* OpeningHoursParserTest.swift */; }; D1A0B0032F50000100A0B001 /* OpeningHoursParserTestSupport.mm in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0002F50000100A0B001 /* OpeningHoursParserTestSupport.mm */; }; + D1A0B0112F50001100A0B001 /* URLExtractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */; }; + D1A0B0122F50001100A0B001 /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3216E9522BA097BD0087D0EF /* StringExtensions.swift */; }; D71B9A8C2FC95D8500FBB0F3 /* OrganizeTracksByViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71B9A8B2FC95D8500FBB0F3 /* OrganizeTracksByViewController.swift */; }; D71B9A8E2FC96D2D00FBB0F3 /* OrganizeByTypeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71B9A8D2FC96D2D00FBB0F3 /* OrganizeByTypeExtension.swift */; }; D71B9A902FC99D5D00FBB0F3 /* OrganizeByTypeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71B9A8F2FC99D5D00FBB0F3 /* OrganizeByTypeCell.swift */; }; D7B76D0D2FD16070004EE3E9 /* OrganizeByStepSizeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B76D0C2FD16070004EE3E9 /* OrganizeByStepSizeViewController.swift */; }; D7BF04782FD2DB4400BABB31 /* TracksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BF04772FD2DB4400BABB31 /* TracksViewController.swift */; }; - D1A0B0112F50001100A0B001 /* URLExtractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */; }; - D1A0B0122F50001100A0B001 /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3216E9522BA097BD0087D0EF /* StringExtensions.swift */; }; DA0132D42A1E0AB500920C14 /* WidgetsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0132D32A1E0AB500920C14 /* WidgetsListViewController.swift */; }; DA0132DD2A1E4A6300920C14 /* ic_custom20_screen_side_right@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = DA0132D52A1E4A6100920C14 /* ic_custom20_screen_side_right@2x.png */; }; DA0132DE2A1E4A6300920C14 /* ic_custom20_screen_side_top@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = DA0132D62A1E4A6100920C14 /* ic_custom20_screen_side_top@2x.png */; }; @@ -5572,12 +5572,12 @@ D1A0AFFE2F50000100A0B001 /* OpeningHoursParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpeningHoursParserTest.swift; sourceTree = ""; }; D1A0AFFF2F50000100A0B001 /* OpeningHoursParserTestSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OpeningHoursParserTestSupport.h; sourceTree = ""; }; D1A0B0002F50000100A0B001 /* OpeningHoursParserTestSupport.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OpeningHoursParserTestSupport.mm; sourceTree = ""; }; + D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtractionTests.swift; sourceTree = ""; }; D71B9A8B2FC95D8500FBB0F3 /* OrganizeTracksByViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeTracksByViewController.swift; sourceTree = ""; }; D71B9A8D2FC96D2D00FBB0F3 /* OrganizeByTypeExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeByTypeExtension.swift; sourceTree = ""; }; D71B9A8F2FC99D5D00FBB0F3 /* OrganizeByTypeCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeByTypeCell.swift; sourceTree = ""; }; D7B76D0C2FD16070004EE3E9 /* OrganizeByStepSizeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeByStepSizeViewController.swift; sourceTree = ""; }; D7BF04772FD2DB4400BABB31 /* TracksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracksViewController.swift; sourceTree = ""; }; - D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtractionTests.swift; sourceTree = ""; }; DA0132D32A1E0AB500920C14 /* WidgetsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetsListViewController.swift; sourceTree = ""; }; DA0132D52A1E4A6100920C14 /* ic_custom20_screen_side_right@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "ic_custom20_screen_side_right@2x.png"; path = "Resources/Icons/ic_custom20_screen_side_right@2x.png"; sourceTree = ""; }; DA0132D62A1E4A6100920C14 /* ic_custom20_screen_side_top@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "ic_custom20_screen_side_top@2x.png"; path = "Resources/Icons/ic_custom20_screen_side_top@2x.png"; sourceTree = ""; }; @@ -8230,6 +8230,10 @@ FAF7920E2A780F2500CE5F79 /* UIViewController+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Extension.swift"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + D73F22B02FE06D8A00D365FF /* PlanRoute */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = PlanRoute; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ BBA3AF66197FC4BB0039D991 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -11878,6 +11882,7 @@ DA5A7ADE26C563A100F274C7 /* Controllers */ = { isa = PBXGroup; children = ( + D73F22B02FE06D8A00D365FF /* PlanRoute */, 46624E6F2993B20100F425AE /* Base */, DA5A7F9526C563A500F274C7 /* BottomSheet */, DA5A7BAA26C563A100F274C7 /* Cells */, @@ -14983,6 +14988,9 @@ dependencies = ( FAB2B14D2C787E4400B59B7A /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + D73F22B02FE06D8A00D365FF /* PlanRoute */, + ); name = "OsmAnd Maps"; productName = OsmAnd; productReference = BBA3AFEE197FC4BB0039D991 /* OsmAnd Maps.app */; diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 5b9d7c69e3..28830c4347 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -1614,6 +1614,16 @@ // Route planning "plan_route" = "Plan a route"; +"shared_string_undo" = "Undo"; +"shared_string_redo" = "Redo"; +"save_as_copy" = "Save as copy"; +"distance_measurement_clear_route" = "Clear all points"; +"plan_route_save_as" = "Save as…"; +"plan_route_append_to_existing_track" = "Append to existing track"; +"plan_route_change_segment_order" = "Change segment order"; +"plan_route_view_directions" = "View directions"; +"plan_route_no_points_title" = "No points added yet"; +"plan_route_no_points_descr" = "Tap anywhere on the map or use search to add the first points of your route."; "coord_input_add_point" = "Add point"; "point_num" = "Point %d"; "points_count" = "Points:"; diff --git a/Sources/Controllers/Panels/OAMapPanelViewController.mm b/Sources/Controllers/Panels/OAMapPanelViewController.mm index ea08fe8ae8..9c36970076 100644 --- a/Sources/Controllers/Panels/OAMapPanelViewController.mm +++ b/Sources/Controllers/Panels/OAMapPanelViewController.mm @@ -407,7 +407,7 @@ - (void) showScrollableHudViewController:(OABaseScrollableHudViewController *)co self.sidePanelController.recognizesPanGesture = NO; - if ([controller isKindOfClass:OARoutePlanningHudViewController.class]) + if ([controller isKindOfClass:OARoutePlanningHudViewController.class] || [controller isKindOfClass:PlanRouteContainerViewController.class]) _activeTargetType = OATargetRoutePlanning; else if ([controller isKindOfClass:OARouteLineAppearanceHudViewController.class]) _activeTargetType = OATargetRouteLineAppearance; diff --git a/Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift b/Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift new file mode 100644 index 0000000000..1d9ef872f7 --- /dev/null +++ b/Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift @@ -0,0 +1,76 @@ +// +// PlanRouteButtonFactory.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum PlanRouteButtonFactory { + static let toolbarButtonSize: CGFloat = 48 + static let bottomButtonHeight: CGFloat = OAUtilities.isIPad() ? 48 : 44 + + static func iconButton(image: UIImage?, size: CGFloat = toolbarButtonSize) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.image = image + configuration.baseForegroundColor = .iconColorBlack + configuration.background.backgroundColor = .mapButtonBgColorDefault + configuration.background.cornerRadius = size / 2 + let button = UIButton(configuration: configuration) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: size), + button.heightAnchor.constraint(equalToConstant: size) + ]) + applyPressedState(to: button) + applyShadow(to: button) + return button + } + + static func labeledButton(title: String, image: UIImage?, height: CGFloat = bottomButtonHeight) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.title = title + configuration.image = image + configuration.imagePadding = 6 + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14) + configuration.baseForegroundColor = .textColorPrimary + configuration.background.backgroundColor = .mapButtonBgColorDefault + configuration.background.cornerRadius = height / 2 + let button = UIButton(configuration: configuration) + button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: height).isActive = true + applyPressedState(to: button) + applyShadow(to: button) + return button + } + + static func primaryButton(title: String, height: CGFloat = toolbarButtonSize) -> UIButton { + var configuration = UIButton.Configuration.filled() + configuration.title = title + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18) + configuration.baseForegroundColor = .white + configuration.baseBackgroundColor = .buttonBgColorPrimary + configuration.background.cornerRadius = height / 2 + let button = UIButton(configuration: configuration) + button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: height).isActive = true + return button + } + + private static func applyPressedState(to button: UIButton) { + button.configurationUpdateHandler = { button in + var updated = button.configuration + updated?.background.backgroundColor = button.isHighlighted ? .mapButtonBgColorTap : .mapButtonBgColorDefault + button.configuration = updated + } + } + + private static func applyShadow(to button: UIButton) { + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOpacity = 0.12 + button.layer.shadowOffset = CGSize(width: 0, height: 2) + button.layer.shadowRadius = 4 + } +} diff --git a/Sources/Controllers/PlanRoute/PlanRouteContainerViewController.swift b/Sources/Controllers/PlanRoute/PlanRouteContainerViewController.swift new file mode 100644 index 0000000000..c58bf51533 --- /dev/null +++ b/Sources/Controllers/PlanRoute/PlanRouteContainerViewController.swift @@ -0,0 +1,435 @@ +// +// PlanRouteContainerViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteContainerViewController: OABaseScrollableHudViewController { + private static let topPartHeight: CGFloat = 50 + private static let grabberAreaHeight: CGFloat = 16 + private static let segmentedControlHeight: CGFloat = 36 + private static let bottomToolbarAreaHeight: CGFloat = 60 + private static let horizontalInset: CGFloat = 16 + private static let cornerRadius: CGFloat = 16 + private static let fullScreenTopGap: CGFloat = 8 + private static let animationDuration: TimeInterval = 0.3 + + private let dataProvider: PlanRouteDataProvider + + private let sheetView = UIView() + private let grabberView = UIView() + private let topToolbar = PlanRouteTopToolbarView() + private let bottomToolbar = PlanRouteBottomToolbarView() + private let topPartView = PlanRouteTopPartView() + private let segmentControl = UISegmentedControl() + private let tabContainerView = UIView() + + private let tabs = PlanRouteTab.allCases + private var sheetState: EOADraggableMenuState = .expanded + private var selectedTab: PlanRouteTab = .default + private var tabViewControllers: [PlanRouteTab: UIViewController] = [:] + private var sheetHeightConstraint: NSLayoutConstraint? + private var panStartHeight: CGFloat = 0 + private weak var currentTabViewController: UIViewController? + + init(dataProvider: PlanRouteDataProvider) { + self.dataProvider = dataProvider + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc static func showNewRoute() { + let provider = PlanRouteStubDataProvider(mode: .newRoute) + let controller = PlanRouteContainerViewController(dataProvider: provider) + OARootViewController.instance().mapPanel?.showScrollableHudViewController(controller) + } + + override func loadView() { + let root = OAUserInteractionPassThroughView() + root.isScreenClickable = true + view = root + } + + override func viewDidLoad() { + setupSheet() + setupTopPart() + setupBottomToolbar() + setupContent() + setupTopToolbar() + selectTab(.default) + reloadData() + } + + override func viewWillAppear(_ animated: Bool) { + navigationController?.setNavigationBarHidden(true, animated: false) + applyHeight(for: sheetState) + tabContainerView.alpha = isContentVisible(in: sheetState) ? 1 : 0 + view.layoutIfNeeded() + if animated { + sheetView.transform = CGAffineTransform(translationX: 0, y: height(for: sheetState)) + UIView.animate(withDuration: Self.animationDuration) { [weak self] in + self?.sheetView.transform = .identity + } + } + refreshMapControls() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + coordinator.animate { [weak self] _ in + guard let self else { return } + applyHeight(for: sheetState) + refreshMapControls() + } + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + OAAppSettings.sharedManager().nightMode ? .lightContent : .default + } + + override func getViewHeight() -> CGFloat { + mapControlsReservedHeight(for: sheetState) + } + + override func getViewHeight(_ state: EOADraggableMenuState) -> CGFloat { + mapControlsReservedHeight(for: state) + } + + override func getNavbarHeight() -> CGFloat { + OAUtilities.getStatusBarHeight() + PlanRouteTopToolbarView.contentHeight + } + + override func getToolbarHeight() -> CGFloat { + Self.bottomToolbarAreaHeight + } + + override func getLandscapeViewWidth() -> CGFloat { + view.bounds.width + } + + override func hide() { + hide(true, duration: Self.animationDuration, onComplete: nil) + } + + override func forceHide() { + hide(false, duration: 0, onComplete: nil) + } + + override func hide(_ animated: Bool, duration: TimeInterval, onComplete: (() -> Void)!) { + let dismiss: () -> Void = { [weak self] in + OARootViewController.instance().mapPanel?.hideScrollableHudViewController() + self?.removeFromParent() + self?.view.removeFromSuperview() + onComplete?() + } + guard animated else { + dismiss() + return + } + UIView.animate(withDuration: duration, animations: { [weak self] in + guard let self else { return } + sheetView.transform = CGAffineTransform(translationX: 0, y: height(for: sheetState)) + }, completion: { _ in dismiss() }) + } + + func reloadData() { + topPartView.configure(with: dataProvider.routeInfo) + currentTabViewController.flatMap { $0 as? PlanRouteTabContent }?.reloadData() + } + + private func setupSheet() { + sheetView.backgroundColor = .groupBg + sheetView.layer.cornerRadius = Self.cornerRadius + sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + sheetView.clipsToBounds = true + sheetView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(sheetView) + let heightConstraint = sheetView.heightAnchor.constraint(equalToConstant: height(for: sheetState)) + sheetHeightConstraint = heightConstraint + NSLayoutConstraint.activate([ + sheetView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + sheetView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + sheetView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + heightConstraint + ]) + + grabberView.backgroundColor = .iconColorTertiary + grabberView.layer.cornerRadius = 2.5 + grabberView.translatesAutoresizingMaskIntoConstraints = false + sheetView.addSubview(grabberView) + NSLayoutConstraint.activate([ + grabberView.topAnchor.constraint(equalTo: sheetView.topAnchor, constant: 8), + grabberView.centerXAnchor.constraint(equalTo: sheetView.centerXAnchor), + grabberView.widthAnchor.constraint(equalToConstant: 36), + grabberView.heightAnchor.constraint(equalToConstant: 5) + ]) + + let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + sheetView.addGestureRecognizer(panRecognizer) + } + + private func setupTopPart() { + topPartView.onTap = { [weak self] in + self?.toggleState() + } + topPartView.translatesAutoresizingMaskIntoConstraints = false + sheetView.addSubview(topPartView) + NSLayoutConstraint.activate([ + topPartView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 6), + topPartView.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor), + topPartView.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor), + topPartView.heightAnchor.constraint(equalToConstant: Self.topPartHeight) + ]) + } + + private func setupContent() { + setupSegmentControl() + tabContainerView.clipsToBounds = true + [segmentControl, tabContainerView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + sheetView.addSubview($0) + } + sheetView.bringSubviewToFront(bottomToolbar) + let inset = Self.horizontalInset + NSLayoutConstraint.activate([ + segmentControl.topAnchor.constraint(equalTo: topPartView.bottomAnchor, constant: 8), + segmentControl.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor, constant: inset), + segmentControl.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor, constant: -inset), + segmentControl.heightAnchor.constraint(equalToConstant: Self.segmentedControlHeight), + + tabContainerView.topAnchor.constraint(equalTo: segmentControl.bottomAnchor, constant: 12), + tabContainerView.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor), + tabContainerView.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor), + tabContainerView.bottomAnchor.constraint(equalTo: sheetView.bottomAnchor) + ]) + } + + private func setupSegmentControl() { + segmentControl.removeAllSegments() + for (index, tab) in tabs.enumerated() { + segmentControl.insertSegment(withTitle: tab.title, at: index, animated: false) + } + segmentControl.selectedSegmentIndex = tabs.firstIndex(of: selectedTab) ?? 0 + segmentControl.backgroundColor = .groupBgColorSecondary + segmentControl.selectedSegmentTintColor = .viewBg + segmentControl.setTitleTextAttributes([.foregroundColor: UIColor.textColorSecondary, + .font: UIFont.scaledSystemFont(ofSize: 13)], for: .normal) + segmentControl.setTitleTextAttributes([.foregroundColor: UIColor.textColorPrimary, + .font: UIFont.scaledSystemFont(ofSize: 13, weight: .semibold)], for: .selected) + segmentControl.addTarget(self, action: #selector(onSegmentChanged), for: .valueChanged) + } + + private func setupBottomToolbar() { + bottomToolbar.isUndoEnabled = dataProvider.canUndo + bottomToolbar.isRedoEnabled = dataProvider.canRedo + bottomToolbar.onAddPoi = { [weak self] in self?.handleAddPoi() } + bottomToolbar.onUndo = { [weak self] in self?.handleUndo() } + bottomToolbar.onRedo = { [weak self] in self?.handleRedo() } + bottomToolbar.onAddRoutePoint = { [weak self] in self?.handleAddRoutePoint() } + bottomToolbar.translatesAutoresizingMaskIntoConstraints = false + sheetView.addSubview(bottomToolbar) + let inset = Self.horizontalInset + NSLayoutConstraint.activate([ + bottomToolbar.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor, constant: inset), + bottomToolbar.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor, constant: -inset), + bottomToolbar.bottomAnchor.constraint(equalTo: sheetView.safeAreaLayoutGuide.bottomAnchor, constant: -8), + bottomToolbar.heightAnchor.constraint(equalToConstant: PlanRouteButtonFactory.bottomButtonHeight) + ]) + } + + private func setupTopToolbar() { + topToolbar.titleText = dataProvider.mode.title + topToolbar.isSaveButtonVisible = true + topToolbar.optionsMenu = makeOptionsMenu() + topToolbar.onClose = { [weak self] in self?.handleClose() } + topToolbar.onSave = { [weak self] in self?.handleSave() } + topToolbar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(topToolbar) + NSLayoutConstraint.activate([ + topToolbar.topAnchor.constraint(equalTo: view.topAnchor), + topToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + topToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + topToolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: PlanRouteTopToolbarView.contentHeight) + ]) + } + + private func height(for state: EOADraggableMenuState) -> CGFloat { + let screenHeight = OAUtilities.calculateScreenHeight() + let collapsed = Self.grabberAreaHeight + Self.topPartHeight + 8 + Self.segmentedControlHeight + 12 + + PlanRouteButtonFactory.bottomButtonHeight + 8 + OAUtilities.getBottomMargin() + switch state { + case .initial: + return collapsed + case .expanded: + return screenHeight / 2 + case .fullScreen: + return screenHeight - getNavbarHeight() - Self.fullScreenTopGap + @unknown default: + return screenHeight / 2 + } + } + + private func applyHeight(for state: EOADraggableMenuState) { + sheetHeightConstraint?.constant = height(for: state) + } + + private func mapControlsReservedHeight(for state: EOADraggableMenuState) -> CGFloat { + min(height(for: state), height(for: .expanded)) + } + + private func setState(_ state: EOADraggableMenuState, animated: Bool) { + sheetState = state + sheetHeightConstraint?.constant = height(for: state) + let updates: () -> Void = { [weak self] in + guard let self else { return } + view.layoutIfNeeded() + tabContainerView.alpha = isContentVisible(in: state) ? 1 : 0 + refreshMapControls() + } + if animated { + UIView.animate(withDuration: Self.animationDuration, animations: updates) + } else { + updates() + } + } + + private func isContentVisible(in state: EOADraggableMenuState) -> Bool { + state != .initial + } + + private func toggleState() { + setState(sheetState == .initial ? .expanded : .initial, animated: true) + } + + private func nearestState(for currentHeight: CGFloat, velocity: CGFloat) -> EOADraggableMenuState { + if velocity < -800 { return .fullScreen } + if velocity > 800 { return .initial } + let candidates: [EOADraggableMenuState] = [.initial, .expanded, .fullScreen] + return candidates.min { abs(height(for: $0) - currentHeight) < abs(height(for: $1) - currentHeight) } ?? .expanded + } + + private func refreshMapControls() { + let style: UIStatusBarStyle = OAAppSettings.sharedManager().nightMode ? .lightContent : .default + OARootViewController.instance().mapPanel?.targetUpdateControlsLayout(true, customStatusBarStyle: style) + } + + private func tabViewController(for tab: PlanRouteTab) -> UIViewController { + if let existing = tabViewControllers[tab] { + return existing + } + let controller: UIViewController + switch tab { + case .poi: controller = PlanRoutePoiViewController(dataSource: dataProvider) + case .analyze: controller = PlanRouteAnalyzeViewController(dataSource: dataProvider) + case .route: controller = PlanRouteRouteViewController(dataSource: dataProvider) + } + tabViewControllers[tab] = controller + return controller + } + + private func selectTab(_ tab: PlanRouteTab) { + selectedTab = tab + let newController = tabViewController(for: tab) + guard newController !== currentTabViewController else { return } + currentTabViewController?.willMove(toParent: nil) + currentTabViewController?.view.removeFromSuperview() + currentTabViewController?.removeFromParent() + + addChild(newController) + newController.view.translatesAutoresizingMaskIntoConstraints = false + tabContainerView.addSubview(newController.view) + NSLayoutConstraint.activate([ + newController.view.topAnchor.constraint(equalTo: tabContainerView.topAnchor), + newController.view.leadingAnchor.constraint(equalTo: tabContainerView.leadingAnchor), + newController.view.trailingAnchor.constraint(equalTo: tabContainerView.trailingAnchor), + newController.view.bottomAnchor.constraint(equalTo: tabContainerView.bottomAnchor) + ]) + newController.didMove(toParent: self) + currentTabViewController = newController + } + + private func makeOptionsMenu() -> UIMenu { + let actions = PlanRouteMenuAction.actions(for: dataProvider.mode).map { action in + UIAction(title: action.title, + image: action.icon, + attributes: action.isDestructive ? .destructive : []) { [weak self] _ in + self?.handleMenuAction(action) + } + } + return UIMenu(children: actions) + } + + private func handleClose() { + guard dataProvider.hasChanges else { + hide() + return + } + let alert = UIAlertController(title: localizedString("exit_without_saving"), + message: nil, + preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: localizedString("shared_string_discard"), style: .destructive) { [weak self] _ in + self?.hide() + }) + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func handleSave() { + print("[PlanRoute] Save tapped") + } + + private func handleAddPoi() { + print("[PlanRoute] Add POI tapped") + } + + private func handleUndo() { + print("[PlanRoute] Undo tapped") + } + + private func handleRedo() { + print("[PlanRoute] Redo tapped") + } + + private func handleAddRoutePoint() { + print("[PlanRoute] Add route point tapped") + } + + private func handleMenuAction(_ action: PlanRouteMenuAction) { + print("[PlanRoute] Options menu action: \(action)") + } + + @objc private func onSegmentChanged() { + let index = segmentControl.selectedSegmentIndex + guard tabs.indices.contains(index) else { return } + let tab = tabs[index] + print("[PlanRoute] Segment switched to: \(tab)") + selectTab(tab) + if sheetState == .initial { + setState(.expanded, animated: true) + } + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard let sheetHeightConstraint else { return } + let translation = gesture.translation(in: view).y + switch gesture.state { + case .began: + panStartHeight = sheetHeightConstraint.constant + case .changed: + let lower = height(for: .initial) + let upper = height(for: .fullScreen) + sheetHeightConstraint.constant = min(max(panStartHeight - translation, lower), upper) + case .ended, .cancelled: + let velocity = gesture.velocity(in: view).y + setState(nearestState(for: sheetHeightConstraint.constant, velocity: velocity), animated: true) + default: + break + } + } +} diff --git a/Sources/Controllers/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/PlanRoute/PlanRouteModels.swift new file mode 100644 index 0000000000..1bf35b4a78 --- /dev/null +++ b/Sources/Controllers/PlanRoute/PlanRouteModels.swift @@ -0,0 +1,177 @@ +// +// PlanRouteModels.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum PlanRouteMode { + case newRoute + case editTrack(fileName: String) + + var title: String { + switch self { + case .newRoute: localizedString("quick_action_new_route") + case let .editTrack(fileName): fileName + } + } + + var isNewRoute: Bool { + if case .newRoute = self { return true } + return false + } + + var isEditTrack: Bool { + !isNewRoute + } +} + +enum PlanRouteTab: Int, CaseIterable { + case poi + case analyze + case route + + var title: String { + switch self { + case .poi: localizedString("poi") + case .analyze: localizedString("gpx_analyze") + case .route: localizedString("layer_route") + } + } + + static var `default`: PlanRouteTab { + .route + } +} + +enum PlanRouteMenuAction: CaseIterable { + case saveAs + case saveAsCopy + case appendToExistingTrack + case changeSegmentOrder + case viewDirections + case reverseRoute + case navigation + case clearAllPoints + + var title: String { + switch self { + case .saveAs: localizedString("plan_route_save_as") + case .saveAsCopy: localizedString("save_as_copy") + case .appendToExistingTrack: localizedString("plan_route_append_to_existing_track") + case .changeSegmentOrder: localizedString("plan_route_change_segment_order") + case .viewDirections: localizedString("plan_route_view_directions") + case .reverseRoute: localizedString("reverse_route") + case .navigation: localizedString("shared_string_navigation") + case .clearAllPoints: localizedString("distance_measurement_clear_route") + } + } + + var icon: UIImage? { + switch self { + case .saveAs: .templateImageNamed("ic_custom_save_to_file") + case .saveAsCopy: .templateImageNamed("ic_custom_save_as_new_file") + case .appendToExistingTrack: .templateImageNamed("ic_custom_add_to_track") + case .changeSegmentOrder: .templateImageNamed("ic_custom_list") + case .viewDirections: .templateImageNamed("ic_custom_route_points") + case .reverseRoute: .templateImageNamed("ic_custom_change_object_position") + case .navigation: .templateImageNamed("ic_custom_navigation_outlined") + case .clearAllPoints: .templateImageNamed("ic_custom_trash_outlined") + } + } + + var isDestructive: Bool { + self == .clearAllPoints + } + + func isVisible(for mode: PlanRouteMode) -> Bool { + switch self { + case .saveAsCopy: mode.isEditTrack + default: true + } + } + + static func actions(for mode: PlanRouteMode) -> [PlanRouteMenuAction] { + allCases.filter { $0.isVisible(for: mode) } + } +} + +struct PlanRouteInfo { + let isNewRoute: Bool + let isStraightLine: Bool + let hasRoute: Bool + let totalDistance: Double + let duration: TimeInterval + let arrivalTime: Date? + let uphill: Double + let downhill: Double + let mapCenterDistance: Double + let bearing: Double + + var showsTime: Bool { + !isNewRoute && !isStraightLine && duration > 0 + } + + static var empty: PlanRouteInfo { + PlanRouteInfo(isNewRoute: true, + isStraightLine: false, + hasRoute: false, + totalDistance: 0, + duration: 0, + arrivalTime: nil, + uphill: 0, + downhill: 0, + mapCenterDistance: 0, + bearing: 0) + } +} + +struct PlanRoutePoint { + let index: Int + let name: String + let distanceFromPrevious: Double + let bearing: Double + let isStart: Bool + let isDestination: Bool +} + +struct PlanRouteSegment { + let index: Int + let points: [PlanRoutePoint] +} + +struct PlanRouteElevationData { + let uphill: Double + let downhill: Double + let elevations: [Double] +} + +protocol PlanRoutePoiDataSource: AnyObject { + var poiPoints: [PlanRoutePoint] { get } +} + +protocol PlanRouteAnalyzeDataSource: AnyObject { + var routeInfo: PlanRouteInfo { get } + var elevationData: PlanRouteElevationData? { get } +} + +protocol PlanRoutePointsDataSource: AnyObject { + var routeInfo: PlanRouteInfo { get } + var segments: [PlanRouteSegment] { get } + var routePoints: [PlanRoutePoint] { get } +} + +protocol PlanRouteDataProvider: PlanRoutePoiDataSource, PlanRouteAnalyzeDataSource, PlanRoutePointsDataSource { + var mode: PlanRouteMode { get } + var hasChanges: Bool { get } + var canUndo: Bool { get } + var canRedo: Bool { get } +} + +protocol PlanRouteTabContent: AnyObject { + var planRouteTab: PlanRouteTab { get } + func reloadData() +} diff --git a/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift new file mode 100644 index 0000000000..0342b1470f --- /dev/null +++ b/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift @@ -0,0 +1,58 @@ +// +// PlanRouteStubDataProvider.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteStubDataProvider: PlanRouteDataProvider { + let mode: PlanRouteMode + + init(mode: PlanRouteMode = .newRoute) { + self.mode = mode + } + + var hasChanges: Bool { + false + } + + var canUndo: Bool { + false + } + + var canRedo: Bool { + false + } + + var routeInfo: PlanRouteInfo { + PlanRouteInfo(isNewRoute: mode.isNewRoute, + isStraightLine: false, + hasRoute: !routePoints.isEmpty, + totalDistance: 0, + duration: 0, + arrivalTime: nil, + uphill: 0, + downhill: 0, + mapCenterDistance: 0, + bearing: 100) + } + + var elevationData: PlanRouteElevationData? { + nil + } + + var poiPoints: [PlanRoutePoint] { + [] + } + + var routePoints: [PlanRoutePoint] { + [] + } + + var segments: [PlanRouteSegment] { + [] + } +} diff --git a/Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift b/Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift new file mode 100644 index 0000000000..71a965b84a --- /dev/null +++ b/Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift @@ -0,0 +1,176 @@ +// +// PlanRouteToolbarsView.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteTopToolbarView: UIView { + static let contentHeight: CGFloat = 56 + + private static let edgeInset: CGFloat = 16 + private static let buttonSpacing: CGFloat = 8 + + var onClose: (() -> Void)? + var onSave: (() -> Void)? + + var titleText: String? { + didSet { titleLabel.text = titleText } + } + + var optionsMenu: UIMenu? { + didSet { + optionsButton.menu = optionsMenu + optionsButton.showsMenuAsPrimaryAction = optionsMenu != nil + } + } + + var isSaveButtonVisible = true { + didSet { saveButton.isHidden = !isSaveButtonVisible } + } + + private let titleLabel = UILabel() + private let closeButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_close")) + private let optionsButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_overflow_menu_stroke")) + + private lazy var saveButton = PlanRouteButtonFactory.primaryButton(title: localizedString("shared_string_save")) + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let hitView = super.hitTest(point, with: event) else { return nil } + return hitView is UIControl ? hitView : nil + } + + private func setupView() { + backgroundColor = .clear + + titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .semibold, maximumSize: 22) + titleLabel.textColor = .textColorPrimary + titleLabel.textAlignment = .center + titleLabel.adjustsFontForContentSizeCategory = true + + let trailingStack = UIStackView(arrangedSubviews: [optionsButton, saveButton]) + trailingStack.spacing = Self.buttonSpacing + trailingStack.alignment = .center + + [closeButton, titleLabel, trailingStack].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + + let inset = Self.edgeInset + NSLayoutConstraint.activate([ + closeButton.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: inset), + closeButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: Self.buttonSpacing), + + trailingStack.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -inset), + trailingStack.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), + + titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + titleLabel.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), + titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: closeButton.trailingAnchor, constant: Self.buttonSpacing), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingStack.leadingAnchor, constant: -Self.buttonSpacing) + ]) + + closeButton.addTarget(self, action: #selector(onCloseTapped), for: .touchUpInside) + saveButton.addTarget(self, action: #selector(onSaveTapped), for: .touchUpInside) + } + + @objc private func onCloseTapped() { + onClose?() + } + + @objc private func onSaveTapped() { + onSave?() + } +} + +final class PlanRouteBottomToolbarView: UIView { + private static let edgeInset: CGFloat = 16 + private static let buttonSpacing: CGFloat = 8 + + var onAddPoi: (() -> Void)? + var onUndo: (() -> Void)? + var onRedo: (() -> Void)? + var onAddRoutePoint: (() -> Void)? + + var isUndoEnabled = false { + didSet { undoButton.isEnabled = isUndoEnabled } + } + + var isRedoEnabled = false { + didSet { redoButton.isEnabled = isRedoEnabled } + } + + private let undoButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_undo"), size: PlanRouteButtonFactory.bottomButtonHeight) + private let redoButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_redo"), size: PlanRouteButtonFactory.bottomButtonHeight) + + private lazy var addPoiButton = PlanRouteButtonFactory.labeledButton(title: localizedString("poi"), image: .templateImageNamed("ic_custom_add")) + private lazy var routeButton = PlanRouteButtonFactory.labeledButton(title: localizedString("layer_route"), image: .templateImageNamed("ic_custom_add")) + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + backgroundColor = .clear + + let centerStack = UIStackView(arrangedSubviews: [undoButton, redoButton]) + centerStack.spacing = Self.buttonSpacing + centerStack.alignment = .center + + [addPoiButton, centerStack, routeButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + + let inset = Self.edgeInset + NSLayoutConstraint.activate([ + addPoiButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), + addPoiButton.topAnchor.constraint(equalTo: topAnchor), + + routeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), + routeButton.centerYAnchor.constraint(equalTo: addPoiButton.centerYAnchor), + + centerStack.centerXAnchor.constraint(equalTo: centerXAnchor), + centerStack.centerYAnchor.constraint(equalTo: addPoiButton.centerYAnchor) + ]) + + addPoiButton.addTarget(self, action: #selector(onAddPoiTapped), for: .touchUpInside) + undoButton.addTarget(self, action: #selector(onUndoTapped), for: .touchUpInside) + redoButton.addTarget(self, action: #selector(onRedoTapped), for: .touchUpInside) + routeButton.addTarget(self, action: #selector(onRouteTapped), for: .touchUpInside) + } + + @objc private func onAddPoiTapped() { + onAddPoi?() + } + + @objc private func onUndoTapped() { + onUndo?() + } + + @objc private func onRedoTapped() { + onRedo?() + } + + @objc private func onRouteTapped() { + onAddRoutePoint?() + } +} diff --git a/Sources/Controllers/PlanRoute/PlanRouteTopPartView.swift b/Sources/Controllers/PlanRoute/PlanRouteTopPartView.swift new file mode 100644 index 0000000000..1979219909 --- /dev/null +++ b/Sources/Controllers/PlanRoute/PlanRouteTopPartView.swift @@ -0,0 +1,132 @@ +// +// PlanRouteTopPartView.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteTopPartView: UIView { + private static let statusIconSize: CGFloat = 30 + private static let horizontalInset: CGFloat = 20 + + var onTap: (() -> Void)? + + private let statusIconView = UIImageView() + private let firstLineLabel = UILabel() + private let secondLineLabel = UILabel() + private let textStackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with info: PlanRouteInfo) { + firstLineLabel.attributedText = makeFirstLine(info) + secondLineLabel.attributedText = makeSecondLine(info) + } + + private func setupView() { + backgroundColor = .clear + + statusIconView.image = .templateImageNamed("ic_custom_plan_route") + statusIconView.tintColor = .iconColorActive + statusIconView.contentMode = .scaleAspectFit + statusIconView.translatesAutoresizingMaskIntoConstraints = false + addSubview(statusIconView) + + firstLineLabel.numberOfLines = 1 + firstLineLabel.adjustsFontForContentSizeCategory = true + secondLineLabel.numberOfLines = 1 + secondLineLabel.adjustsFontForContentSizeCategory = true + + textStackView.axis = .vertical + textStackView.spacing = 2 + textStackView.alignment = .leading + textStackView.addArrangedSubview(firstLineLabel) + textStackView.addArrangedSubview(secondLineLabel) + textStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(textStackView) + + let horizontalInset = Self.horizontalInset + let statusIconSize = Self.statusIconSize + + NSLayoutConstraint.activate([ + statusIconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset), + statusIconView.centerYAnchor.constraint(equalTo: centerYAnchor), + statusIconView.widthAnchor.constraint(equalToConstant: statusIconSize), + statusIconView.heightAnchor.constraint(equalToConstant: statusIconSize), + + textStackView.centerYAnchor.constraint(equalTo: centerYAnchor), + textStackView.leadingAnchor.constraint(equalTo: statusIconView.trailingAnchor, constant: 12), + textStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset) + ]) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(onViewTapped)) + addGestureRecognizer(tapRecognizer) + } + + private func makeFirstLine(_ info: PlanRouteInfo) -> NSAttributedString { + let bodyFont = UIFont.preferredFont(forTextStyle: .body) + let primary: [NSAttributedString.Key: Any] = [.font: bodyFont, .foregroundColor: UIColor.textColorPrimary] + let secondary: [NSAttributedString.Key: Any] = [.font: bodyFont, .foregroundColor: UIColor.textColorSecondary] + + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: formattedDistance(info.totalDistance), attributes: primary)) + + guard info.showsTime else { return result } + + result.append(NSAttributedString(string: " • ", attributes: secondary)) + result.append(NSAttributedString(string: formattedDuration(info.duration), attributes: secondary)) + if let arrival = info.arrivalTime { + result.append(NSAttributedString(string: " (\(formattedTime(arrival)))", attributes: secondary)) + } + return result + } + + private func makeSecondLine(_ info: PlanRouteInfo) -> NSAttributedString { + let subheadFont = UIFont.preferredFont(forTextStyle: .subheadline) + let attributes: [NSAttributedString.Key: Any] = [.font: subheadFont, .foregroundColor: UIColor.textColorSecondary] + + let result = NSMutableAttributedString() + result.append(symbolAttachment("arrow.up.right", font: subheadFont)) + result.append(NSAttributedString(string: " \(formattedDistance(info.uphill)) ", attributes: attributes)) + result.append(symbolAttachment("arrow.down.right", font: subheadFont)) + result.append(NSAttributedString(string: " \(formattedDistance(info.downhill))", attributes: attributes)) + result.append(NSAttributedString(string: " | ", attributes: attributes)) + result.append(NSAttributedString(string: "\(formattedDistance(info.mapCenterDistance)) • \(Int(info.bearing))°", attributes: attributes)) + return result + } + + private func symbolAttachment(_ name: String, font: UIFont) -> NSAttributedString { + let attachment = NSTextAttachment() + let configuration = UIImage.SymbolConfiguration(font: font) + attachment.image = UIImage(systemName: name, withConfiguration: configuration)?.withTintColor(.textColorSecondary, renderingMode: .alwaysOriginal) + return NSAttributedString(attachment: attachment) + } + + private func formattedDistance(_ meters: Double) -> String { + OAOsmAndFormatter.getFormattedDistance(Float(meters)) + } + + private func formattedDuration(_ duration: TimeInterval) -> String { + OAOsmAndFormatter.getFormattedTimeInterval(duration, shortFormat: true) + } + + private func formattedTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: date) + } + + @objc private func onViewTapped() { + onTap?() + } +} diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift new file mode 100644 index 0000000000..7d70fd1db9 --- /dev/null +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift @@ -0,0 +1,50 @@ +// +// PlanRouteAnalyzeViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteAnalyzeViewController: UIViewController, PlanRouteTabContent { + let planRouteTab: PlanRouteTab = .analyze + + private weak var dataSource: PlanRouteAnalyzeDataSource? + + private let placeholderLabel = UILabel() + + init(dataSource: PlanRouteAnalyzeDataSource?) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupPlaceholder() + reloadData() + } + + func reloadData() { + guard isViewLoaded else { return } + placeholderLabel.text = planRouteTab.title + } + + private func setupPlaceholder() { + view.backgroundColor = .clear + placeholderLabel.font = .preferredFont(forTextStyle: .body) + placeholderLabel.textColor = .textColorSecondary + placeholderLabel.textAlignment = .center + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(placeholderLabel) + NSLayoutConstraint.activate([ + placeholderLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + placeholderLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift new file mode 100644 index 0000000000..29d95ac07f --- /dev/null +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift @@ -0,0 +1,50 @@ +// +// PlanRoutePoiViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRoutePoiViewController: UIViewController, PlanRouteTabContent { + let planRouteTab: PlanRouteTab = .poi + + private weak var dataSource: PlanRoutePoiDataSource? + + private let placeholderLabel = UILabel() + + init(dataSource: PlanRoutePoiDataSource?) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupPlaceholder() + reloadData() + } + + func reloadData() { + guard isViewLoaded else { return } + placeholderLabel.text = planRouteTab.title + } + + private func setupPlaceholder() { + view.backgroundColor = .clear + placeholderLabel.font = .preferredFont(forTextStyle: .body) + placeholderLabel.textColor = .textColorSecondary + placeholderLabel.textAlignment = .center + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(placeholderLabel) + NSLayoutConstraint.activate([ + placeholderLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + placeholderLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift new file mode 100644 index 0000000000..ca6e60cc94 --- /dev/null +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift @@ -0,0 +1,276 @@ +// +// PlanRouteRouteViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent { + let planRouteTab: PlanRouteTab = .route + + private weak var dataSource: PlanRoutePointsDataSource? + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private var points: [PlanRoutePoint] = [] + + init(dataSource: PlanRoutePointsDataSource?) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupTableView() + reloadData() + } + + func reloadData() { + guard isViewLoaded else { return } + points = dataSource?.routePoints ?? [] + tableView.reloadData() + } + + private func setupTableView() { + view.backgroundColor = .clear + tableView.backgroundColor = .clear + tableView.dataSource = self + tableView.delegate = self + tableView.separatorInset = UIEdgeInsets(top: 0, left: 76, bottom: 0, right: 0) + tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 72, right: 0) + tableView.register(PlanRoutePointCell.self, forCellReuseIdentifier: PlanRoutePointCell.cellReuseId) + tableView.register(PlanRouteEmptyCell.self, forCellReuseIdentifier: PlanRouteEmptyCell.cellReuseId) + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } +} + +// MARK: - UITableViewDataSource +extension PlanRouteRouteViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + points.isEmpty ? 1 : 2 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? max(points.count, 1) : 1 + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? localizedString("route_points") : nil + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if indexPath.section == 0, points.isEmpty { + guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRouteEmptyCell.cellReuseId, for: indexPath) as? PlanRouteEmptyCell else { + return UITableViewCell() + } + return cell + } + if indexPath.section == 1 { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.textLabel?.text = localizedString("gpx_start_new_segment") + cell.textLabel?.textColor = .iconColorActive + cell.textLabel?.font = .scaledSystemFont(ofSize: 17) + cell.backgroundColor = .groupBg + return cell + } + guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRoutePointCell.cellReuseId, for: indexPath) as? PlanRoutePointCell else { + return UITableViewCell() + } + cell.configure(with: points[indexPath.row]) + cell.onDelete = { [weak self] in + print("[PlanRoute] Delete point at index: \(indexPath.row)") + self?.reloadData() + } + return cell + } +} + +// MARK: - UITableViewDelegate +extension PlanRouteRouteViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if indexPath.section == 1 { + print("[PlanRoute] Start new segment tapped") + } else if !points.isEmpty { + print("[PlanRoute] Selected point at index: \(indexPath.row)") + } + } +} + +final class PlanRoutePointCell: UITableViewCell { + static let cellReuseId = "PlanRoutePointCell" + + private static let circleSize: CGFloat = 28 + private static let deleteSize: CGFloat = 24 + + var onDelete: (() -> Void)? + + private let deleteButton = UIButton(type: .system) + private let numberLabel = UILabel() + private let numberContainer = UIView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let dragHandleView = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with point: PlanRoutePoint) { + numberLabel.text = "\(point.index + 1)" + titleLabel.text = point.name + subtitleLabel.text = subtitle(for: point) + } + + private func setupCell() { + backgroundColor = .groupBg + selectionStyle = .none + + deleteButton.setImage(UIImage(systemName: "minus.circle.fill"), for: .normal) + deleteButton.tintColor = .systemRed + deleteButton.addTarget(self, action: #selector(onDeleteTapped), for: .touchUpInside) + + numberContainer.layer.cornerRadius = Self.circleSize / 2 + numberContainer.layer.borderWidth = 2 + numberContainer.layer.borderColor = UIColor.iconColorActive.cgColor + numberLabel.font = .scaledSystemFont(ofSize: 13, weight: .semibold) + numberLabel.textColor = .iconColorActive + numberLabel.textAlignment = .center + numberLabel.translatesAutoresizingMaskIntoConstraints = false + numberContainer.addSubview(numberLabel) + + titleLabel.font = .scaledSystemFont(ofSize: 17) + titleLabel.textColor = .textColorPrimary + subtitleLabel.font = .scaledSystemFont(ofSize: 13) + subtitleLabel.textColor = .textColorSecondary + + let textStack = UIStackView(arrangedSubviews: [subtitleLabel, titleLabel]) + textStack.axis = .vertical + textStack.spacing = 2 + + dragHandleView.image = UIImage(systemName: "line.3.horizontal") + dragHandleView.tintColor = .iconColorTertiary + dragHandleView.contentMode = .scaleAspectFit + + [deleteButton, numberContainer, textStack, dragHandleView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + NSLayoutConstraint.activate([ + deleteButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + deleteButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + deleteButton.widthAnchor.constraint(equalToConstant: Self.deleteSize), + deleteButton.heightAnchor.constraint(equalToConstant: Self.deleteSize), + + numberContainer.leadingAnchor.constraint(equalTo: deleteButton.trailingAnchor, constant: 12), + numberContainer.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + numberContainer.widthAnchor.constraint(equalToConstant: Self.circleSize), + numberContainer.heightAnchor.constraint(equalToConstant: Self.circleSize), + numberLabel.centerXAnchor.constraint(equalTo: numberContainer.centerXAnchor), + numberLabel.centerYAnchor.constraint(equalTo: numberContainer.centerYAnchor), + + textStack.leadingAnchor.constraint(equalTo: numberContainer.trailingAnchor, constant: 12), + textStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + textStack.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 8), + + dragHandleView.leadingAnchor.constraint(greaterThanOrEqualTo: textStack.trailingAnchor, constant: 12), + dragHandleView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + dragHandleView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + dragHandleView.widthAnchor.constraint(equalToConstant: 24), + dragHandleView.heightAnchor.constraint(equalToConstant: 24) + ]) + } + + private func subtitle(for point: PlanRoutePoint) -> String { + if point.isStart { + return localizedString("starting_point") + } + let distance = OAOsmAndFormatter.getFormattedDistance(Float(point.distanceFromPrevious)) + if point.isDestination { + return "\(distance) • \(localizedString("route_descr_destination"))" + } + return "\(distance) • \(Int(point.bearing))°" + } + + @objc private func onDeleteTapped() { + onDelete?() + } +} + +final class PlanRouteEmptyCell: UITableViewCell { + static let cellReuseId = "PlanRouteEmptyCell" + + private static let iconSize: CGFloat = 30 + + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + private let iconView = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupCell() { + backgroundColor = .groupBg + selectionStyle = .none + + titleLabel.text = localizedString("plan_route_no_points_title") + titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .medium) + titleLabel.textColor = .textColorPrimary + titleLabel.numberOfLines = 0 + + descriptionLabel.text = localizedString("plan_route_no_points_descr") + descriptionLabel.font = .scaledSystemFont(ofSize: 15) + descriptionLabel.textColor = .textColorSecondary + descriptionLabel.numberOfLines = 0 + + iconView.image = .templateImageNamed("ic_custom_plan_route") + iconView.tintColor = .iconColorActive + iconView.contentMode = .scaleAspectFit + + let textStack = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel]) + textStack.axis = .vertical + textStack.spacing = 6 + + [textStack, iconView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + NSLayoutConstraint.activate([ + textStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + textStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + textStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16), + + iconView.leadingAnchor.constraint(equalTo: textStack.trailingAnchor, constant: 12), + iconView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + iconView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + iconView.widthAnchor.constraint(equalToConstant: Self.iconSize), + iconView.heightAnchor.constraint(equalToConstant: Self.iconSize) + ]) + } +} diff --git a/Sources/Controllers/RoutePlanning/InitialRoutePlanningBottomSheetViewController.mm b/Sources/Controllers/RoutePlanning/InitialRoutePlanningBottomSheetViewController.mm index 1035130a1c..f38111edf8 100644 --- a/Sources/Controllers/RoutePlanning/InitialRoutePlanningBottomSheetViewController.mm +++ b/Sources/Controllers/RoutePlanning/InitialRoutePlanningBottomSheetViewController.mm @@ -19,9 +19,9 @@ #import "OAUtilities.h" #import "Localization.h" #import "OsmAnd_Maps-Swift.h" +#import "OsmAnd_Maps-Swift.h" #import "OAOsmAndFormatter.h" #import "GeneratedAssetSymbols.h" -#import "OAMeasurementEditingContext.h" #define kVerticalMargin 18. #define kHorizontalMargin 20. @@ -151,11 +151,6 @@ - (void) onRightButtonPressed [super onRightButtonPressed]; } -- (BOOL)isTransportMode:(OAApplicationMode *)mode -{ - return [mode isDerivedRoutingFrom:OAApplicationMode.PUBLIC_TRANSPORT]; -} - #pragma mark - UITableViewDataSource - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath @@ -260,11 +255,9 @@ - (void) tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath NSString *key = item[@"key"]; if ([key isEqualToString:@"create_new_route"]) { - [self hide:YES]; - OAMeasurementEditingContext *editingContext = [[OAMeasurementEditingContext alloc] init]; - OAApplicationMode *mode = [[OAAppSettings sharedManager].applicationMode get]; - editingContext.appMode = [self isTransportMode:mode] ? OAApplicationMode.DEFAULT : mode; - [[OARootViewController instance].mapPanel showScrollableHudViewController:[[OARoutePlanningHudViewController alloc] initWithEditingContext:editingContext]]; + [self hide:YES completion:^{ + [PlanRouteContainerViewController showNewRoute]; + }]; return; } else if ([key isEqualToString:@"open_track"]) diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteButtonFactory.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteButtonFactory.swift new file mode 100644 index 0000000000..1d9ef872f7 --- /dev/null +++ b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteButtonFactory.swift @@ -0,0 +1,76 @@ +// +// PlanRouteButtonFactory.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum PlanRouteButtonFactory { + static let toolbarButtonSize: CGFloat = 48 + static let bottomButtonHeight: CGFloat = OAUtilities.isIPad() ? 48 : 44 + + static func iconButton(image: UIImage?, size: CGFloat = toolbarButtonSize) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.image = image + configuration.baseForegroundColor = .iconColorBlack + configuration.background.backgroundColor = .mapButtonBgColorDefault + configuration.background.cornerRadius = size / 2 + let button = UIButton(configuration: configuration) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: size), + button.heightAnchor.constraint(equalToConstant: size) + ]) + applyPressedState(to: button) + applyShadow(to: button) + return button + } + + static func labeledButton(title: String, image: UIImage?, height: CGFloat = bottomButtonHeight) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.title = title + configuration.image = image + configuration.imagePadding = 6 + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14) + configuration.baseForegroundColor = .textColorPrimary + configuration.background.backgroundColor = .mapButtonBgColorDefault + configuration.background.cornerRadius = height / 2 + let button = UIButton(configuration: configuration) + button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: height).isActive = true + applyPressedState(to: button) + applyShadow(to: button) + return button + } + + static func primaryButton(title: String, height: CGFloat = toolbarButtonSize) -> UIButton { + var configuration = UIButton.Configuration.filled() + configuration.title = title + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18) + configuration.baseForegroundColor = .white + configuration.baseBackgroundColor = .buttonBgColorPrimary + configuration.background.cornerRadius = height / 2 + let button = UIButton(configuration: configuration) + button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: height).isActive = true + return button + } + + private static func applyPressedState(to button: UIButton) { + button.configurationUpdateHandler = { button in + var updated = button.configuration + updated?.background.backgroundColor = button.isHighlighted ? .mapButtonBgColorTap : .mapButtonBgColorDefault + button.configuration = updated + } + } + + private static func applyShadow(to button: UIButton) { + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOpacity = 0.12 + button.layer.shadowOffset = CGSize(width: 0, height: 2) + button.layer.shadowRadius = 4 + } +} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteContainerViewController.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteContainerViewController.swift new file mode 100644 index 0000000000..c58bf51533 --- /dev/null +++ b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteContainerViewController.swift @@ -0,0 +1,435 @@ +// +// PlanRouteContainerViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteContainerViewController: OABaseScrollableHudViewController { + private static let topPartHeight: CGFloat = 50 + private static let grabberAreaHeight: CGFloat = 16 + private static let segmentedControlHeight: CGFloat = 36 + private static let bottomToolbarAreaHeight: CGFloat = 60 + private static let horizontalInset: CGFloat = 16 + private static let cornerRadius: CGFloat = 16 + private static let fullScreenTopGap: CGFloat = 8 + private static let animationDuration: TimeInterval = 0.3 + + private let dataProvider: PlanRouteDataProvider + + private let sheetView = UIView() + private let grabberView = UIView() + private let topToolbar = PlanRouteTopToolbarView() + private let bottomToolbar = PlanRouteBottomToolbarView() + private let topPartView = PlanRouteTopPartView() + private let segmentControl = UISegmentedControl() + private let tabContainerView = UIView() + + private let tabs = PlanRouteTab.allCases + private var sheetState: EOADraggableMenuState = .expanded + private var selectedTab: PlanRouteTab = .default + private var tabViewControllers: [PlanRouteTab: UIViewController] = [:] + private var sheetHeightConstraint: NSLayoutConstraint? + private var panStartHeight: CGFloat = 0 + private weak var currentTabViewController: UIViewController? + + init(dataProvider: PlanRouteDataProvider) { + self.dataProvider = dataProvider + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc static func showNewRoute() { + let provider = PlanRouteStubDataProvider(mode: .newRoute) + let controller = PlanRouteContainerViewController(dataProvider: provider) + OARootViewController.instance().mapPanel?.showScrollableHudViewController(controller) + } + + override func loadView() { + let root = OAUserInteractionPassThroughView() + root.isScreenClickable = true + view = root + } + + override func viewDidLoad() { + setupSheet() + setupTopPart() + setupBottomToolbar() + setupContent() + setupTopToolbar() + selectTab(.default) + reloadData() + } + + override func viewWillAppear(_ animated: Bool) { + navigationController?.setNavigationBarHidden(true, animated: false) + applyHeight(for: sheetState) + tabContainerView.alpha = isContentVisible(in: sheetState) ? 1 : 0 + view.layoutIfNeeded() + if animated { + sheetView.transform = CGAffineTransform(translationX: 0, y: height(for: sheetState)) + UIView.animate(withDuration: Self.animationDuration) { [weak self] in + self?.sheetView.transform = .identity + } + } + refreshMapControls() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + coordinator.animate { [weak self] _ in + guard let self else { return } + applyHeight(for: sheetState) + refreshMapControls() + } + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + OAAppSettings.sharedManager().nightMode ? .lightContent : .default + } + + override func getViewHeight() -> CGFloat { + mapControlsReservedHeight(for: sheetState) + } + + override func getViewHeight(_ state: EOADraggableMenuState) -> CGFloat { + mapControlsReservedHeight(for: state) + } + + override func getNavbarHeight() -> CGFloat { + OAUtilities.getStatusBarHeight() + PlanRouteTopToolbarView.contentHeight + } + + override func getToolbarHeight() -> CGFloat { + Self.bottomToolbarAreaHeight + } + + override func getLandscapeViewWidth() -> CGFloat { + view.bounds.width + } + + override func hide() { + hide(true, duration: Self.animationDuration, onComplete: nil) + } + + override func forceHide() { + hide(false, duration: 0, onComplete: nil) + } + + override func hide(_ animated: Bool, duration: TimeInterval, onComplete: (() -> Void)!) { + let dismiss: () -> Void = { [weak self] in + OARootViewController.instance().mapPanel?.hideScrollableHudViewController() + self?.removeFromParent() + self?.view.removeFromSuperview() + onComplete?() + } + guard animated else { + dismiss() + return + } + UIView.animate(withDuration: duration, animations: { [weak self] in + guard let self else { return } + sheetView.transform = CGAffineTransform(translationX: 0, y: height(for: sheetState)) + }, completion: { _ in dismiss() }) + } + + func reloadData() { + topPartView.configure(with: dataProvider.routeInfo) + currentTabViewController.flatMap { $0 as? PlanRouteTabContent }?.reloadData() + } + + private func setupSheet() { + sheetView.backgroundColor = .groupBg + sheetView.layer.cornerRadius = Self.cornerRadius + sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + sheetView.clipsToBounds = true + sheetView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(sheetView) + let heightConstraint = sheetView.heightAnchor.constraint(equalToConstant: height(for: sheetState)) + sheetHeightConstraint = heightConstraint + NSLayoutConstraint.activate([ + sheetView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + sheetView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + sheetView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + heightConstraint + ]) + + grabberView.backgroundColor = .iconColorTertiary + grabberView.layer.cornerRadius = 2.5 + grabberView.translatesAutoresizingMaskIntoConstraints = false + sheetView.addSubview(grabberView) + NSLayoutConstraint.activate([ + grabberView.topAnchor.constraint(equalTo: sheetView.topAnchor, constant: 8), + grabberView.centerXAnchor.constraint(equalTo: sheetView.centerXAnchor), + grabberView.widthAnchor.constraint(equalToConstant: 36), + grabberView.heightAnchor.constraint(equalToConstant: 5) + ]) + + let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + sheetView.addGestureRecognizer(panRecognizer) + } + + private func setupTopPart() { + topPartView.onTap = { [weak self] in + self?.toggleState() + } + topPartView.translatesAutoresizingMaskIntoConstraints = false + sheetView.addSubview(topPartView) + NSLayoutConstraint.activate([ + topPartView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 6), + topPartView.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor), + topPartView.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor), + topPartView.heightAnchor.constraint(equalToConstant: Self.topPartHeight) + ]) + } + + private func setupContent() { + setupSegmentControl() + tabContainerView.clipsToBounds = true + [segmentControl, tabContainerView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + sheetView.addSubview($0) + } + sheetView.bringSubviewToFront(bottomToolbar) + let inset = Self.horizontalInset + NSLayoutConstraint.activate([ + segmentControl.topAnchor.constraint(equalTo: topPartView.bottomAnchor, constant: 8), + segmentControl.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor, constant: inset), + segmentControl.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor, constant: -inset), + segmentControl.heightAnchor.constraint(equalToConstant: Self.segmentedControlHeight), + + tabContainerView.topAnchor.constraint(equalTo: segmentControl.bottomAnchor, constant: 12), + tabContainerView.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor), + tabContainerView.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor), + tabContainerView.bottomAnchor.constraint(equalTo: sheetView.bottomAnchor) + ]) + } + + private func setupSegmentControl() { + segmentControl.removeAllSegments() + for (index, tab) in tabs.enumerated() { + segmentControl.insertSegment(withTitle: tab.title, at: index, animated: false) + } + segmentControl.selectedSegmentIndex = tabs.firstIndex(of: selectedTab) ?? 0 + segmentControl.backgroundColor = .groupBgColorSecondary + segmentControl.selectedSegmentTintColor = .viewBg + segmentControl.setTitleTextAttributes([.foregroundColor: UIColor.textColorSecondary, + .font: UIFont.scaledSystemFont(ofSize: 13)], for: .normal) + segmentControl.setTitleTextAttributes([.foregroundColor: UIColor.textColorPrimary, + .font: UIFont.scaledSystemFont(ofSize: 13, weight: .semibold)], for: .selected) + segmentControl.addTarget(self, action: #selector(onSegmentChanged), for: .valueChanged) + } + + private func setupBottomToolbar() { + bottomToolbar.isUndoEnabled = dataProvider.canUndo + bottomToolbar.isRedoEnabled = dataProvider.canRedo + bottomToolbar.onAddPoi = { [weak self] in self?.handleAddPoi() } + bottomToolbar.onUndo = { [weak self] in self?.handleUndo() } + bottomToolbar.onRedo = { [weak self] in self?.handleRedo() } + bottomToolbar.onAddRoutePoint = { [weak self] in self?.handleAddRoutePoint() } + bottomToolbar.translatesAutoresizingMaskIntoConstraints = false + sheetView.addSubview(bottomToolbar) + let inset = Self.horizontalInset + NSLayoutConstraint.activate([ + bottomToolbar.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor, constant: inset), + bottomToolbar.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor, constant: -inset), + bottomToolbar.bottomAnchor.constraint(equalTo: sheetView.safeAreaLayoutGuide.bottomAnchor, constant: -8), + bottomToolbar.heightAnchor.constraint(equalToConstant: PlanRouteButtonFactory.bottomButtonHeight) + ]) + } + + private func setupTopToolbar() { + topToolbar.titleText = dataProvider.mode.title + topToolbar.isSaveButtonVisible = true + topToolbar.optionsMenu = makeOptionsMenu() + topToolbar.onClose = { [weak self] in self?.handleClose() } + topToolbar.onSave = { [weak self] in self?.handleSave() } + topToolbar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(topToolbar) + NSLayoutConstraint.activate([ + topToolbar.topAnchor.constraint(equalTo: view.topAnchor), + topToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + topToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + topToolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: PlanRouteTopToolbarView.contentHeight) + ]) + } + + private func height(for state: EOADraggableMenuState) -> CGFloat { + let screenHeight = OAUtilities.calculateScreenHeight() + let collapsed = Self.grabberAreaHeight + Self.topPartHeight + 8 + Self.segmentedControlHeight + 12 + + PlanRouteButtonFactory.bottomButtonHeight + 8 + OAUtilities.getBottomMargin() + switch state { + case .initial: + return collapsed + case .expanded: + return screenHeight / 2 + case .fullScreen: + return screenHeight - getNavbarHeight() - Self.fullScreenTopGap + @unknown default: + return screenHeight / 2 + } + } + + private func applyHeight(for state: EOADraggableMenuState) { + sheetHeightConstraint?.constant = height(for: state) + } + + private func mapControlsReservedHeight(for state: EOADraggableMenuState) -> CGFloat { + min(height(for: state), height(for: .expanded)) + } + + private func setState(_ state: EOADraggableMenuState, animated: Bool) { + sheetState = state + sheetHeightConstraint?.constant = height(for: state) + let updates: () -> Void = { [weak self] in + guard let self else { return } + view.layoutIfNeeded() + tabContainerView.alpha = isContentVisible(in: state) ? 1 : 0 + refreshMapControls() + } + if animated { + UIView.animate(withDuration: Self.animationDuration, animations: updates) + } else { + updates() + } + } + + private func isContentVisible(in state: EOADraggableMenuState) -> Bool { + state != .initial + } + + private func toggleState() { + setState(sheetState == .initial ? .expanded : .initial, animated: true) + } + + private func nearestState(for currentHeight: CGFloat, velocity: CGFloat) -> EOADraggableMenuState { + if velocity < -800 { return .fullScreen } + if velocity > 800 { return .initial } + let candidates: [EOADraggableMenuState] = [.initial, .expanded, .fullScreen] + return candidates.min { abs(height(for: $0) - currentHeight) < abs(height(for: $1) - currentHeight) } ?? .expanded + } + + private func refreshMapControls() { + let style: UIStatusBarStyle = OAAppSettings.sharedManager().nightMode ? .lightContent : .default + OARootViewController.instance().mapPanel?.targetUpdateControlsLayout(true, customStatusBarStyle: style) + } + + private func tabViewController(for tab: PlanRouteTab) -> UIViewController { + if let existing = tabViewControllers[tab] { + return existing + } + let controller: UIViewController + switch tab { + case .poi: controller = PlanRoutePoiViewController(dataSource: dataProvider) + case .analyze: controller = PlanRouteAnalyzeViewController(dataSource: dataProvider) + case .route: controller = PlanRouteRouteViewController(dataSource: dataProvider) + } + tabViewControllers[tab] = controller + return controller + } + + private func selectTab(_ tab: PlanRouteTab) { + selectedTab = tab + let newController = tabViewController(for: tab) + guard newController !== currentTabViewController else { return } + currentTabViewController?.willMove(toParent: nil) + currentTabViewController?.view.removeFromSuperview() + currentTabViewController?.removeFromParent() + + addChild(newController) + newController.view.translatesAutoresizingMaskIntoConstraints = false + tabContainerView.addSubview(newController.view) + NSLayoutConstraint.activate([ + newController.view.topAnchor.constraint(equalTo: tabContainerView.topAnchor), + newController.view.leadingAnchor.constraint(equalTo: tabContainerView.leadingAnchor), + newController.view.trailingAnchor.constraint(equalTo: tabContainerView.trailingAnchor), + newController.view.bottomAnchor.constraint(equalTo: tabContainerView.bottomAnchor) + ]) + newController.didMove(toParent: self) + currentTabViewController = newController + } + + private func makeOptionsMenu() -> UIMenu { + let actions = PlanRouteMenuAction.actions(for: dataProvider.mode).map { action in + UIAction(title: action.title, + image: action.icon, + attributes: action.isDestructive ? .destructive : []) { [weak self] _ in + self?.handleMenuAction(action) + } + } + return UIMenu(children: actions) + } + + private func handleClose() { + guard dataProvider.hasChanges else { + hide() + return + } + let alert = UIAlertController(title: localizedString("exit_without_saving"), + message: nil, + preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: localizedString("shared_string_discard"), style: .destructive) { [weak self] _ in + self?.hide() + }) + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func handleSave() { + print("[PlanRoute] Save tapped") + } + + private func handleAddPoi() { + print("[PlanRoute] Add POI tapped") + } + + private func handleUndo() { + print("[PlanRoute] Undo tapped") + } + + private func handleRedo() { + print("[PlanRoute] Redo tapped") + } + + private func handleAddRoutePoint() { + print("[PlanRoute] Add route point tapped") + } + + private func handleMenuAction(_ action: PlanRouteMenuAction) { + print("[PlanRoute] Options menu action: \(action)") + } + + @objc private func onSegmentChanged() { + let index = segmentControl.selectedSegmentIndex + guard tabs.indices.contains(index) else { return } + let tab = tabs[index] + print("[PlanRoute] Segment switched to: \(tab)") + selectTab(tab) + if sheetState == .initial { + setState(.expanded, animated: true) + } + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard let sheetHeightConstraint else { return } + let translation = gesture.translation(in: view).y + switch gesture.state { + case .began: + panStartHeight = sheetHeightConstraint.constant + case .changed: + let lower = height(for: .initial) + let upper = height(for: .fullScreen) + sheetHeightConstraint.constant = min(max(panStartHeight - translation, lower), upper) + case .ended, .cancelled: + let velocity = gesture.velocity(in: view).y + setState(nearestState(for: sheetHeightConstraint.constant, velocity: velocity), animated: true) + default: + break + } + } +} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteModels.swift new file mode 100644 index 0000000000..1bf35b4a78 --- /dev/null +++ b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteModels.swift @@ -0,0 +1,177 @@ +// +// PlanRouteModels.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum PlanRouteMode { + case newRoute + case editTrack(fileName: String) + + var title: String { + switch self { + case .newRoute: localizedString("quick_action_new_route") + case let .editTrack(fileName): fileName + } + } + + var isNewRoute: Bool { + if case .newRoute = self { return true } + return false + } + + var isEditTrack: Bool { + !isNewRoute + } +} + +enum PlanRouteTab: Int, CaseIterable { + case poi + case analyze + case route + + var title: String { + switch self { + case .poi: localizedString("poi") + case .analyze: localizedString("gpx_analyze") + case .route: localizedString("layer_route") + } + } + + static var `default`: PlanRouteTab { + .route + } +} + +enum PlanRouteMenuAction: CaseIterable { + case saveAs + case saveAsCopy + case appendToExistingTrack + case changeSegmentOrder + case viewDirections + case reverseRoute + case navigation + case clearAllPoints + + var title: String { + switch self { + case .saveAs: localizedString("plan_route_save_as") + case .saveAsCopy: localizedString("save_as_copy") + case .appendToExistingTrack: localizedString("plan_route_append_to_existing_track") + case .changeSegmentOrder: localizedString("plan_route_change_segment_order") + case .viewDirections: localizedString("plan_route_view_directions") + case .reverseRoute: localizedString("reverse_route") + case .navigation: localizedString("shared_string_navigation") + case .clearAllPoints: localizedString("distance_measurement_clear_route") + } + } + + var icon: UIImage? { + switch self { + case .saveAs: .templateImageNamed("ic_custom_save_to_file") + case .saveAsCopy: .templateImageNamed("ic_custom_save_as_new_file") + case .appendToExistingTrack: .templateImageNamed("ic_custom_add_to_track") + case .changeSegmentOrder: .templateImageNamed("ic_custom_list") + case .viewDirections: .templateImageNamed("ic_custom_route_points") + case .reverseRoute: .templateImageNamed("ic_custom_change_object_position") + case .navigation: .templateImageNamed("ic_custom_navigation_outlined") + case .clearAllPoints: .templateImageNamed("ic_custom_trash_outlined") + } + } + + var isDestructive: Bool { + self == .clearAllPoints + } + + func isVisible(for mode: PlanRouteMode) -> Bool { + switch self { + case .saveAsCopy: mode.isEditTrack + default: true + } + } + + static func actions(for mode: PlanRouteMode) -> [PlanRouteMenuAction] { + allCases.filter { $0.isVisible(for: mode) } + } +} + +struct PlanRouteInfo { + let isNewRoute: Bool + let isStraightLine: Bool + let hasRoute: Bool + let totalDistance: Double + let duration: TimeInterval + let arrivalTime: Date? + let uphill: Double + let downhill: Double + let mapCenterDistance: Double + let bearing: Double + + var showsTime: Bool { + !isNewRoute && !isStraightLine && duration > 0 + } + + static var empty: PlanRouteInfo { + PlanRouteInfo(isNewRoute: true, + isStraightLine: false, + hasRoute: false, + totalDistance: 0, + duration: 0, + arrivalTime: nil, + uphill: 0, + downhill: 0, + mapCenterDistance: 0, + bearing: 0) + } +} + +struct PlanRoutePoint { + let index: Int + let name: String + let distanceFromPrevious: Double + let bearing: Double + let isStart: Bool + let isDestination: Bool +} + +struct PlanRouteSegment { + let index: Int + let points: [PlanRoutePoint] +} + +struct PlanRouteElevationData { + let uphill: Double + let downhill: Double + let elevations: [Double] +} + +protocol PlanRoutePoiDataSource: AnyObject { + var poiPoints: [PlanRoutePoint] { get } +} + +protocol PlanRouteAnalyzeDataSource: AnyObject { + var routeInfo: PlanRouteInfo { get } + var elevationData: PlanRouteElevationData? { get } +} + +protocol PlanRoutePointsDataSource: AnyObject { + var routeInfo: PlanRouteInfo { get } + var segments: [PlanRouteSegment] { get } + var routePoints: [PlanRoutePoint] { get } +} + +protocol PlanRouteDataProvider: PlanRoutePoiDataSource, PlanRouteAnalyzeDataSource, PlanRoutePointsDataSource { + var mode: PlanRouteMode { get } + var hasChanges: Bool { get } + var canUndo: Bool { get } + var canRedo: Bool { get } +} + +protocol PlanRouteTabContent: AnyObject { + var planRouteTab: PlanRouteTab { get } + func reloadData() +} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteStubDataProvider.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteStubDataProvider.swift new file mode 100644 index 0000000000..0342b1470f --- /dev/null +++ b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteStubDataProvider.swift @@ -0,0 +1,58 @@ +// +// PlanRouteStubDataProvider.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteStubDataProvider: PlanRouteDataProvider { + let mode: PlanRouteMode + + init(mode: PlanRouteMode = .newRoute) { + self.mode = mode + } + + var hasChanges: Bool { + false + } + + var canUndo: Bool { + false + } + + var canRedo: Bool { + false + } + + var routeInfo: PlanRouteInfo { + PlanRouteInfo(isNewRoute: mode.isNewRoute, + isStraightLine: false, + hasRoute: !routePoints.isEmpty, + totalDistance: 0, + duration: 0, + arrivalTime: nil, + uphill: 0, + downhill: 0, + mapCenterDistance: 0, + bearing: 100) + } + + var elevationData: PlanRouteElevationData? { + nil + } + + var poiPoints: [PlanRoutePoint] { + [] + } + + var routePoints: [PlanRoutePoint] { + [] + } + + var segments: [PlanRouteSegment] { + [] + } +} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteToolbarsView.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteToolbarsView.swift new file mode 100644 index 0000000000..71a965b84a --- /dev/null +++ b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteToolbarsView.swift @@ -0,0 +1,176 @@ +// +// PlanRouteToolbarsView.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteTopToolbarView: UIView { + static let contentHeight: CGFloat = 56 + + private static let edgeInset: CGFloat = 16 + private static let buttonSpacing: CGFloat = 8 + + var onClose: (() -> Void)? + var onSave: (() -> Void)? + + var titleText: String? { + didSet { titleLabel.text = titleText } + } + + var optionsMenu: UIMenu? { + didSet { + optionsButton.menu = optionsMenu + optionsButton.showsMenuAsPrimaryAction = optionsMenu != nil + } + } + + var isSaveButtonVisible = true { + didSet { saveButton.isHidden = !isSaveButtonVisible } + } + + private let titleLabel = UILabel() + private let closeButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_close")) + private let optionsButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_overflow_menu_stroke")) + + private lazy var saveButton = PlanRouteButtonFactory.primaryButton(title: localizedString("shared_string_save")) + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let hitView = super.hitTest(point, with: event) else { return nil } + return hitView is UIControl ? hitView : nil + } + + private func setupView() { + backgroundColor = .clear + + titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .semibold, maximumSize: 22) + titleLabel.textColor = .textColorPrimary + titleLabel.textAlignment = .center + titleLabel.adjustsFontForContentSizeCategory = true + + let trailingStack = UIStackView(arrangedSubviews: [optionsButton, saveButton]) + trailingStack.spacing = Self.buttonSpacing + trailingStack.alignment = .center + + [closeButton, titleLabel, trailingStack].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + + let inset = Self.edgeInset + NSLayoutConstraint.activate([ + closeButton.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: inset), + closeButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: Self.buttonSpacing), + + trailingStack.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -inset), + trailingStack.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), + + titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + titleLabel.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), + titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: closeButton.trailingAnchor, constant: Self.buttonSpacing), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingStack.leadingAnchor, constant: -Self.buttonSpacing) + ]) + + closeButton.addTarget(self, action: #selector(onCloseTapped), for: .touchUpInside) + saveButton.addTarget(self, action: #selector(onSaveTapped), for: .touchUpInside) + } + + @objc private func onCloseTapped() { + onClose?() + } + + @objc private func onSaveTapped() { + onSave?() + } +} + +final class PlanRouteBottomToolbarView: UIView { + private static let edgeInset: CGFloat = 16 + private static let buttonSpacing: CGFloat = 8 + + var onAddPoi: (() -> Void)? + var onUndo: (() -> Void)? + var onRedo: (() -> Void)? + var onAddRoutePoint: (() -> Void)? + + var isUndoEnabled = false { + didSet { undoButton.isEnabled = isUndoEnabled } + } + + var isRedoEnabled = false { + didSet { redoButton.isEnabled = isRedoEnabled } + } + + private let undoButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_undo"), size: PlanRouteButtonFactory.bottomButtonHeight) + private let redoButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_redo"), size: PlanRouteButtonFactory.bottomButtonHeight) + + private lazy var addPoiButton = PlanRouteButtonFactory.labeledButton(title: localizedString("poi"), image: .templateImageNamed("ic_custom_add")) + private lazy var routeButton = PlanRouteButtonFactory.labeledButton(title: localizedString("layer_route"), image: .templateImageNamed("ic_custom_add")) + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + backgroundColor = .clear + + let centerStack = UIStackView(arrangedSubviews: [undoButton, redoButton]) + centerStack.spacing = Self.buttonSpacing + centerStack.alignment = .center + + [addPoiButton, centerStack, routeButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + + let inset = Self.edgeInset + NSLayoutConstraint.activate([ + addPoiButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), + addPoiButton.topAnchor.constraint(equalTo: topAnchor), + + routeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), + routeButton.centerYAnchor.constraint(equalTo: addPoiButton.centerYAnchor), + + centerStack.centerXAnchor.constraint(equalTo: centerXAnchor), + centerStack.centerYAnchor.constraint(equalTo: addPoiButton.centerYAnchor) + ]) + + addPoiButton.addTarget(self, action: #selector(onAddPoiTapped), for: .touchUpInside) + undoButton.addTarget(self, action: #selector(onUndoTapped), for: .touchUpInside) + redoButton.addTarget(self, action: #selector(onRedoTapped), for: .touchUpInside) + routeButton.addTarget(self, action: #selector(onRouteTapped), for: .touchUpInside) + } + + @objc private func onAddPoiTapped() { + onAddPoi?() + } + + @objc private func onUndoTapped() { + onUndo?() + } + + @objc private func onRedoTapped() { + onRedo?() + } + + @objc private func onRouteTapped() { + onAddRoutePoint?() + } +} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteTopPartView.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteTopPartView.swift new file mode 100644 index 0000000000..1979219909 --- /dev/null +++ b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteTopPartView.swift @@ -0,0 +1,132 @@ +// +// PlanRouteTopPartView.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteTopPartView: UIView { + private static let statusIconSize: CGFloat = 30 + private static let horizontalInset: CGFloat = 20 + + var onTap: (() -> Void)? + + private let statusIconView = UIImageView() + private let firstLineLabel = UILabel() + private let secondLineLabel = UILabel() + private let textStackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with info: PlanRouteInfo) { + firstLineLabel.attributedText = makeFirstLine(info) + secondLineLabel.attributedText = makeSecondLine(info) + } + + private func setupView() { + backgroundColor = .clear + + statusIconView.image = .templateImageNamed("ic_custom_plan_route") + statusIconView.tintColor = .iconColorActive + statusIconView.contentMode = .scaleAspectFit + statusIconView.translatesAutoresizingMaskIntoConstraints = false + addSubview(statusIconView) + + firstLineLabel.numberOfLines = 1 + firstLineLabel.adjustsFontForContentSizeCategory = true + secondLineLabel.numberOfLines = 1 + secondLineLabel.adjustsFontForContentSizeCategory = true + + textStackView.axis = .vertical + textStackView.spacing = 2 + textStackView.alignment = .leading + textStackView.addArrangedSubview(firstLineLabel) + textStackView.addArrangedSubview(secondLineLabel) + textStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(textStackView) + + let horizontalInset = Self.horizontalInset + let statusIconSize = Self.statusIconSize + + NSLayoutConstraint.activate([ + statusIconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset), + statusIconView.centerYAnchor.constraint(equalTo: centerYAnchor), + statusIconView.widthAnchor.constraint(equalToConstant: statusIconSize), + statusIconView.heightAnchor.constraint(equalToConstant: statusIconSize), + + textStackView.centerYAnchor.constraint(equalTo: centerYAnchor), + textStackView.leadingAnchor.constraint(equalTo: statusIconView.trailingAnchor, constant: 12), + textStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset) + ]) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(onViewTapped)) + addGestureRecognizer(tapRecognizer) + } + + private func makeFirstLine(_ info: PlanRouteInfo) -> NSAttributedString { + let bodyFont = UIFont.preferredFont(forTextStyle: .body) + let primary: [NSAttributedString.Key: Any] = [.font: bodyFont, .foregroundColor: UIColor.textColorPrimary] + let secondary: [NSAttributedString.Key: Any] = [.font: bodyFont, .foregroundColor: UIColor.textColorSecondary] + + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: formattedDistance(info.totalDistance), attributes: primary)) + + guard info.showsTime else { return result } + + result.append(NSAttributedString(string: " • ", attributes: secondary)) + result.append(NSAttributedString(string: formattedDuration(info.duration), attributes: secondary)) + if let arrival = info.arrivalTime { + result.append(NSAttributedString(string: " (\(formattedTime(arrival)))", attributes: secondary)) + } + return result + } + + private func makeSecondLine(_ info: PlanRouteInfo) -> NSAttributedString { + let subheadFont = UIFont.preferredFont(forTextStyle: .subheadline) + let attributes: [NSAttributedString.Key: Any] = [.font: subheadFont, .foregroundColor: UIColor.textColorSecondary] + + let result = NSMutableAttributedString() + result.append(symbolAttachment("arrow.up.right", font: subheadFont)) + result.append(NSAttributedString(string: " \(formattedDistance(info.uphill)) ", attributes: attributes)) + result.append(symbolAttachment("arrow.down.right", font: subheadFont)) + result.append(NSAttributedString(string: " \(formattedDistance(info.downhill))", attributes: attributes)) + result.append(NSAttributedString(string: " | ", attributes: attributes)) + result.append(NSAttributedString(string: "\(formattedDistance(info.mapCenterDistance)) • \(Int(info.bearing))°", attributes: attributes)) + return result + } + + private func symbolAttachment(_ name: String, font: UIFont) -> NSAttributedString { + let attachment = NSTextAttachment() + let configuration = UIImage.SymbolConfiguration(font: font) + attachment.image = UIImage(systemName: name, withConfiguration: configuration)?.withTintColor(.textColorSecondary, renderingMode: .alwaysOriginal) + return NSAttributedString(attachment: attachment) + } + + private func formattedDistance(_ meters: Double) -> String { + OAOsmAndFormatter.getFormattedDistance(Float(meters)) + } + + private func formattedDuration(_ duration: TimeInterval) -> String { + OAOsmAndFormatter.getFormattedTimeInterval(duration, shortFormat: true) + } + + private func formattedTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: date) + } + + @objc private func onViewTapped() { + onTap?() + } +} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift b/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift new file mode 100644 index 0000000000..7d70fd1db9 --- /dev/null +++ b/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift @@ -0,0 +1,50 @@ +// +// PlanRouteAnalyzeViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteAnalyzeViewController: UIViewController, PlanRouteTabContent { + let planRouteTab: PlanRouteTab = .analyze + + private weak var dataSource: PlanRouteAnalyzeDataSource? + + private let placeholderLabel = UILabel() + + init(dataSource: PlanRouteAnalyzeDataSource?) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupPlaceholder() + reloadData() + } + + func reloadData() { + guard isViewLoaded else { return } + placeholderLabel.text = planRouteTab.title + } + + private func setupPlaceholder() { + view.backgroundColor = .clear + placeholderLabel.font = .preferredFont(forTextStyle: .body) + placeholderLabel.textColor = .textColorSecondary + placeholderLabel.textAlignment = .center + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(placeholderLabel) + NSLayoutConstraint.activate([ + placeholderLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + placeholderLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRoutePoiViewController.swift b/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRoutePoiViewController.swift new file mode 100644 index 0000000000..29d95ac07f --- /dev/null +++ b/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRoutePoiViewController.swift @@ -0,0 +1,50 @@ +// +// PlanRoutePoiViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRoutePoiViewController: UIViewController, PlanRouteTabContent { + let planRouteTab: PlanRouteTab = .poi + + private weak var dataSource: PlanRoutePoiDataSource? + + private let placeholderLabel = UILabel() + + init(dataSource: PlanRoutePoiDataSource?) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupPlaceholder() + reloadData() + } + + func reloadData() { + guard isViewLoaded else { return } + placeholderLabel.text = planRouteTab.title + } + + private func setupPlaceholder() { + view.backgroundColor = .clear + placeholderLabel.font = .preferredFont(forTextStyle: .body) + placeholderLabel.textColor = .textColorSecondary + placeholderLabel.textAlignment = .center + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(placeholderLabel) + NSLayoutConstraint.activate([ + placeholderLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + placeholderLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteRouteViewController.swift b/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteRouteViewController.swift new file mode 100644 index 0000000000..ca6e60cc94 --- /dev/null +++ b/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteRouteViewController.swift @@ -0,0 +1,276 @@ +// +// PlanRouteRouteViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent { + let planRouteTab: PlanRouteTab = .route + + private weak var dataSource: PlanRoutePointsDataSource? + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private var points: [PlanRoutePoint] = [] + + init(dataSource: PlanRoutePointsDataSource?) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupTableView() + reloadData() + } + + func reloadData() { + guard isViewLoaded else { return } + points = dataSource?.routePoints ?? [] + tableView.reloadData() + } + + private func setupTableView() { + view.backgroundColor = .clear + tableView.backgroundColor = .clear + tableView.dataSource = self + tableView.delegate = self + tableView.separatorInset = UIEdgeInsets(top: 0, left: 76, bottom: 0, right: 0) + tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 72, right: 0) + tableView.register(PlanRoutePointCell.self, forCellReuseIdentifier: PlanRoutePointCell.cellReuseId) + tableView.register(PlanRouteEmptyCell.self, forCellReuseIdentifier: PlanRouteEmptyCell.cellReuseId) + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } +} + +// MARK: - UITableViewDataSource +extension PlanRouteRouteViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + points.isEmpty ? 1 : 2 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? max(points.count, 1) : 1 + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? localizedString("route_points") : nil + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if indexPath.section == 0, points.isEmpty { + guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRouteEmptyCell.cellReuseId, for: indexPath) as? PlanRouteEmptyCell else { + return UITableViewCell() + } + return cell + } + if indexPath.section == 1 { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.textLabel?.text = localizedString("gpx_start_new_segment") + cell.textLabel?.textColor = .iconColorActive + cell.textLabel?.font = .scaledSystemFont(ofSize: 17) + cell.backgroundColor = .groupBg + return cell + } + guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRoutePointCell.cellReuseId, for: indexPath) as? PlanRoutePointCell else { + return UITableViewCell() + } + cell.configure(with: points[indexPath.row]) + cell.onDelete = { [weak self] in + print("[PlanRoute] Delete point at index: \(indexPath.row)") + self?.reloadData() + } + return cell + } +} + +// MARK: - UITableViewDelegate +extension PlanRouteRouteViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if indexPath.section == 1 { + print("[PlanRoute] Start new segment tapped") + } else if !points.isEmpty { + print("[PlanRoute] Selected point at index: \(indexPath.row)") + } + } +} + +final class PlanRoutePointCell: UITableViewCell { + static let cellReuseId = "PlanRoutePointCell" + + private static let circleSize: CGFloat = 28 + private static let deleteSize: CGFloat = 24 + + var onDelete: (() -> Void)? + + private let deleteButton = UIButton(type: .system) + private let numberLabel = UILabel() + private let numberContainer = UIView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let dragHandleView = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with point: PlanRoutePoint) { + numberLabel.text = "\(point.index + 1)" + titleLabel.text = point.name + subtitleLabel.text = subtitle(for: point) + } + + private func setupCell() { + backgroundColor = .groupBg + selectionStyle = .none + + deleteButton.setImage(UIImage(systemName: "minus.circle.fill"), for: .normal) + deleteButton.tintColor = .systemRed + deleteButton.addTarget(self, action: #selector(onDeleteTapped), for: .touchUpInside) + + numberContainer.layer.cornerRadius = Self.circleSize / 2 + numberContainer.layer.borderWidth = 2 + numberContainer.layer.borderColor = UIColor.iconColorActive.cgColor + numberLabel.font = .scaledSystemFont(ofSize: 13, weight: .semibold) + numberLabel.textColor = .iconColorActive + numberLabel.textAlignment = .center + numberLabel.translatesAutoresizingMaskIntoConstraints = false + numberContainer.addSubview(numberLabel) + + titleLabel.font = .scaledSystemFont(ofSize: 17) + titleLabel.textColor = .textColorPrimary + subtitleLabel.font = .scaledSystemFont(ofSize: 13) + subtitleLabel.textColor = .textColorSecondary + + let textStack = UIStackView(arrangedSubviews: [subtitleLabel, titleLabel]) + textStack.axis = .vertical + textStack.spacing = 2 + + dragHandleView.image = UIImage(systemName: "line.3.horizontal") + dragHandleView.tintColor = .iconColorTertiary + dragHandleView.contentMode = .scaleAspectFit + + [deleteButton, numberContainer, textStack, dragHandleView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + NSLayoutConstraint.activate([ + deleteButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + deleteButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + deleteButton.widthAnchor.constraint(equalToConstant: Self.deleteSize), + deleteButton.heightAnchor.constraint(equalToConstant: Self.deleteSize), + + numberContainer.leadingAnchor.constraint(equalTo: deleteButton.trailingAnchor, constant: 12), + numberContainer.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + numberContainer.widthAnchor.constraint(equalToConstant: Self.circleSize), + numberContainer.heightAnchor.constraint(equalToConstant: Self.circleSize), + numberLabel.centerXAnchor.constraint(equalTo: numberContainer.centerXAnchor), + numberLabel.centerYAnchor.constraint(equalTo: numberContainer.centerYAnchor), + + textStack.leadingAnchor.constraint(equalTo: numberContainer.trailingAnchor, constant: 12), + textStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + textStack.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 8), + + dragHandleView.leadingAnchor.constraint(greaterThanOrEqualTo: textStack.trailingAnchor, constant: 12), + dragHandleView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + dragHandleView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + dragHandleView.widthAnchor.constraint(equalToConstant: 24), + dragHandleView.heightAnchor.constraint(equalToConstant: 24) + ]) + } + + private func subtitle(for point: PlanRoutePoint) -> String { + if point.isStart { + return localizedString("starting_point") + } + let distance = OAOsmAndFormatter.getFormattedDistance(Float(point.distanceFromPrevious)) + if point.isDestination { + return "\(distance) • \(localizedString("route_descr_destination"))" + } + return "\(distance) • \(Int(point.bearing))°" + } + + @objc private func onDeleteTapped() { + onDelete?() + } +} + +final class PlanRouteEmptyCell: UITableViewCell { + static let cellReuseId = "PlanRouteEmptyCell" + + private static let iconSize: CGFloat = 30 + + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + private let iconView = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupCell() { + backgroundColor = .groupBg + selectionStyle = .none + + titleLabel.text = localizedString("plan_route_no_points_title") + titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .medium) + titleLabel.textColor = .textColorPrimary + titleLabel.numberOfLines = 0 + + descriptionLabel.text = localizedString("plan_route_no_points_descr") + descriptionLabel.font = .scaledSystemFont(ofSize: 15) + descriptionLabel.textColor = .textColorSecondary + descriptionLabel.numberOfLines = 0 + + iconView.image = .templateImageNamed("ic_custom_plan_route") + iconView.tintColor = .iconColorActive + iconView.contentMode = .scaleAspectFit + + let textStack = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel]) + textStack.axis = .vertical + textStack.spacing = 6 + + [textStack, iconView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + NSLayoutConstraint.activate([ + textStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + textStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + textStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16), + + iconView.leadingAnchor.constraint(equalTo: textStack.trailingAnchor, constant: 12), + iconView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + iconView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + iconView.widthAnchor.constraint(equalToConstant: Self.iconSize), + iconView.heightAnchor.constraint(equalToConstant: Self.iconSize) + ]) + } +} diff --git a/Sources/QuickAction/Actions/RouteAction.swift b/Sources/QuickAction/Actions/RouteAction.swift index ba01e1c468..fc4195a4f6 100644 --- a/Sources/QuickAction/Actions/RouteAction.swift +++ b/Sources/QuickAction/Actions/RouteAction.swift @@ -36,6 +36,6 @@ final class RouteAction: OAQuickAction { } override func execute() { - OARootViewController.instance().mapPanel.showScrollableHudViewController(OARoutePlanningHudViewController()) + PlanRouteContainerViewController.showNewRoute() } } From 5fe4124373787e9abef6618b6976855c4dc732bc Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Tue, 16 Jun 2026 12:58:01 +0200 Subject: [PATCH 07/47] [WIP] 1323 --- .../Panels/OAMapPanelViewController.mm | 2 +- .../PlanRoute/PlanRouteButtonFactory.swift | 6 +- ...> PlanRouteScrollableViewController.swift} | 20 +- .../PlanRoute/PlanRouteToolbarsView.swift | 10 +- ...lRoutePlanningBottomSheetViewController.mm | 10 +- .../PlanRoute/PlanRouteButtonFactory.swift | 76 --- .../PlanRouteContainerViewController.swift | 435 ------------------ .../PlanRoute/PlanRouteModels.swift | 177 ------- .../PlanRoute/PlanRouteStubDataProvider.swift | 58 --- .../PlanRoute/PlanRouteToolbarsView.swift | 176 ------- .../PlanRoute/PlanRouteTopPartView.swift | 132 ------ .../Tabs/PlanRouteAnalyzeViewController.swift | 50 -- .../Tabs/PlanRoutePoiViewController.swift | 50 -- .../Tabs/PlanRouteRouteViewController.swift | 276 ----------- Sources/QuickAction/Actions/RouteAction.swift | 2 +- 15 files changed, 31 insertions(+), 1449 deletions(-) rename Sources/Controllers/PlanRoute/{PlanRouteContainerViewController.swift => PlanRouteScrollableViewController.swift} (96%) delete mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteButtonFactory.swift delete mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteContainerViewController.swift delete mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteModels.swift delete mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteStubDataProvider.swift delete mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteToolbarsView.swift delete mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteTopPartView.swift delete mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift delete mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRoutePoiViewController.swift delete mode 100644 Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteRouteViewController.swift diff --git a/Sources/Controllers/Panels/OAMapPanelViewController.mm b/Sources/Controllers/Panels/OAMapPanelViewController.mm index 9c36970076..5aa59fc0ac 100644 --- a/Sources/Controllers/Panels/OAMapPanelViewController.mm +++ b/Sources/Controllers/Panels/OAMapPanelViewController.mm @@ -407,7 +407,7 @@ - (void) showScrollableHudViewController:(OABaseScrollableHudViewController *)co self.sidePanelController.recognizesPanGesture = NO; - if ([controller isKindOfClass:OARoutePlanningHudViewController.class] || [controller isKindOfClass:PlanRouteContainerViewController.class]) + if ([controller isKindOfClass:OARoutePlanningHudViewController.class] || [controller isKindOfClass:PlanRouteScrollableViewController.class]) _activeTargetType = OATargetRoutePlanning; else if ([controller isKindOfClass:OARouteLineAppearanceHudViewController.class]) _activeTargetType = OATargetRouteLineAppearance; diff --git a/Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift b/Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift index 1d9ef872f7..a786077b27 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift @@ -15,7 +15,7 @@ enum PlanRouteButtonFactory { static func iconButton(image: UIImage?, size: CGFloat = toolbarButtonSize) -> UIButton { var configuration = UIButton.Configuration.plain() configuration.image = image - configuration.baseForegroundColor = .iconColorBlack + configuration.baseForegroundColor = .mapButtonIconColorDefault configuration.background.backgroundColor = .mapButtonBgColorDefault configuration.background.cornerRadius = size / 2 let button = UIButton(configuration: configuration) @@ -69,8 +69,8 @@ enum PlanRouteButtonFactory { private static func applyShadow(to button: UIButton) { button.layer.shadowColor = UIColor.black.cgColor - button.layer.shadowOpacity = 0.12 + button.layer.shadowOpacity = 0.35 button.layer.shadowOffset = CGSize(width: 0, height: 2) - button.layer.shadowRadius = 4 + button.layer.shadowRadius = 5 } } diff --git a/Sources/Controllers/PlanRoute/PlanRouteContainerViewController.swift b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift similarity index 96% rename from Sources/Controllers/PlanRoute/PlanRouteContainerViewController.swift rename to Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift index c58bf51533..d37e41a54e 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteContainerViewController.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift @@ -1,5 +1,5 @@ // -// PlanRouteContainerViewController.swift +// PlanRouteScrollableViewController.swift // OsmAnd Maps // // Created by OsmAnd on 15.06.2026. @@ -8,7 +8,7 @@ import UIKit -final class PlanRouteContainerViewController: OABaseScrollableHudViewController { +final class PlanRouteScrollableViewController: OABaseScrollableHudViewController { private static let topPartHeight: CGFloat = 50 private static let grabberAreaHeight: CGFloat = 16 private static let segmentedControlHeight: CGFloat = 36 @@ -45,9 +45,19 @@ final class PlanRouteContainerViewController: OABaseScrollableHudViewController fatalError("init(coder:) has not been implemented") } - @objc static func showNewRoute() { - let provider = PlanRouteStubDataProvider(mode: .newRoute) - let controller = PlanRouteContainerViewController(dataProvider: provider) + @objc(showNewRoute) + static func showNewRoute() { + showPlanRoute(dataProvider: PlanRouteStubDataProvider(mode: .newRoute)) + } + + @objc(openExistingTrackWithFilePath:) + static func openExistingTrack(filePath: String) { + let fileName = ((filePath as NSString).lastPathComponent as NSString).deletingPathExtension + showPlanRoute(dataProvider: PlanRouteStubDataProvider(mode: .editTrack(fileName: fileName))) + } + + private static func showPlanRoute(dataProvider: PlanRouteDataProvider) { + let controller = PlanRouteScrollableViewController(dataProvider: dataProvider) OARootViewController.instance().mapPanel?.showScrollableHudViewController(controller) } diff --git a/Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift b/Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift index 71a965b84a..5f80ec260a 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift @@ -33,7 +33,7 @@ final class PlanRouteTopToolbarView: UIView { } private let titleLabel = UILabel() - private let closeButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_close")) + private let closeButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_navbar_close")) private let optionsButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_overflow_menu_stroke")) private lazy var saveButton = PlanRouteButtonFactory.primaryButton(title: localizedString("shared_string_save")) @@ -57,8 +57,11 @@ final class PlanRouteTopToolbarView: UIView { titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .semibold, maximumSize: 22) titleLabel.textColor = .textColorPrimary - titleLabel.textAlignment = .center + titleLabel.textAlignment = .natural titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.numberOfLines = 1 + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let trailingStack = UIStackView(arrangedSubviews: [optionsButton, saveButton]) trailingStack.spacing = Self.buttonSpacing @@ -77,9 +80,8 @@ final class PlanRouteTopToolbarView: UIView { trailingStack.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -inset), trailingStack.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), - titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor), titleLabel.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), - titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: closeButton.trailingAnchor, constant: Self.buttonSpacing), + titleLabel.leadingAnchor.constraint(equalTo: closeButton.trailingAnchor, constant: 12), titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingStack.leadingAnchor, constant: -Self.buttonSpacing) ]) diff --git a/Sources/Controllers/RoutePlanning/InitialRoutePlanningBottomSheetViewController.mm b/Sources/Controllers/RoutePlanning/InitialRoutePlanningBottomSheetViewController.mm index f38111edf8..d64f27bdbb 100644 --- a/Sources/Controllers/RoutePlanning/InitialRoutePlanningBottomSheetViewController.mm +++ b/Sources/Controllers/RoutePlanning/InitialRoutePlanningBottomSheetViewController.mm @@ -256,7 +256,7 @@ - (void) tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath if ([key isEqualToString:@"create_new_route"]) { [self hide:YES completion:^{ - [PlanRouteContainerViewController showNewRoute]; + [PlanRouteScrollableViewController showNewRoute]; }]; return; } @@ -271,9 +271,9 @@ - (void) tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath else if ([key isEqualToString:@"gpx_route"]) { OASGpxDataItem *track = item[@"track"]; - [self hide:YES]; - [[OARootViewController instance].mapPanel showScrollableHudViewController: - [[OARoutePlanningHudViewController alloc] initWithFileName:track.gpxFilePath]]; + [self hide:YES completion:^{ + [PlanRouteScrollableViewController openExistingTrackWithFilePath:track.gpxFilePath]; + }]; return; } } @@ -297,7 +297,7 @@ - (void) closeBottomSheet - (void)onFileSelected:(NSString *)gpxFilePath { - [[OARootViewController instance].mapPanel showScrollableHudViewController:[[OARoutePlanningHudViewController alloc] initWithFileName:gpxFilePath]]; + [PlanRouteScrollableViewController openExistingTrackWithFilePath:gpxFilePath]; } @end diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteButtonFactory.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteButtonFactory.swift deleted file mode 100644 index 1d9ef872f7..0000000000 --- a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteButtonFactory.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// PlanRouteButtonFactory.swift -// OsmAnd Maps -// -// Created by OsmAnd on 15.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import UIKit - -enum PlanRouteButtonFactory { - static let toolbarButtonSize: CGFloat = 48 - static let bottomButtonHeight: CGFloat = OAUtilities.isIPad() ? 48 : 44 - - static func iconButton(image: UIImage?, size: CGFloat = toolbarButtonSize) -> UIButton { - var configuration = UIButton.Configuration.plain() - configuration.image = image - configuration.baseForegroundColor = .iconColorBlack - configuration.background.backgroundColor = .mapButtonBgColorDefault - configuration.background.cornerRadius = size / 2 - let button = UIButton(configuration: configuration) - button.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - button.widthAnchor.constraint(equalToConstant: size), - button.heightAnchor.constraint(equalToConstant: size) - ]) - applyPressedState(to: button) - applyShadow(to: button) - return button - } - - static func labeledButton(title: String, image: UIImage?, height: CGFloat = bottomButtonHeight) -> UIButton { - var configuration = UIButton.Configuration.plain() - configuration.title = title - configuration.image = image - configuration.imagePadding = 6 - configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14) - configuration.baseForegroundColor = .textColorPrimary - configuration.background.backgroundColor = .mapButtonBgColorDefault - configuration.background.cornerRadius = height / 2 - let button = UIButton(configuration: configuration) - button.translatesAutoresizingMaskIntoConstraints = false - button.heightAnchor.constraint(equalToConstant: height).isActive = true - applyPressedState(to: button) - applyShadow(to: button) - return button - } - - static func primaryButton(title: String, height: CGFloat = toolbarButtonSize) -> UIButton { - var configuration = UIButton.Configuration.filled() - configuration.title = title - configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18) - configuration.baseForegroundColor = .white - configuration.baseBackgroundColor = .buttonBgColorPrimary - configuration.background.cornerRadius = height / 2 - let button = UIButton(configuration: configuration) - button.translatesAutoresizingMaskIntoConstraints = false - button.heightAnchor.constraint(equalToConstant: height).isActive = true - return button - } - - private static func applyPressedState(to button: UIButton) { - button.configurationUpdateHandler = { button in - var updated = button.configuration - updated?.background.backgroundColor = button.isHighlighted ? .mapButtonBgColorTap : .mapButtonBgColorDefault - button.configuration = updated - } - } - - private static func applyShadow(to button: UIButton) { - button.layer.shadowColor = UIColor.black.cgColor - button.layer.shadowOpacity = 0.12 - button.layer.shadowOffset = CGSize(width: 0, height: 2) - button.layer.shadowRadius = 4 - } -} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteContainerViewController.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteContainerViewController.swift deleted file mode 100644 index c58bf51533..0000000000 --- a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteContainerViewController.swift +++ /dev/null @@ -1,435 +0,0 @@ -// -// PlanRouteContainerViewController.swift -// OsmAnd Maps -// -// Created by OsmAnd on 15.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import UIKit - -final class PlanRouteContainerViewController: OABaseScrollableHudViewController { - private static let topPartHeight: CGFloat = 50 - private static let grabberAreaHeight: CGFloat = 16 - private static let segmentedControlHeight: CGFloat = 36 - private static let bottomToolbarAreaHeight: CGFloat = 60 - private static let horizontalInset: CGFloat = 16 - private static let cornerRadius: CGFloat = 16 - private static let fullScreenTopGap: CGFloat = 8 - private static let animationDuration: TimeInterval = 0.3 - - private let dataProvider: PlanRouteDataProvider - - private let sheetView = UIView() - private let grabberView = UIView() - private let topToolbar = PlanRouteTopToolbarView() - private let bottomToolbar = PlanRouteBottomToolbarView() - private let topPartView = PlanRouteTopPartView() - private let segmentControl = UISegmentedControl() - private let tabContainerView = UIView() - - private let tabs = PlanRouteTab.allCases - private var sheetState: EOADraggableMenuState = .expanded - private var selectedTab: PlanRouteTab = .default - private var tabViewControllers: [PlanRouteTab: UIViewController] = [:] - private var sheetHeightConstraint: NSLayoutConstraint? - private var panStartHeight: CGFloat = 0 - private weak var currentTabViewController: UIViewController? - - init(dataProvider: PlanRouteDataProvider) { - self.dataProvider = dataProvider - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @objc static func showNewRoute() { - let provider = PlanRouteStubDataProvider(mode: .newRoute) - let controller = PlanRouteContainerViewController(dataProvider: provider) - OARootViewController.instance().mapPanel?.showScrollableHudViewController(controller) - } - - override func loadView() { - let root = OAUserInteractionPassThroughView() - root.isScreenClickable = true - view = root - } - - override func viewDidLoad() { - setupSheet() - setupTopPart() - setupBottomToolbar() - setupContent() - setupTopToolbar() - selectTab(.default) - reloadData() - } - - override func viewWillAppear(_ animated: Bool) { - navigationController?.setNavigationBarHidden(true, animated: false) - applyHeight(for: sheetState) - tabContainerView.alpha = isContentVisible(in: sheetState) ? 1 : 0 - view.layoutIfNeeded() - if animated { - sheetView.transform = CGAffineTransform(translationX: 0, y: height(for: sheetState)) - UIView.animate(withDuration: Self.animationDuration) { [weak self] in - self?.sheetView.transform = .identity - } - } - refreshMapControls() - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - coordinator.animate { [weak self] _ in - guard let self else { return } - applyHeight(for: sheetState) - refreshMapControls() - } - } - - override var preferredStatusBarStyle: UIStatusBarStyle { - OAAppSettings.sharedManager().nightMode ? .lightContent : .default - } - - override func getViewHeight() -> CGFloat { - mapControlsReservedHeight(for: sheetState) - } - - override func getViewHeight(_ state: EOADraggableMenuState) -> CGFloat { - mapControlsReservedHeight(for: state) - } - - override func getNavbarHeight() -> CGFloat { - OAUtilities.getStatusBarHeight() + PlanRouteTopToolbarView.contentHeight - } - - override func getToolbarHeight() -> CGFloat { - Self.bottomToolbarAreaHeight - } - - override func getLandscapeViewWidth() -> CGFloat { - view.bounds.width - } - - override func hide() { - hide(true, duration: Self.animationDuration, onComplete: nil) - } - - override func forceHide() { - hide(false, duration: 0, onComplete: nil) - } - - override func hide(_ animated: Bool, duration: TimeInterval, onComplete: (() -> Void)!) { - let dismiss: () -> Void = { [weak self] in - OARootViewController.instance().mapPanel?.hideScrollableHudViewController() - self?.removeFromParent() - self?.view.removeFromSuperview() - onComplete?() - } - guard animated else { - dismiss() - return - } - UIView.animate(withDuration: duration, animations: { [weak self] in - guard let self else { return } - sheetView.transform = CGAffineTransform(translationX: 0, y: height(for: sheetState)) - }, completion: { _ in dismiss() }) - } - - func reloadData() { - topPartView.configure(with: dataProvider.routeInfo) - currentTabViewController.flatMap { $0 as? PlanRouteTabContent }?.reloadData() - } - - private func setupSheet() { - sheetView.backgroundColor = .groupBg - sheetView.layer.cornerRadius = Self.cornerRadius - sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - sheetView.clipsToBounds = true - sheetView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(sheetView) - let heightConstraint = sheetView.heightAnchor.constraint(equalToConstant: height(for: sheetState)) - sheetHeightConstraint = heightConstraint - NSLayoutConstraint.activate([ - sheetView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - sheetView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - sheetView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - heightConstraint - ]) - - grabberView.backgroundColor = .iconColorTertiary - grabberView.layer.cornerRadius = 2.5 - grabberView.translatesAutoresizingMaskIntoConstraints = false - sheetView.addSubview(grabberView) - NSLayoutConstraint.activate([ - grabberView.topAnchor.constraint(equalTo: sheetView.topAnchor, constant: 8), - grabberView.centerXAnchor.constraint(equalTo: sheetView.centerXAnchor), - grabberView.widthAnchor.constraint(equalToConstant: 36), - grabberView.heightAnchor.constraint(equalToConstant: 5) - ]) - - let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) - sheetView.addGestureRecognizer(panRecognizer) - } - - private func setupTopPart() { - topPartView.onTap = { [weak self] in - self?.toggleState() - } - topPartView.translatesAutoresizingMaskIntoConstraints = false - sheetView.addSubview(topPartView) - NSLayoutConstraint.activate([ - topPartView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 6), - topPartView.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor), - topPartView.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor), - topPartView.heightAnchor.constraint(equalToConstant: Self.topPartHeight) - ]) - } - - private func setupContent() { - setupSegmentControl() - tabContainerView.clipsToBounds = true - [segmentControl, tabContainerView].forEach { - $0.translatesAutoresizingMaskIntoConstraints = false - sheetView.addSubview($0) - } - sheetView.bringSubviewToFront(bottomToolbar) - let inset = Self.horizontalInset - NSLayoutConstraint.activate([ - segmentControl.topAnchor.constraint(equalTo: topPartView.bottomAnchor, constant: 8), - segmentControl.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor, constant: inset), - segmentControl.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor, constant: -inset), - segmentControl.heightAnchor.constraint(equalToConstant: Self.segmentedControlHeight), - - tabContainerView.topAnchor.constraint(equalTo: segmentControl.bottomAnchor, constant: 12), - tabContainerView.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor), - tabContainerView.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor), - tabContainerView.bottomAnchor.constraint(equalTo: sheetView.bottomAnchor) - ]) - } - - private func setupSegmentControl() { - segmentControl.removeAllSegments() - for (index, tab) in tabs.enumerated() { - segmentControl.insertSegment(withTitle: tab.title, at: index, animated: false) - } - segmentControl.selectedSegmentIndex = tabs.firstIndex(of: selectedTab) ?? 0 - segmentControl.backgroundColor = .groupBgColorSecondary - segmentControl.selectedSegmentTintColor = .viewBg - segmentControl.setTitleTextAttributes([.foregroundColor: UIColor.textColorSecondary, - .font: UIFont.scaledSystemFont(ofSize: 13)], for: .normal) - segmentControl.setTitleTextAttributes([.foregroundColor: UIColor.textColorPrimary, - .font: UIFont.scaledSystemFont(ofSize: 13, weight: .semibold)], for: .selected) - segmentControl.addTarget(self, action: #selector(onSegmentChanged), for: .valueChanged) - } - - private func setupBottomToolbar() { - bottomToolbar.isUndoEnabled = dataProvider.canUndo - bottomToolbar.isRedoEnabled = dataProvider.canRedo - bottomToolbar.onAddPoi = { [weak self] in self?.handleAddPoi() } - bottomToolbar.onUndo = { [weak self] in self?.handleUndo() } - bottomToolbar.onRedo = { [weak self] in self?.handleRedo() } - bottomToolbar.onAddRoutePoint = { [weak self] in self?.handleAddRoutePoint() } - bottomToolbar.translatesAutoresizingMaskIntoConstraints = false - sheetView.addSubview(bottomToolbar) - let inset = Self.horizontalInset - NSLayoutConstraint.activate([ - bottomToolbar.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor, constant: inset), - bottomToolbar.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor, constant: -inset), - bottomToolbar.bottomAnchor.constraint(equalTo: sheetView.safeAreaLayoutGuide.bottomAnchor, constant: -8), - bottomToolbar.heightAnchor.constraint(equalToConstant: PlanRouteButtonFactory.bottomButtonHeight) - ]) - } - - private func setupTopToolbar() { - topToolbar.titleText = dataProvider.mode.title - topToolbar.isSaveButtonVisible = true - topToolbar.optionsMenu = makeOptionsMenu() - topToolbar.onClose = { [weak self] in self?.handleClose() } - topToolbar.onSave = { [weak self] in self?.handleSave() } - topToolbar.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(topToolbar) - NSLayoutConstraint.activate([ - topToolbar.topAnchor.constraint(equalTo: view.topAnchor), - topToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), - topToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - topToolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: PlanRouteTopToolbarView.contentHeight) - ]) - } - - private func height(for state: EOADraggableMenuState) -> CGFloat { - let screenHeight = OAUtilities.calculateScreenHeight() - let collapsed = Self.grabberAreaHeight + Self.topPartHeight + 8 + Self.segmentedControlHeight + 12 - + PlanRouteButtonFactory.bottomButtonHeight + 8 + OAUtilities.getBottomMargin() - switch state { - case .initial: - return collapsed - case .expanded: - return screenHeight / 2 - case .fullScreen: - return screenHeight - getNavbarHeight() - Self.fullScreenTopGap - @unknown default: - return screenHeight / 2 - } - } - - private func applyHeight(for state: EOADraggableMenuState) { - sheetHeightConstraint?.constant = height(for: state) - } - - private func mapControlsReservedHeight(for state: EOADraggableMenuState) -> CGFloat { - min(height(for: state), height(for: .expanded)) - } - - private func setState(_ state: EOADraggableMenuState, animated: Bool) { - sheetState = state - sheetHeightConstraint?.constant = height(for: state) - let updates: () -> Void = { [weak self] in - guard let self else { return } - view.layoutIfNeeded() - tabContainerView.alpha = isContentVisible(in: state) ? 1 : 0 - refreshMapControls() - } - if animated { - UIView.animate(withDuration: Self.animationDuration, animations: updates) - } else { - updates() - } - } - - private func isContentVisible(in state: EOADraggableMenuState) -> Bool { - state != .initial - } - - private func toggleState() { - setState(sheetState == .initial ? .expanded : .initial, animated: true) - } - - private func nearestState(for currentHeight: CGFloat, velocity: CGFloat) -> EOADraggableMenuState { - if velocity < -800 { return .fullScreen } - if velocity > 800 { return .initial } - let candidates: [EOADraggableMenuState] = [.initial, .expanded, .fullScreen] - return candidates.min { abs(height(for: $0) - currentHeight) < abs(height(for: $1) - currentHeight) } ?? .expanded - } - - private func refreshMapControls() { - let style: UIStatusBarStyle = OAAppSettings.sharedManager().nightMode ? .lightContent : .default - OARootViewController.instance().mapPanel?.targetUpdateControlsLayout(true, customStatusBarStyle: style) - } - - private func tabViewController(for tab: PlanRouteTab) -> UIViewController { - if let existing = tabViewControllers[tab] { - return existing - } - let controller: UIViewController - switch tab { - case .poi: controller = PlanRoutePoiViewController(dataSource: dataProvider) - case .analyze: controller = PlanRouteAnalyzeViewController(dataSource: dataProvider) - case .route: controller = PlanRouteRouteViewController(dataSource: dataProvider) - } - tabViewControllers[tab] = controller - return controller - } - - private func selectTab(_ tab: PlanRouteTab) { - selectedTab = tab - let newController = tabViewController(for: tab) - guard newController !== currentTabViewController else { return } - currentTabViewController?.willMove(toParent: nil) - currentTabViewController?.view.removeFromSuperview() - currentTabViewController?.removeFromParent() - - addChild(newController) - newController.view.translatesAutoresizingMaskIntoConstraints = false - tabContainerView.addSubview(newController.view) - NSLayoutConstraint.activate([ - newController.view.topAnchor.constraint(equalTo: tabContainerView.topAnchor), - newController.view.leadingAnchor.constraint(equalTo: tabContainerView.leadingAnchor), - newController.view.trailingAnchor.constraint(equalTo: tabContainerView.trailingAnchor), - newController.view.bottomAnchor.constraint(equalTo: tabContainerView.bottomAnchor) - ]) - newController.didMove(toParent: self) - currentTabViewController = newController - } - - private func makeOptionsMenu() -> UIMenu { - let actions = PlanRouteMenuAction.actions(for: dataProvider.mode).map { action in - UIAction(title: action.title, - image: action.icon, - attributes: action.isDestructive ? .destructive : []) { [weak self] _ in - self?.handleMenuAction(action) - } - } - return UIMenu(children: actions) - } - - private func handleClose() { - guard dataProvider.hasChanges else { - hide() - return - } - let alert = UIAlertController(title: localizedString("exit_without_saving"), - message: nil, - preferredStyle: .actionSheet) - alert.addAction(UIAlertAction(title: localizedString("shared_string_discard"), style: .destructive) { [weak self] _ in - self?.hide() - }) - alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) - present(alert, animated: true) - } - - private func handleSave() { - print("[PlanRoute] Save tapped") - } - - private func handleAddPoi() { - print("[PlanRoute] Add POI tapped") - } - - private func handleUndo() { - print("[PlanRoute] Undo tapped") - } - - private func handleRedo() { - print("[PlanRoute] Redo tapped") - } - - private func handleAddRoutePoint() { - print("[PlanRoute] Add route point tapped") - } - - private func handleMenuAction(_ action: PlanRouteMenuAction) { - print("[PlanRoute] Options menu action: \(action)") - } - - @objc private func onSegmentChanged() { - let index = segmentControl.selectedSegmentIndex - guard tabs.indices.contains(index) else { return } - let tab = tabs[index] - print("[PlanRoute] Segment switched to: \(tab)") - selectTab(tab) - if sheetState == .initial { - setState(.expanded, animated: true) - } - } - - @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { - guard let sheetHeightConstraint else { return } - let translation = gesture.translation(in: view).y - switch gesture.state { - case .began: - panStartHeight = sheetHeightConstraint.constant - case .changed: - let lower = height(for: .initial) - let upper = height(for: .fullScreen) - sheetHeightConstraint.constant = min(max(panStartHeight - translation, lower), upper) - case .ended, .cancelled: - let velocity = gesture.velocity(in: view).y - setState(nearestState(for: sheetHeightConstraint.constant, velocity: velocity), animated: true) - default: - break - } - } -} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteModels.swift deleted file mode 100644 index 1bf35b4a78..0000000000 --- a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteModels.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// PlanRouteModels.swift -// OsmAnd Maps -// -// Created by OsmAnd on 15.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import UIKit - -enum PlanRouteMode { - case newRoute - case editTrack(fileName: String) - - var title: String { - switch self { - case .newRoute: localizedString("quick_action_new_route") - case let .editTrack(fileName): fileName - } - } - - var isNewRoute: Bool { - if case .newRoute = self { return true } - return false - } - - var isEditTrack: Bool { - !isNewRoute - } -} - -enum PlanRouteTab: Int, CaseIterable { - case poi - case analyze - case route - - var title: String { - switch self { - case .poi: localizedString("poi") - case .analyze: localizedString("gpx_analyze") - case .route: localizedString("layer_route") - } - } - - static var `default`: PlanRouteTab { - .route - } -} - -enum PlanRouteMenuAction: CaseIterable { - case saveAs - case saveAsCopy - case appendToExistingTrack - case changeSegmentOrder - case viewDirections - case reverseRoute - case navigation - case clearAllPoints - - var title: String { - switch self { - case .saveAs: localizedString("plan_route_save_as") - case .saveAsCopy: localizedString("save_as_copy") - case .appendToExistingTrack: localizedString("plan_route_append_to_existing_track") - case .changeSegmentOrder: localizedString("plan_route_change_segment_order") - case .viewDirections: localizedString("plan_route_view_directions") - case .reverseRoute: localizedString("reverse_route") - case .navigation: localizedString("shared_string_navigation") - case .clearAllPoints: localizedString("distance_measurement_clear_route") - } - } - - var icon: UIImage? { - switch self { - case .saveAs: .templateImageNamed("ic_custom_save_to_file") - case .saveAsCopy: .templateImageNamed("ic_custom_save_as_new_file") - case .appendToExistingTrack: .templateImageNamed("ic_custom_add_to_track") - case .changeSegmentOrder: .templateImageNamed("ic_custom_list") - case .viewDirections: .templateImageNamed("ic_custom_route_points") - case .reverseRoute: .templateImageNamed("ic_custom_change_object_position") - case .navigation: .templateImageNamed("ic_custom_navigation_outlined") - case .clearAllPoints: .templateImageNamed("ic_custom_trash_outlined") - } - } - - var isDestructive: Bool { - self == .clearAllPoints - } - - func isVisible(for mode: PlanRouteMode) -> Bool { - switch self { - case .saveAsCopy: mode.isEditTrack - default: true - } - } - - static func actions(for mode: PlanRouteMode) -> [PlanRouteMenuAction] { - allCases.filter { $0.isVisible(for: mode) } - } -} - -struct PlanRouteInfo { - let isNewRoute: Bool - let isStraightLine: Bool - let hasRoute: Bool - let totalDistance: Double - let duration: TimeInterval - let arrivalTime: Date? - let uphill: Double - let downhill: Double - let mapCenterDistance: Double - let bearing: Double - - var showsTime: Bool { - !isNewRoute && !isStraightLine && duration > 0 - } - - static var empty: PlanRouteInfo { - PlanRouteInfo(isNewRoute: true, - isStraightLine: false, - hasRoute: false, - totalDistance: 0, - duration: 0, - arrivalTime: nil, - uphill: 0, - downhill: 0, - mapCenterDistance: 0, - bearing: 0) - } -} - -struct PlanRoutePoint { - let index: Int - let name: String - let distanceFromPrevious: Double - let bearing: Double - let isStart: Bool - let isDestination: Bool -} - -struct PlanRouteSegment { - let index: Int - let points: [PlanRoutePoint] -} - -struct PlanRouteElevationData { - let uphill: Double - let downhill: Double - let elevations: [Double] -} - -protocol PlanRoutePoiDataSource: AnyObject { - var poiPoints: [PlanRoutePoint] { get } -} - -protocol PlanRouteAnalyzeDataSource: AnyObject { - var routeInfo: PlanRouteInfo { get } - var elevationData: PlanRouteElevationData? { get } -} - -protocol PlanRoutePointsDataSource: AnyObject { - var routeInfo: PlanRouteInfo { get } - var segments: [PlanRouteSegment] { get } - var routePoints: [PlanRoutePoint] { get } -} - -protocol PlanRouteDataProvider: PlanRoutePoiDataSource, PlanRouteAnalyzeDataSource, PlanRoutePointsDataSource { - var mode: PlanRouteMode { get } - var hasChanges: Bool { get } - var canUndo: Bool { get } - var canRedo: Bool { get } -} - -protocol PlanRouteTabContent: AnyObject { - var planRouteTab: PlanRouteTab { get } - func reloadData() -} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteStubDataProvider.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteStubDataProvider.swift deleted file mode 100644 index 0342b1470f..0000000000 --- a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteStubDataProvider.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// PlanRouteStubDataProvider.swift -// OsmAnd Maps -// -// Created by OsmAnd on 15.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import UIKit - -final class PlanRouteStubDataProvider: PlanRouteDataProvider { - let mode: PlanRouteMode - - init(mode: PlanRouteMode = .newRoute) { - self.mode = mode - } - - var hasChanges: Bool { - false - } - - var canUndo: Bool { - false - } - - var canRedo: Bool { - false - } - - var routeInfo: PlanRouteInfo { - PlanRouteInfo(isNewRoute: mode.isNewRoute, - isStraightLine: false, - hasRoute: !routePoints.isEmpty, - totalDistance: 0, - duration: 0, - arrivalTime: nil, - uphill: 0, - downhill: 0, - mapCenterDistance: 0, - bearing: 100) - } - - var elevationData: PlanRouteElevationData? { - nil - } - - var poiPoints: [PlanRoutePoint] { - [] - } - - var routePoints: [PlanRoutePoint] { - [] - } - - var segments: [PlanRouteSegment] { - [] - } -} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteToolbarsView.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteToolbarsView.swift deleted file mode 100644 index 71a965b84a..0000000000 --- a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteToolbarsView.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// PlanRouteToolbarsView.swift -// OsmAnd Maps -// -// Created by OsmAnd on 15.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import UIKit - -final class PlanRouteTopToolbarView: UIView { - static let contentHeight: CGFloat = 56 - - private static let edgeInset: CGFloat = 16 - private static let buttonSpacing: CGFloat = 8 - - var onClose: (() -> Void)? - var onSave: (() -> Void)? - - var titleText: String? { - didSet { titleLabel.text = titleText } - } - - var optionsMenu: UIMenu? { - didSet { - optionsButton.menu = optionsMenu - optionsButton.showsMenuAsPrimaryAction = optionsMenu != nil - } - } - - var isSaveButtonVisible = true { - didSet { saveButton.isHidden = !isSaveButtonVisible } - } - - private let titleLabel = UILabel() - private let closeButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_close")) - private let optionsButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_overflow_menu_stroke")) - - private lazy var saveButton = PlanRouteButtonFactory.primaryButton(title: localizedString("shared_string_save")) - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - guard let hitView = super.hitTest(point, with: event) else { return nil } - return hitView is UIControl ? hitView : nil - } - - private func setupView() { - backgroundColor = .clear - - titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .semibold, maximumSize: 22) - titleLabel.textColor = .textColorPrimary - titleLabel.textAlignment = .center - titleLabel.adjustsFontForContentSizeCategory = true - - let trailingStack = UIStackView(arrangedSubviews: [optionsButton, saveButton]) - trailingStack.spacing = Self.buttonSpacing - trailingStack.alignment = .center - - [closeButton, titleLabel, trailingStack].forEach { - $0.translatesAutoresizingMaskIntoConstraints = false - addSubview($0) - } - - let inset = Self.edgeInset - NSLayoutConstraint.activate([ - closeButton.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: inset), - closeButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: Self.buttonSpacing), - - trailingStack.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -inset), - trailingStack.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), - - titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor), - titleLabel.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), - titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: closeButton.trailingAnchor, constant: Self.buttonSpacing), - titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingStack.leadingAnchor, constant: -Self.buttonSpacing) - ]) - - closeButton.addTarget(self, action: #selector(onCloseTapped), for: .touchUpInside) - saveButton.addTarget(self, action: #selector(onSaveTapped), for: .touchUpInside) - } - - @objc private func onCloseTapped() { - onClose?() - } - - @objc private func onSaveTapped() { - onSave?() - } -} - -final class PlanRouteBottomToolbarView: UIView { - private static let edgeInset: CGFloat = 16 - private static let buttonSpacing: CGFloat = 8 - - var onAddPoi: (() -> Void)? - var onUndo: (() -> Void)? - var onRedo: (() -> Void)? - var onAddRoutePoint: (() -> Void)? - - var isUndoEnabled = false { - didSet { undoButton.isEnabled = isUndoEnabled } - } - - var isRedoEnabled = false { - didSet { redoButton.isEnabled = isRedoEnabled } - } - - private let undoButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_undo"), size: PlanRouteButtonFactory.bottomButtonHeight) - private let redoButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_redo"), size: PlanRouteButtonFactory.bottomButtonHeight) - - private lazy var addPoiButton = PlanRouteButtonFactory.labeledButton(title: localizedString("poi"), image: .templateImageNamed("ic_custom_add")) - private lazy var routeButton = PlanRouteButtonFactory.labeledButton(title: localizedString("layer_route"), image: .templateImageNamed("ic_custom_add")) - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupView() { - backgroundColor = .clear - - let centerStack = UIStackView(arrangedSubviews: [undoButton, redoButton]) - centerStack.spacing = Self.buttonSpacing - centerStack.alignment = .center - - [addPoiButton, centerStack, routeButton].forEach { - $0.translatesAutoresizingMaskIntoConstraints = false - addSubview($0) - } - - let inset = Self.edgeInset - NSLayoutConstraint.activate([ - addPoiButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), - addPoiButton.topAnchor.constraint(equalTo: topAnchor), - - routeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), - routeButton.centerYAnchor.constraint(equalTo: addPoiButton.centerYAnchor), - - centerStack.centerXAnchor.constraint(equalTo: centerXAnchor), - centerStack.centerYAnchor.constraint(equalTo: addPoiButton.centerYAnchor) - ]) - - addPoiButton.addTarget(self, action: #selector(onAddPoiTapped), for: .touchUpInside) - undoButton.addTarget(self, action: #selector(onUndoTapped), for: .touchUpInside) - redoButton.addTarget(self, action: #selector(onRedoTapped), for: .touchUpInside) - routeButton.addTarget(self, action: #selector(onRouteTapped), for: .touchUpInside) - } - - @objc private func onAddPoiTapped() { - onAddPoi?() - } - - @objc private func onUndoTapped() { - onUndo?() - } - - @objc private func onRedoTapped() { - onRedo?() - } - - @objc private func onRouteTapped() { - onAddRoutePoint?() - } -} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteTopPartView.swift b/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteTopPartView.swift deleted file mode 100644 index 1979219909..0000000000 --- a/Sources/Controllers/RoutePlanning/PlanRoute/PlanRouteTopPartView.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// PlanRouteTopPartView.swift -// OsmAnd Maps -// -// Created by OsmAnd on 15.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import UIKit - -final class PlanRouteTopPartView: UIView { - private static let statusIconSize: CGFloat = 30 - private static let horizontalInset: CGFloat = 20 - - var onTap: (() -> Void)? - - private let statusIconView = UIImageView() - private let firstLineLabel = UILabel() - private let secondLineLabel = UILabel() - private let textStackView = UIStackView() - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure(with info: PlanRouteInfo) { - firstLineLabel.attributedText = makeFirstLine(info) - secondLineLabel.attributedText = makeSecondLine(info) - } - - private func setupView() { - backgroundColor = .clear - - statusIconView.image = .templateImageNamed("ic_custom_plan_route") - statusIconView.tintColor = .iconColorActive - statusIconView.contentMode = .scaleAspectFit - statusIconView.translatesAutoresizingMaskIntoConstraints = false - addSubview(statusIconView) - - firstLineLabel.numberOfLines = 1 - firstLineLabel.adjustsFontForContentSizeCategory = true - secondLineLabel.numberOfLines = 1 - secondLineLabel.adjustsFontForContentSizeCategory = true - - textStackView.axis = .vertical - textStackView.spacing = 2 - textStackView.alignment = .leading - textStackView.addArrangedSubview(firstLineLabel) - textStackView.addArrangedSubview(secondLineLabel) - textStackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(textStackView) - - let horizontalInset = Self.horizontalInset - let statusIconSize = Self.statusIconSize - - NSLayoutConstraint.activate([ - statusIconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset), - statusIconView.centerYAnchor.constraint(equalTo: centerYAnchor), - statusIconView.widthAnchor.constraint(equalToConstant: statusIconSize), - statusIconView.heightAnchor.constraint(equalToConstant: statusIconSize), - - textStackView.centerYAnchor.constraint(equalTo: centerYAnchor), - textStackView.leadingAnchor.constraint(equalTo: statusIconView.trailingAnchor, constant: 12), - textStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset) - ]) - - let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(onViewTapped)) - addGestureRecognizer(tapRecognizer) - } - - private func makeFirstLine(_ info: PlanRouteInfo) -> NSAttributedString { - let bodyFont = UIFont.preferredFont(forTextStyle: .body) - let primary: [NSAttributedString.Key: Any] = [.font: bodyFont, .foregroundColor: UIColor.textColorPrimary] - let secondary: [NSAttributedString.Key: Any] = [.font: bodyFont, .foregroundColor: UIColor.textColorSecondary] - - let result = NSMutableAttributedString() - result.append(NSAttributedString(string: formattedDistance(info.totalDistance), attributes: primary)) - - guard info.showsTime else { return result } - - result.append(NSAttributedString(string: " • ", attributes: secondary)) - result.append(NSAttributedString(string: formattedDuration(info.duration), attributes: secondary)) - if let arrival = info.arrivalTime { - result.append(NSAttributedString(string: " (\(formattedTime(arrival)))", attributes: secondary)) - } - return result - } - - private func makeSecondLine(_ info: PlanRouteInfo) -> NSAttributedString { - let subheadFont = UIFont.preferredFont(forTextStyle: .subheadline) - let attributes: [NSAttributedString.Key: Any] = [.font: subheadFont, .foregroundColor: UIColor.textColorSecondary] - - let result = NSMutableAttributedString() - result.append(symbolAttachment("arrow.up.right", font: subheadFont)) - result.append(NSAttributedString(string: " \(formattedDistance(info.uphill)) ", attributes: attributes)) - result.append(symbolAttachment("arrow.down.right", font: subheadFont)) - result.append(NSAttributedString(string: " \(formattedDistance(info.downhill))", attributes: attributes)) - result.append(NSAttributedString(string: " | ", attributes: attributes)) - result.append(NSAttributedString(string: "\(formattedDistance(info.mapCenterDistance)) • \(Int(info.bearing))°", attributes: attributes)) - return result - } - - private func symbolAttachment(_ name: String, font: UIFont) -> NSAttributedString { - let attachment = NSTextAttachment() - let configuration = UIImage.SymbolConfiguration(font: font) - attachment.image = UIImage(systemName: name, withConfiguration: configuration)?.withTintColor(.textColorSecondary, renderingMode: .alwaysOriginal) - return NSAttributedString(attachment: attachment) - } - - private func formattedDistance(_ meters: Double) -> String { - OAOsmAndFormatter.getFormattedDistance(Float(meters)) - } - - private func formattedDuration(_ duration: TimeInterval) -> String { - OAOsmAndFormatter.getFormattedTimeInterval(duration, shortFormat: true) - } - - private func formattedTime(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - return formatter.string(from: date) - } - - @objc private func onViewTapped() { - onTap?() - } -} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift b/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift deleted file mode 100644 index 7d70fd1db9..0000000000 --- a/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// PlanRouteAnalyzeViewController.swift -// OsmAnd Maps -// -// Created by OsmAnd on 15.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import UIKit - -final class PlanRouteAnalyzeViewController: UIViewController, PlanRouteTabContent { - let planRouteTab: PlanRouteTab = .analyze - - private weak var dataSource: PlanRouteAnalyzeDataSource? - - private let placeholderLabel = UILabel() - - init(dataSource: PlanRouteAnalyzeDataSource?) { - self.dataSource = dataSource - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - setupPlaceholder() - reloadData() - } - - func reloadData() { - guard isViewLoaded else { return } - placeholderLabel.text = planRouteTab.title - } - - private func setupPlaceholder() { - view.backgroundColor = .clear - placeholderLabel.font = .preferredFont(forTextStyle: .body) - placeholderLabel.textColor = .textColorSecondary - placeholderLabel.textAlignment = .center - placeholderLabel.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(placeholderLabel) - NSLayoutConstraint.activate([ - placeholderLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - placeholderLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - } -} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRoutePoiViewController.swift b/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRoutePoiViewController.swift deleted file mode 100644 index 29d95ac07f..0000000000 --- a/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRoutePoiViewController.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// PlanRoutePoiViewController.swift -// OsmAnd Maps -// -// Created by OsmAnd on 15.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import UIKit - -final class PlanRoutePoiViewController: UIViewController, PlanRouteTabContent { - let planRouteTab: PlanRouteTab = .poi - - private weak var dataSource: PlanRoutePoiDataSource? - - private let placeholderLabel = UILabel() - - init(dataSource: PlanRoutePoiDataSource?) { - self.dataSource = dataSource - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - setupPlaceholder() - reloadData() - } - - func reloadData() { - guard isViewLoaded else { return } - placeholderLabel.text = planRouteTab.title - } - - private func setupPlaceholder() { - view.backgroundColor = .clear - placeholderLabel.font = .preferredFont(forTextStyle: .body) - placeholderLabel.textColor = .textColorSecondary - placeholderLabel.textAlignment = .center - placeholderLabel.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(placeholderLabel) - NSLayoutConstraint.activate([ - placeholderLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - placeholderLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - } -} diff --git a/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteRouteViewController.swift b/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteRouteViewController.swift deleted file mode 100644 index ca6e60cc94..0000000000 --- a/Sources/Controllers/RoutePlanning/PlanRoute/Tabs/PlanRouteRouteViewController.swift +++ /dev/null @@ -1,276 +0,0 @@ -// -// PlanRouteRouteViewController.swift -// OsmAnd Maps -// -// Created by OsmAnd on 15.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import UIKit - -final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent { - let planRouteTab: PlanRouteTab = .route - - private weak var dataSource: PlanRoutePointsDataSource? - - private let tableView = UITableView(frame: .zero, style: .insetGrouped) - private var points: [PlanRoutePoint] = [] - - init(dataSource: PlanRoutePointsDataSource?) { - self.dataSource = dataSource - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - setupTableView() - reloadData() - } - - func reloadData() { - guard isViewLoaded else { return } - points = dataSource?.routePoints ?? [] - tableView.reloadData() - } - - private func setupTableView() { - view.backgroundColor = .clear - tableView.backgroundColor = .clear - tableView.dataSource = self - tableView.delegate = self - tableView.separatorInset = UIEdgeInsets(top: 0, left: 76, bottom: 0, right: 0) - tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 72, right: 0) - tableView.register(PlanRoutePointCell.self, forCellReuseIdentifier: PlanRoutePointCell.cellReuseId) - tableView.register(PlanRouteEmptyCell.self, forCellReuseIdentifier: PlanRouteEmptyCell.cellReuseId) - tableView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tableView) - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - } -} - -// MARK: - UITableViewDataSource -extension PlanRouteRouteViewController: UITableViewDataSource { - func numberOfSections(in tableView: UITableView) -> Int { - points.isEmpty ? 1 : 2 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - section == 0 ? max(points.count, 1) : 1 - } - - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - section == 0 ? localizedString("route_points") : nil - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if indexPath.section == 0, points.isEmpty { - guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRouteEmptyCell.cellReuseId, for: indexPath) as? PlanRouteEmptyCell else { - return UITableViewCell() - } - return cell - } - if indexPath.section == 1 { - let cell = UITableViewCell(style: .default, reuseIdentifier: nil) - cell.textLabel?.text = localizedString("gpx_start_new_segment") - cell.textLabel?.textColor = .iconColorActive - cell.textLabel?.font = .scaledSystemFont(ofSize: 17) - cell.backgroundColor = .groupBg - return cell - } - guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRoutePointCell.cellReuseId, for: indexPath) as? PlanRoutePointCell else { - return UITableViewCell() - } - cell.configure(with: points[indexPath.row]) - cell.onDelete = { [weak self] in - print("[PlanRoute] Delete point at index: \(indexPath.row)") - self?.reloadData() - } - return cell - } -} - -// MARK: - UITableViewDelegate -extension PlanRouteRouteViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - if indexPath.section == 1 { - print("[PlanRoute] Start new segment tapped") - } else if !points.isEmpty { - print("[PlanRoute] Selected point at index: \(indexPath.row)") - } - } -} - -final class PlanRoutePointCell: UITableViewCell { - static let cellReuseId = "PlanRoutePointCell" - - private static let circleSize: CGFloat = 28 - private static let deleteSize: CGFloat = 24 - - var onDelete: (() -> Void)? - - private let deleteButton = UIButton(type: .system) - private let numberLabel = UILabel() - private let numberContainer = UIView() - private let titleLabel = UILabel() - private let subtitleLabel = UILabel() - private let dragHandleView = UIImageView() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setupCell() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure(with point: PlanRoutePoint) { - numberLabel.text = "\(point.index + 1)" - titleLabel.text = point.name - subtitleLabel.text = subtitle(for: point) - } - - private func setupCell() { - backgroundColor = .groupBg - selectionStyle = .none - - deleteButton.setImage(UIImage(systemName: "minus.circle.fill"), for: .normal) - deleteButton.tintColor = .systemRed - deleteButton.addTarget(self, action: #selector(onDeleteTapped), for: .touchUpInside) - - numberContainer.layer.cornerRadius = Self.circleSize / 2 - numberContainer.layer.borderWidth = 2 - numberContainer.layer.borderColor = UIColor.iconColorActive.cgColor - numberLabel.font = .scaledSystemFont(ofSize: 13, weight: .semibold) - numberLabel.textColor = .iconColorActive - numberLabel.textAlignment = .center - numberLabel.translatesAutoresizingMaskIntoConstraints = false - numberContainer.addSubview(numberLabel) - - titleLabel.font = .scaledSystemFont(ofSize: 17) - titleLabel.textColor = .textColorPrimary - subtitleLabel.font = .scaledSystemFont(ofSize: 13) - subtitleLabel.textColor = .textColorSecondary - - let textStack = UIStackView(arrangedSubviews: [subtitleLabel, titleLabel]) - textStack.axis = .vertical - textStack.spacing = 2 - - dragHandleView.image = UIImage(systemName: "line.3.horizontal") - dragHandleView.tintColor = .iconColorTertiary - dragHandleView.contentMode = .scaleAspectFit - - [deleteButton, numberContainer, textStack, dragHandleView].forEach { - $0.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview($0) - } - - NSLayoutConstraint.activate([ - deleteButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), - deleteButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - deleteButton.widthAnchor.constraint(equalToConstant: Self.deleteSize), - deleteButton.heightAnchor.constraint(equalToConstant: Self.deleteSize), - - numberContainer.leadingAnchor.constraint(equalTo: deleteButton.trailingAnchor, constant: 12), - numberContainer.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - numberContainer.widthAnchor.constraint(equalToConstant: Self.circleSize), - numberContainer.heightAnchor.constraint(equalToConstant: Self.circleSize), - numberLabel.centerXAnchor.constraint(equalTo: numberContainer.centerXAnchor), - numberLabel.centerYAnchor.constraint(equalTo: numberContainer.centerYAnchor), - - textStack.leadingAnchor.constraint(equalTo: numberContainer.trailingAnchor, constant: 12), - textStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - textStack.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 8), - - dragHandleView.leadingAnchor.constraint(greaterThanOrEqualTo: textStack.trailingAnchor, constant: 12), - dragHandleView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), - dragHandleView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - dragHandleView.widthAnchor.constraint(equalToConstant: 24), - dragHandleView.heightAnchor.constraint(equalToConstant: 24) - ]) - } - - private func subtitle(for point: PlanRoutePoint) -> String { - if point.isStart { - return localizedString("starting_point") - } - let distance = OAOsmAndFormatter.getFormattedDistance(Float(point.distanceFromPrevious)) - if point.isDestination { - return "\(distance) • \(localizedString("route_descr_destination"))" - } - return "\(distance) • \(Int(point.bearing))°" - } - - @objc private func onDeleteTapped() { - onDelete?() - } -} - -final class PlanRouteEmptyCell: UITableViewCell { - static let cellReuseId = "PlanRouteEmptyCell" - - private static let iconSize: CGFloat = 30 - - private let titleLabel = UILabel() - private let descriptionLabel = UILabel() - private let iconView = UIImageView() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setupCell() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupCell() { - backgroundColor = .groupBg - selectionStyle = .none - - titleLabel.text = localizedString("plan_route_no_points_title") - titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .medium) - titleLabel.textColor = .textColorPrimary - titleLabel.numberOfLines = 0 - - descriptionLabel.text = localizedString("plan_route_no_points_descr") - descriptionLabel.font = .scaledSystemFont(ofSize: 15) - descriptionLabel.textColor = .textColorSecondary - descriptionLabel.numberOfLines = 0 - - iconView.image = .templateImageNamed("ic_custom_plan_route") - iconView.tintColor = .iconColorActive - iconView.contentMode = .scaleAspectFit - - let textStack = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel]) - textStack.axis = .vertical - textStack.spacing = 6 - - [textStack, iconView].forEach { - $0.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview($0) - } - - NSLayoutConstraint.activate([ - textStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), - textStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), - textStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16), - - iconView.leadingAnchor.constraint(equalTo: textStack.trailingAnchor, constant: 12), - iconView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), - iconView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), - iconView.widthAnchor.constraint(equalToConstant: Self.iconSize), - iconView.heightAnchor.constraint(equalToConstant: Self.iconSize) - ]) - } -} diff --git a/Sources/QuickAction/Actions/RouteAction.swift b/Sources/QuickAction/Actions/RouteAction.swift index fc4195a4f6..964529f65e 100644 --- a/Sources/QuickAction/Actions/RouteAction.swift +++ b/Sources/QuickAction/Actions/RouteAction.swift @@ -36,6 +36,6 @@ final class RouteAction: OAQuickAction { } override func execute() { - PlanRouteContainerViewController.showNewRoute() + PlanRouteScrollableViewController.showNewRoute() } } From 951b46d6d2e1ce537aeac7db4978385649a56d5a Mon Sep 17 00:00:00 2001 From: Vladyslav-lys <66596645+Vladyslav-lys@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:15:20 +0300 Subject: [PATCH 08/47] Crush fixes 5.4 (#5470) * [WIP] map underlay quick action crush fixed * crash on street widget fixed --- Sources/Controllers/Map/Widgets/OACurrentStreetName.mm | 9 +++++---- Sources/QuickAction/Actions/MapUnderlayAction.swift | 4 ++-- Sources/QuickAction/Actions/OASwitchableAction.h | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Sources/Controllers/Map/Widgets/OACurrentStreetName.mm b/Sources/Controllers/Map/Widgets/OACurrentStreetName.mm index 3791426a3e..8df74e09b0 100644 --- a/Sources/Controllers/Map/Widgets/OACurrentStreetName.mm +++ b/Sources/Controllers/Map/Widgets/OACurrentStreetName.mm @@ -229,11 +229,12 @@ - (instancetype)initWithRDO:(std::shared_ptr)rdo tag:(NSString { uint32_t nameId = rdo->namesIds[i].first; - const char *cTag = nullptr; + NSString *tag = nil; if (rdo->region) - cTag = rdo->region->quickGetEncodingRule(nameId).getTag().c_str(); - - NSString *tag = cTag ? OAStringFromUTF8Nullable(cTag) : nil; + { + std::string localTagStr = rdo->region->quickGetEncodingRule(nameId).getTag(); + tag = OAStringFromUTF8Nullable(localTagStr.c_str()); + } if (!tag) { NSLog(@"[RoadShield] Warning: tag is null for nameId %u", nameId); diff --git a/Sources/QuickAction/Actions/MapUnderlayAction.swift b/Sources/QuickAction/Actions/MapUnderlayAction.swift index 1e3fbaf0ac..5ef932e2f0 100644 --- a/Sources/QuickAction/Actions/MapUnderlayAction.swift +++ b/Sources/QuickAction/Actions/MapUnderlayAction.swift @@ -73,8 +73,8 @@ final class MapUnderlayAction: OASwitchableAction { } override func nextSelectedItem() -> String { - guard let sources: [[String]] = loadListFromParams() as? [[String]] else { return "" } - return next(fromSource: sources, defValue: noUnderlay) + guard let sources: [[String]] = loadListFromParams() as? [[String]], let next = next(fromSource: sources, defValue: noUnderlay) else { return "" } + return next } override func fillParams(_ model: [AnyHashable: Any]) -> Bool { diff --git a/Sources/QuickAction/Actions/OASwitchableAction.h b/Sources/QuickAction/Actions/OASwitchableAction.h index 8cb1906fc2..f145565bf1 100644 --- a/Sources/QuickAction/Actions/OASwitchableAction.h +++ b/Sources/QuickAction/Actions/OASwitchableAction.h @@ -29,7 +29,7 @@ - (NSString *)selectedItem; - (NSString *)nextSelectedItem; -- (NSString *)nextFromSource:(NSArray *> *)sources defValue:(NSString *)defValue; +- (nullable NSString *)nextFromSource:(NSArray *> *)sources defValue:(NSString *)defValue; //protected abstract View.OnClickListener getOnAddBtnClickListener(MapActivity activity, final Adapter adapter); From 442ab31baeacdee2b6a33ca481271b3847c00e1d Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Wed, 17 Jun 2026 13:06:21 +0300 Subject: [PATCH 09/47] add MyPlacesNavigator --- OsmAnd.xcodeproj/project.pbxproj | 18 ++++- Sources/AppNavigation/MyPlacesNavigator.swift | 66 +++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 Sources/AppNavigation/MyPlacesNavigator.swift diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index 37e215932f..ecd9f23f2f 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -1630,13 +1630,13 @@ CE8A82A92FCFE11F00EADFD8 /* MapVariantReplacementManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8A82A82FCFE11F00EADFD8 /* MapVariantReplacementManager.swift */; }; D1A0B0012F50000100A0B001 /* OpeningHoursParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0AFFE2F50000100A0B001 /* OpeningHoursParserTest.swift */; }; D1A0B0032F50000100A0B001 /* OpeningHoursParserTestSupport.mm in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0002F50000100A0B001 /* OpeningHoursParserTestSupport.mm */; }; + D1A0B0112F50001100A0B001 /* URLExtractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */; }; + D1A0B0122F50001100A0B001 /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3216E9522BA097BD0087D0EF /* StringExtensions.swift */; }; D71B9A8C2FC95D8500FBB0F3 /* OrganizeTracksByViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71B9A8B2FC95D8500FBB0F3 /* OrganizeTracksByViewController.swift */; }; D71B9A8E2FC96D2D00FBB0F3 /* OrganizeByTypeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71B9A8D2FC96D2D00FBB0F3 /* OrganizeByTypeExtension.swift */; }; D71B9A902FC99D5D00FBB0F3 /* OrganizeByTypeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71B9A8F2FC99D5D00FBB0F3 /* OrganizeByTypeCell.swift */; }; D7B76D0D2FD16070004EE3E9 /* OrganizeByStepSizeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B76D0C2FD16070004EE3E9 /* OrganizeByStepSizeViewController.swift */; }; D7BF04782FD2DB4400BABB31 /* TracksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BF04772FD2DB4400BABB31 /* TracksViewController.swift */; }; - D1A0B0112F50001100A0B001 /* URLExtractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */; }; - D1A0B0122F50001100A0B001 /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3216E9522BA097BD0087D0EF /* StringExtensions.swift */; }; DA0132D42A1E0AB500920C14 /* WidgetsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0132D32A1E0AB500920C14 /* WidgetsListViewController.swift */; }; DA0132DD2A1E4A6300920C14 /* ic_custom20_screen_side_right@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = DA0132D52A1E4A6100920C14 /* ic_custom20_screen_side_right@2x.png */; }; DA0132DE2A1E4A6300920C14 /* ic_custom20_screen_side_top@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = DA0132D62A1E4A6100920C14 /* ic_custom20_screen_side_top@2x.png */; }; @@ -3363,6 +3363,7 @@ FA9628E22AFCFB57004B7DEF /* SectionHeaderFooterButton.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA9628E12AFCFB57004B7DEF /* SectionHeaderFooterButton.xib */; }; FA9628E42AFCFCE1004B7DEF /* SectionHeaderFooterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9628E32AFCFCE1004B7DEF /* SectionHeaderFooterButton.swift */; }; FA9628E62AFD2F2E004B7DEF /* BLETemperatureSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9628E52AFD2F2E004B7DEF /* BLETemperatureSensor.swift */; }; + FA9E63F92FE2A7FA00167475 /* MyPlacesNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9E63F82FE2A7FA00167475 /* MyPlacesNavigator.swift */; }; FA9F416F2E6F10B7001A49E9 /* ImageCacheInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9F416E2E6F10B7001A49E9 /* ImageCacheInfoViewController.swift */; }; FA9FA5992E97D11400376C47 /* MapScrollHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9FA5982E97D11400376C47 /* MapScrollHelper.swift */; }; FAA055762E02C51400F84EDF /* VehicleMetricsTripRecordingCommandsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA055752E02C51200F84EDF /* VehicleMetricsTripRecordingCommandsViewController.swift */; }; @@ -5572,12 +5573,12 @@ D1A0AFFE2F50000100A0B001 /* OpeningHoursParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpeningHoursParserTest.swift; sourceTree = ""; }; D1A0AFFF2F50000100A0B001 /* OpeningHoursParserTestSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OpeningHoursParserTestSupport.h; sourceTree = ""; }; D1A0B0002F50000100A0B001 /* OpeningHoursParserTestSupport.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OpeningHoursParserTestSupport.mm; sourceTree = ""; }; + D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtractionTests.swift; sourceTree = ""; }; D71B9A8B2FC95D8500FBB0F3 /* OrganizeTracksByViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeTracksByViewController.swift; sourceTree = ""; }; D71B9A8D2FC96D2D00FBB0F3 /* OrganizeByTypeExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeByTypeExtension.swift; sourceTree = ""; }; D71B9A8F2FC99D5D00FBB0F3 /* OrganizeByTypeCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeByTypeCell.swift; sourceTree = ""; }; D7B76D0C2FD16070004EE3E9 /* OrganizeByStepSizeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeByStepSizeViewController.swift; sourceTree = ""; }; D7BF04772FD2DB4400BABB31 /* TracksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracksViewController.swift; sourceTree = ""; }; - D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtractionTests.swift; sourceTree = ""; }; DA0132D32A1E0AB500920C14 /* WidgetsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetsListViewController.swift; sourceTree = ""; }; DA0132D52A1E4A6100920C14 /* ic_custom20_screen_side_right@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "ic_custom20_screen_side_right@2x.png"; path = "Resources/Icons/ic_custom20_screen_side_right@2x.png"; sourceTree = ""; }; DA0132D62A1E4A6100920C14 /* ic_custom20_screen_side_top@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "ic_custom20_screen_side_top@2x.png"; path = "Resources/Icons/ic_custom20_screen_side_top@2x.png"; sourceTree = ""; }; @@ -8143,6 +8144,7 @@ FA9628E12AFCFB57004B7DEF /* SectionHeaderFooterButton.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SectionHeaderFooterButton.xib; sourceTree = ""; }; FA9628E32AFCFCE1004B7DEF /* SectionHeaderFooterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionHeaderFooterButton.swift; sourceTree = ""; }; FA9628E52AFD2F2E004B7DEF /* BLETemperatureSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLETemperatureSensor.swift; sourceTree = ""; }; + FA9E63F82FE2A7FA00167475 /* MyPlacesNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyPlacesNavigator.swift; sourceTree = ""; }; FA9F416E2E6F10B7001A49E9 /* ImageCacheInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCacheInfoViewController.swift; sourceTree = ""; }; FA9FA5982E97D11400376C47 /* MapScrollHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapScrollHelper.swift; sourceTree = ""; }; FAA055752E02C51200F84EDF /* VehicleMetricsTripRecordingCommandsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleMetricsTripRecordingCommandsViewController.swift; sourceTree = ""; }; @@ -10992,6 +10994,7 @@ DA5A78FF26C5639F00F274C7 /* Sources */ = { isa = PBXGroup; children = ( + FA9E63F72FE2A7E400167475 /* AppNavigation */, FA069C522B9AF6090007E4DE /* Controls */, DAABE4EC2A16578A00569F71 /* SwiftExtensions */, DACF784127E343FF00A8A218 /* Backup */, @@ -14705,6 +14708,14 @@ path = VehicleMetricsSearchViewController; sourceTree = ""; }; + FA9E63F72FE2A7E400167475 /* AppNavigation */ = { + isa = PBXGroup; + children = ( + FA9E63F82FE2A7FA00167475 /* MyPlacesNavigator.swift */, + ); + path = AppNavigation; + sourceTree = ""; + }; FAA055742E02C4E100F84EDF /* VehicleMetricsTripRecordingCommandsViewController */ = { isa = PBXGroup; children = ( @@ -17260,6 +17271,7 @@ DA5A83C026C563A800F274C7 /* OAActionAddMapSourceViewController.mm in Sources */, DA5A854C26C563A900F274C7 /* OAWikiArticleHelper.mm in Sources */, DA5A848026C563A900F274C7 /* OATopTextView.mm in Sources */, + FA9E63F92FE2A7FA00167475 /* MyPlacesNavigator.swift in Sources */, DA5A839626C563A800F274C7 /* OAPluginsViewController.mm in Sources */, DA5A816226C563A700F274C7 /* OAAppearance.m in Sources */, 32C1C4ED2DD4C4F200A053D4 /* OAClickableWayHelper.mm in Sources */, diff --git a/Sources/AppNavigation/MyPlacesNavigator.swift b/Sources/AppNavigation/MyPlacesNavigator.swift new file mode 100644 index 0000000000..f9753796c2 --- /dev/null +++ b/Sources/AppNavigation/MyPlacesNavigator.swift @@ -0,0 +1,66 @@ +// +// MyPlacesNavigator.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 17.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +@objcMembers +final class MyPlacesNavigator: NSObject { + private weak var root: OARootViewController? + + init(rootViewController: OARootViewController) { + self.root = rootViewController + super.init() + } + + func openFavorites() { + openMyPlaces(tab: .favorites) + } + + func openTracks() { + openMyPlaces(tab: .tracks) + } + + func openTrack(_ track: TrackItem) { + guard let root, + let nav = root.navigationController else { return } + + let history = nav.saveCurrentStateForScrollableHud() + + root.mapPanel.openTargetViewWithGPX( + fromTracksList: track, + navControllerHistory: history, + fromTrackMenu: false, + selectedTab: .overviewTab + ) + } + + func openTrack(gpxFilePath: String) { + guard let item = OAGPXDatabase.sharedDb().getGPXItem(gpxFilePath) else { return } + let track = TrackItem(file: item.file) + openTrack(track) + } + + private func openMyPlaces(tab: MyPlacesContainerViewController.Tab) { + guard let root, + let nav = root.navigationController else { return } + + if let myPlaces = nav.visibleViewController as? MyPlacesContainerViewController { + if myPlaces.availableTabs.contains(tab) { + myPlaces.selectedTab = tab + myPlaces.switchToWithSegmentControl(tab: tab) + } + return + } + + nav.dismiss(animated: false) + nav.popToRootViewController(animated: false) + + let myPlaces = MyPlacesContainerViewController() + myPlaces.loadViewIfNeeded() + myPlaces.selectedTab = tab + nav.pushViewController(myPlaces, animated: true) + } +} From d20cf55a5a299e296a278ec5dc861b25a9cce834 Mon Sep 17 00:00:00 2001 From: dmpr0 Date: Wed, 17 Jun 2026 13:50:06 +0300 Subject: [PATCH 10/47] Added colors for Filter chip and Cell button --- .../Colors/Cell Button/Contents.json | 6 +++ .../cellButtonBg.colorset/Contents.json | 38 +++++++++++++++++++ .../Contents.json | 38 +++++++++++++++++++ .../cellButtonIcon.colorset/Contents.json | 38 +++++++++++++++++++ .../Contents.json | 38 +++++++++++++++++++ .../Colors/Filter chip/Contents.json | 6 +++ .../filterChipBGActive.colorset/Contents.json | 38 +++++++++++++++++++ .../Contents.json | 38 +++++++++++++++++++ .../Contents.json | 38 +++++++++++++++++++ .../Contents.json | 38 +++++++++++++++++++ .../Contents.json | 38 +++++++++++++++++++ .../Contents.json | 38 +++++++++++++++++++ .../ic_custom_sort_rises.svg | 8 +++- 13 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 Resources/Images.xcassets/Colors/Cell Button/Contents.json create mode 100644 Resources/Images.xcassets/Colors/Cell Button/cellButtonBg.colorset/Contents.json create mode 100644 Resources/Images.xcassets/Colors/Cell Button/cellButtonBgPressed.colorset/Contents.json create mode 100644 Resources/Images.xcassets/Colors/Cell Button/cellButtonIcon.colorset/Contents.json create mode 100644 Resources/Images.xcassets/Colors/Cell Button/cellButtonIconDisabled.colorset/Contents.json create mode 100644 Resources/Images.xcassets/Colors/Filter chip/Contents.json create mode 100644 Resources/Images.xcassets/Colors/Filter chip/filterChipBGActive.colorset/Contents.json create mode 100644 Resources/Images.xcassets/Colors/Filter chip/filterChipBGDefault.colorset/Contents.json create mode 100644 Resources/Images.xcassets/Colors/Filter chip/filterChipIconActive.colorset/Contents.json create mode 100644 Resources/Images.xcassets/Colors/Filter chip/filterChipIconDefault.colorset/Contents.json create mode 100644 Resources/Images.xcassets/Colors/Filter chip/filterChipTextActive.colorset/Contents.json create mode 100644 Resources/Images.xcassets/Colors/Filter chip/filterChipTextDefault.colorset/Contents.json diff --git a/Resources/Images.xcassets/Colors/Cell Button/Contents.json b/Resources/Images.xcassets/Colors/Cell Button/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Cell Button/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Colors/Cell Button/cellButtonBg.colorset/Contents.json b/Resources/Images.xcassets/Colors/Cell Button/cellButtonBg.colorset/Contents.json new file mode 100644 index 0000000000..0809f51988 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Cell Button/cellButtonBg.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "0x89", + "green" : "0x6C", + "red" : "0x76" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.150", + "blue" : "0x81", + "green" : "0x74", + "red" : "0x79" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Colors/Cell Button/cellButtonBgPressed.colorset/Contents.json b/Resources/Images.xcassets/Colors/Cell Button/cellButtonBgPressed.colorset/Contents.json new file mode 100644 index 0000000000..397f8026a7 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Cell Button/cellButtonBgPressed.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.500", + "blue" : "0x89", + "green" : "0x6C", + "red" : "0x76" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.500", + "blue" : "0x89", + "green" : "0x6C", + "red" : "0x76" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Colors/Cell Button/cellButtonIcon.colorset/Contents.json b/Resources/Images.xcassets/Colors/Cell Button/cellButtonIcon.colorset/Contents.json new file mode 100644 index 0000000000..0c600f92f1 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Cell Button/cellButtonIcon.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Colors/Cell Button/cellButtonIconDisabled.colorset/Contents.json b/Resources/Images.xcassets/Colors/Cell Button/cellButtonIconDisabled.colorset/Contents.json new file mode 100644 index 0000000000..f3e083169d --- /dev/null +++ b/Resources/Images.xcassets/Colors/Cell Button/cellButtonIconDisabled.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAF", + "green" : "0x9D", + "red" : "0xA4" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x78", + "green" : "0x6D", + "red" : "0x71" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Colors/Filter chip/Contents.json b/Resources/Images.xcassets/Colors/Filter chip/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Filter chip/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Colors/Filter chip/filterChipBGActive.colorset/Contents.json b/Resources/Images.xcassets/Colors/Filter chip/filterChipBGActive.colorset/Contents.json new file mode 100644 index 0000000000..83d5f3d410 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Filter chip/filterChipBGActive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCC", + "green" : "0x14", + "red" : "0x57" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xB3", + "green" : "0x2D", + "red" : "0x5E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Colors/Filter chip/filterChipBGDefault.colorset/Contents.json b/Resources/Images.xcassets/Colors/Filter chip/filterChipBGDefault.colorset/Contents.json new file mode 100644 index 0000000000..e83901bce7 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Filter chip/filterChipBGDefault.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1D", + "green" : "0x1C", + "red" : "0x1C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Colors/Filter chip/filterChipIconActive.colorset/Contents.json b/Resources/Images.xcassets/Colors/Filter chip/filterChipIconActive.colorset/Contents.json new file mode 100644 index 0000000000..66d01872f2 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Filter chip/filterChipIconActive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFD", + "green" : "0xE8", + "red" : "0xEF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFD", + "green" : "0xE8", + "red" : "0xEF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Colors/Filter chip/filterChipIconDefault.colorset/Contents.json b/Resources/Images.xcassets/Colors/Filter chip/filterChipIconDefault.colorset/Contents.json new file mode 100644 index 0000000000..e0450d49f5 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Filter chip/filterChipIconDefault.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCC", + "green" : "0x14", + "red" : "0x57" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEF", + "green" : "0x70", + "red" : "0x5C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Colors/Filter chip/filterChipTextActive.colorset/Contents.json b/Resources/Images.xcassets/Colors/Filter chip/filterChipTextActive.colorset/Contents.json new file mode 100644 index 0000000000..66d01872f2 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Filter chip/filterChipTextActive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFD", + "green" : "0xE8", + "red" : "0xEF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFD", + "green" : "0xE8", + "red" : "0xEF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Colors/Filter chip/filterChipTextDefault.colorset/Contents.json b/Resources/Images.xcassets/Colors/Filter chip/filterChipTextDefault.colorset/Contents.json new file mode 100644 index 0000000000..e0450d49f5 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Filter chip/filterChipTextDefault.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCC", + "green" : "0x14", + "red" : "0x57" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEF", + "green" : "0x70", + "red" : "0x5C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_rises.imageset/ic_custom_sort_rises.svg b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_rises.imageset/ic_custom_sort_rises.svg index 185baac81a..a74624db03 100644 --- a/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_rises.imageset/ic_custom_sort_rises.svg +++ b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sort_rises.imageset/ic_custom_sort_rises.svg @@ -1,3 +1,9 @@ - + + + + + + + From c8a190705327849950f2499b598003053f704619 Mon Sep 17 00:00:00 2001 From: dmpr0 Date: Wed, 17 Jun 2026 14:13:43 +0300 Subject: [PATCH 11/47] Added icons for Choose Plan and banners --- .../Contents.json | 16 +++++++ .../ic_custom_sky_map_download.svg | 47 +++++++++++++++++++ .../Contents.json | 16 +++++++ .../ic_custom_telescope_colored.svg | 35 ++++++++++++++ .../Contents.json | 26 ++++++++++ .../ic_custom_astronomy_colored_day.svg | 35 ++++++++++++++ .../ic_custom_astronomy_colored_night.svg | 34 ++++++++++++++ 7 files changed, 209 insertions(+) create mode 100644 Resources/Images.xcassets/Icons/Astronomy/ic_custom_sky_map_download.imageset/Contents.json create mode 100644 Resources/Images.xcassets/Icons/Astronomy/ic_custom_sky_map_download.imageset/ic_custom_sky_map_download.svg create mode 100644 Resources/Images.xcassets/Icons/Astronomy/ic_custom_telescope_colored.imageset/Contents.json create mode 100644 Resources/Images.xcassets/Icons/Astronomy/ic_custom_telescope_colored.imageset/ic_custom_telescope_colored.svg create mode 100644 Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/Contents.json create mode 100644 Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/ic_custom_astronomy_colored_day.svg create mode 100644 Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/ic_custom_astronomy_colored_night.svg diff --git a/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sky_map_download.imageset/Contents.json b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sky_map_download.imageset/Contents.json new file mode 100644 index 0000000000..1cfe2b5f66 --- /dev/null +++ b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sky_map_download.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_custom_sky_map_download.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sky_map_download.imageset/ic_custom_sky_map_download.svg b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sky_map_download.imageset/ic_custom_sky_map_download.svg new file mode 100644 index 0000000000..b4a4a6e3a2 --- /dev/null +++ b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_sky_map_download.imageset/ic_custom_sky_map_download.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/Images.xcassets/Icons/Astronomy/ic_custom_telescope_colored.imageset/Contents.json b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_telescope_colored.imageset/Contents.json new file mode 100644 index 0000000000..bfbb5b1ca8 --- /dev/null +++ b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_telescope_colored.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_custom_telescope_colored.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Resources/Images.xcassets/Icons/Astronomy/ic_custom_telescope_colored.imageset/ic_custom_telescope_colored.svg b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_telescope_colored.imageset/ic_custom_telescope_colored.svg new file mode 100644 index 0000000000..f254453ff3 --- /dev/null +++ b/Resources/Images.xcassets/Icons/Astronomy/ic_custom_telescope_colored.imageset/ic_custom_telescope_colored.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/Contents.json b/Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/Contents.json new file mode 100644 index 0000000000..fa2603af63 --- /dev/null +++ b/Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "ic_custom_astronomy_colored_day.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "ic_custom_astronomy_colored_night.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/ic_custom_astronomy_colored_day.svg b/Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/ic_custom_astronomy_colored_day.svg new file mode 100644 index 0000000000..14316d3b24 --- /dev/null +++ b/Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/ic_custom_astronomy_colored_day.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/ic_custom_astronomy_colored_night.svg b/Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/ic_custom_astronomy_colored_night.svg new file mode 100644 index 0000000000..2c999abcf5 --- /dev/null +++ b/Resources/Images.xcassets/Icons/choose plan/ic_custom_astronomy_colored.imageset/ic_custom_astronomy_colored_night.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 91055e48273e57d15b67ec06320f36f5c38d693b Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Wed, 17 Jun 2026 14:23:57 +0300 Subject: [PATCH 12/47] update SwiftLint', '~> 0.63' --- .swiftlint.yml | 45 +++++++++++++++++++++++++++++++-------------- Podfile | 4 ++-- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index f1c487dd8c..95863b59c7 100755 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -227,9 +227,13 @@ opt_in_rules: # some rules are only opt-in # https://github.com/realm/SwiftLint/blob/master/Rules.md#array-init - array_init + # Rationale: Avoids misleading async declarations. + # https://github.com/realm/SwiftLint/blob/master/Rules.md#async-without-await + - async_without_await + # Rationale: Provides consistency in coding style # https://github.com/realm/SwiftLint/blob/master/Rules.md#attributes - #- attributes + - attributes # Rationale: Provides consistency in coding style and follows modern practices of the language # https://github.com/realm/SwiftLint/blob/master/Rules.md#block-based-kvo @@ -339,6 +343,10 @@ opt_in_rules: # some rules are only opt-in # https://github.com/realm/SwiftLint/blob/master/Rules.md#fatal-error-message - fatal_error_message + # Rationale: Prevents XCTest classes from being subclassed accidentally. + # https://github.com/realm/SwiftLint/blob/master/Rules.md#final-test-case + - final_test_case + # Rationale: Provides consistency # https://github.com/realm/SwiftLint/blob/master/Rules.md#file-header # - file_header @@ -367,6 +375,10 @@ opt_in_rules: # some rules are only opt-in # https://github.com/realm/SwiftLint/blob/master/Rules.md#implicit-getter - implicit_getter + # Rationale: Catches likely copy-paste mistakes in comparisons. + # https://github.com/realm/SwiftLint/blob/master/Rules.md#identical-operands + - identical_operands + # Rationale: Prevents coder error, doesn't crash, makes coder be explicit about their assumptions # https://github.com/realm/SwiftLint/blob/master/Rules.md#implicitly-unwrapped-optional - implicitly_unwrapped_optional @@ -440,8 +452,8 @@ opt_in_rules: # some rules are only opt-in - operator_usage_whitespace # Rationale: Provides consistency in coding style - # https://github.com/realm/SwiftLint/blob/master/Rules.md#operator-function-whitespace - - operator_whitespace + # https://github.com/realm/SwiftLint/blob/master/Rules.md#function-name-whitespace + - function_name_whitespace # Rationale: Prevents coder error # https://github.com/realm/SwiftLint/blob/master/Rules.md#overridden-methods-call-super @@ -455,6 +467,10 @@ opt_in_rules: # some rules are only opt-in # https://github.com/realm/SwiftLint/blob/master/Rules.md#pattern-matching-keywords - pattern_matching_keywords + # Rationale: Keeps optional enum case matching concise and unambiguous. + # https://github.com/realm/SwiftLint/blob/master/Rules.md#optional-enum-case-matching + - optional_enum_case_matching + # Rationale: UI elements should only be configurable by their owners and not be exposed to others # https://github.com/realm/SwiftLint/blob/master/Rules.md#private-actions - private_action @@ -492,8 +508,8 @@ opt_in_rules: # some rules are only opt-in - redundant_objc_attribute # Rationale: Provides consistency in coding style and brevity - # https://github.com/realm/SwiftLint/blob/master/Rules.md#redundant-optional-initialization - - redundant_optional_initialization + # https://github.com/realm/SwiftLint/blob/master/Rules.md#implicit-optional-initialization + - implicit_optional_initialization # Rationale: Provides consistency in coding style and brevity # https://github.com/realm/SwiftLint/blob/master/Rules.md#redundant-void-return @@ -591,10 +607,6 @@ opt_in_rules: # some rules are only opt-in # https://github.com/realm/SwiftLint/blob/master/Rules.md#void-return - void_return - # Rationale: Avoids using weak when it has no effect. - # https://github.com/realm/SwiftLint/blob/master/Rules.md#weak-computed-property - - weak_computed_property - # Rationale: Prevents retain cycles and coder error # https://github.com/realm/SwiftLint/blob/master/Rules.md#weak-delegate - weak_delegate @@ -623,13 +635,13 @@ opt_in_rules: # some rules are only opt-in # https://github.com/realm/SwiftLint/blob/master/Rules.md#type-contents-order - type_contents_order - # Rationale: Provides consistency in coding style. - # https://github.com/realm/SwiftLint/blob/master/Rules.md#unused-capture-list - - unused_capture_list - # Rationale: Prevents issues with using unowned. # https://github.com/realm/SwiftLint/blob/master/Rules.md#unowned-variable-capture - unowned_variable_capture + + # Rationale: Prevents silently ignored errors from throwing tasks. + # https://github.com/realm/SwiftLint/blob/master/Rules.md#unhandled-throwing-task + - unhandled_throwing_task # Rationale: Ensures all enums can be switched upon. # https://github.com/realm/SwiftLint/blob/master/Rules.md#duplicate-enum-cases @@ -642,6 +654,11 @@ opt_in_rules: # some rules are only opt-in excluded: # paths to ignore during linting. Takes precedence over `included`. - Carthage - Pods + - build + - DerivedData + - ios/Pods + - ios/build + - ios/DerivedData - OsmAnd_projects - Libraries/** @@ -666,7 +683,7 @@ custom_rules: severity: warning custom_todo: name: "TODO Violation" - regex: "(TODO).(?!.*(https&)).(?!.*issue)" + regex: "\\bTODO\\b(?!.*(https?://|issue|#\\d+))" match_kinds: comment message: "TODOs must include a link to the issue." severity: warning diff --git a/Podfile b/Podfile index 4d4e926c9c..f011b93d09 100644 --- a/Podfile +++ b/Podfile @@ -20,10 +20,11 @@ def defaultPods pod 'BRCybertron', '~> 1.1.1' pod 'MCBinaryHeap', '~> 0.1' pod 'TTRangeSlider', '~> 1.0.6' - pod 'SwiftLint', '~> 0.52.4' + pod 'SwiftLint', '~> 0.63' pod 'Kingfisher', '8.3.1' end + target 'OsmAnd Maps' do defaultPods end @@ -37,4 +38,3 @@ post_install do |installer| end end end - From 11951f6b99eb1621d4340a07c49c9c893a57dd64 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Wed, 17 Jun 2026 15:16:10 +0300 Subject: [PATCH 13/47] add script check_access_order.swift --- .swiftlint.yml | 13 +- OsmAnd.xcodeproj/project.pbxproj | 2 +- Scripts/check_access_order.swift | 376 +++++++++++++++++++++++++++++++ 3 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 Scripts/check_access_order.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 95863b59c7..e3a288bb49 100755 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -671,8 +671,17 @@ type_contents_order: - associated_type - type_alias - subtype - - [type_property, instance_property, ib_outlet, ib_inspectable] - - [initializer, deinitializer, type_method, view_life_cycle_method, subscript, other_method, ib_action] + - type_property + - ib_outlet + - ib_inspectable + - instance_property + - initializer + - type_method + - view_life_cycle_method + - other_method + - subscript + - ib_action + - deinitializer custom_rules: force_https: diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index ecd9f23f2f..7478ca370a 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -17140,7 +17140,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Check if SwiftLint is installed\nif ! command -v \"${PODS_ROOT}/SwiftLint/swiftlint\" &>/dev/null; then\n echo \"SwiftLint is not installed. Skipping linting.\"\n exit 0\nfi\n\ncount=0\n\n# Get modified or added .swift files using git status\nmodified_files=$(git status --porcelain | grep -E '^\\s*(M|A)\\s' | awk '{print $2}' | grep \".swift$\")\n\n# Process only modified or added files\nfor file_path in $modified_files; do\n export SCRIPT_INPUT_FILE_$count=$file_path\n count=$((count + 1))\ndone\n\n# Set the total number of files to lint\nexport SCRIPT_INPUT_FILE_COUNT=$count\n\n# If there are any files to lint, run SwiftLint\nif [ $SCRIPT_INPUT_FILE_COUNT -ne 0 ]; then\n # Run linting for modified and added files\n \"${PODS_ROOT}/SwiftLint/swiftlint\" lint --use-script-input-files\nelse\n echo \"No modified files to lint.\"\nfi\n"; + shellScript = "# Check if SwiftLint is installed\nif ! command -v \"${PODS_ROOT}/SwiftLint/swiftlint\" &>/dev/null; then\n echo \"SwiftLint is not installed. Skipping linting.\"\n exit 0\nfi\n\ncount=0\n\n# Get modified or added .swift files using git status\nmodified_files=$(git status --porcelain | grep -E '^\\s*(M|A)\\s' | awk '{print $2}' | grep \".swift$\")\n\n# Process only modified or added files\nfor file_path in $modified_files; do\n export SCRIPT_INPUT_FILE_$count=$file_path\n count=$((count + 1))\ndone\n\n# Set the total number of files to lint\nexport SCRIPT_INPUT_FILE_COUNT=$count\n\n# If there are any files to lint, run SwiftLint and custom checks\nif [ $SCRIPT_INPUT_FILE_COUNT -ne 0 ]; then\n # Run linting for modified and added files\n \"${PODS_ROOT}/SwiftLint/swiftlint\" lint --use-script-input-files\n\n access_order_script=\"${SRCROOT}/Scripts/check_access_order.swift\"\n if [ -f \"$access_order_script\" ] && command -v xcrun &>/dev/null; then\n module_cache_path=\"${DERIVED_FILE_DIR}/AccessOrderModuleCache\"\n mkdir -p \"$module_cache_path\"\n SDKROOT=\"$(xcrun --sdk macosx --show-sdk-path)\" xcrun --sdk macosx swift -module-cache-path \"$module_cache_path\" \"$access_order_script\"\n fi\nelse\n echo \"No modified files to lint.\"\nfi\n"; }; FAFD24032D3684370023C91F /* Link Qt Script (arm_device || arm_simulator) */ = { isa = PBXShellScriptBuildPhase; diff --git a/Scripts/check_access_order.swift b/Scripts/check_access_order.swift new file mode 100644 index 0000000000..bf4cb0fc23 --- /dev/null +++ b/Scripts/check_access_order.swift @@ -0,0 +1,376 @@ +#!/usr/bin/env swift + +import Foundation + +private enum AccessGroup: Hashable { + case open + case publicOnly + case package + case internalOnly + case privateOnly + case fileprivateOnly + + var order: Int { + switch self { + case .open: + return 0 + case .publicOnly: + return 1 + case .package: + return 2 + case .internalOnly: + return 3 + case .privateOnly: + return 4 + case .fileprivateOnly: + return 5 + } + } + + var displayName: String { + switch self { + case .open: + return "open" + case .publicOnly: + return "public" + case .package: + return "package" + case .internalOnly: + return "internal" + case .privateOnly: + return "private" + case .fileprivateOnly: + return "fileprivate" + } + } +} + +private enum MemberBucket: String, CaseIterable { + case instanceProperty + case typeProperty + case instanceMethod + case typeMethod + + var displayName: String { + switch self { + case .instanceProperty: + return "instance property" + case .typeProperty: + return "type property" + case .instanceMethod: + return "instance method" + case .typeMethod: + return "type method" + } + } + + var pluralDisplayName: String { + switch self { + case .instanceProperty: + return "instance properties" + case .typeProperty: + return "type properties" + case .instanceMethod: + return "instance methods" + case .typeMethod: + return "type methods" + } + } +} + +private struct TypeContext { + let bodyDepth: Int + var lastAccessGroup: [MemberBucket: AccessGroup] = [:] + var sawWeakProperty: [AccessGroup: Set] = [:] + + mutating func markAccessGroup(_ accessGroup: AccessGroup, bucket: MemberBucket) { + let current = lastAccessGroup[bucket] + if current == nil || accessGroup.order > current!.order { + lastAccessGroup[bucket] = accessGroup + } + } + + mutating func markWeakProperty(_ bucket: MemberBucket, accessGroup: AccessGroup) { + sawWeakProperty[accessGroup, default: []].insert(bucket) + } + + func hasSeenMoreRestrictiveAccessGroup( + than accessGroup: AccessGroup, + bucket: MemberBucket + ) -> AccessGroup? { + guard let current = lastAccessGroup[bucket], current.order > accessGroup.order else { + return nil + } + return current + } + + func hasSeenWeakProperty(_ bucket: MemberBucket, accessGroup: AccessGroup) -> Bool { + sawWeakProperty[accessGroup]?.contains(bucket) == true + } +} + +private struct MemberDeclaration { + let bucket: MemberBucket + let accessGroup: AccessGroup + let isWeakProperty: Bool +} + +private func accessGroup(from tokens: [String]) -> AccessGroup { + if tokens.contains("open") { + return .open + } + if tokens.contains("public") { + return .publicOnly + } + if tokens.contains("package") { + return .package + } + if tokens.contains("fileprivate") { + return .fileprivateOnly + } + if tokens.contains("private") { + return .privateOnly + } + return .internalOnly +} + +private let ignoredAttributeNames: Set = [ + "IBAction", + "IBInspectable", + "IBOutlet", + "IBSegueAction" +] + +private func inputFiles() -> [String] { + let arguments = Array(CommandLine.arguments.dropFirst()) + if !arguments.isEmpty { + return arguments + } + + let environment = ProcessInfo.processInfo.environment + guard let countString = environment["SCRIPT_INPUT_FILE_COUNT"], + let count = Int(countString), + count > 0 else { + return [] + } + + return (0.. String { + var result = "" + var index = line.startIndex + var inString = false + var escaped = false + + while index < line.endIndex { + let character = line[index] + let nextIndex = line.index(after: index) + let nextCharacter = nextIndex < line.endIndex ? line[nextIndex] : nil + + if inBlockComment { + if character == "*", nextCharacter == "/" { + inBlockComment = false + index = line.index(after: nextIndex) + } else { + index = nextIndex + } + continue + } + + if inString { + if escaped { + escaped = false + } else if character == "\\" { + escaped = true + } else if character == "\"" { + inString = false + } + index = nextIndex + continue + } + + if character == "/", nextCharacter == "/" { + break + } + + if character == "/", nextCharacter == "*" { + inBlockComment = true + index = line.index(after: nextIndex) + continue + } + + if character == "\"" { + inString = true + index = nextIndex + continue + } + + result.append(character) + index = nextIndex + } + + return result +} + +private func braceDelta(in code: String) -> Int { + code.reduce(0) { delta, character in + switch character { + case "{": + return delta + 1 + case "}": + return delta - 1 + default: + return delta + } + } +} + +private func opensTypeDeclaration(_ code: String) -> Bool { + let pattern = #"(^|[^A-Za-z0-9_])(actor|class|enum|extension|protocol|struct)([^A-Za-z0-9_]|$).*[\{]"# + return code.range(of: pattern, options: .regularExpression) != nil +} + +private func attributeNames(in text: String) -> Set { + let pattern = #"@([A-Za-z_][A-Za-z0-9_]*)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [] + } + + let range = NSRange(text.startIndex.. [String]? { + guard let range = code.range( + of: #"(^|[^A-Za-z0-9_])\#(declarationKeyword)([^A-Za-z0-9_]|$)"#, + options: .regularExpression + ) else { + return nil + } + + let prefix = String(code[.. MemberDeclaration? { + let combinedAttributes = attributeNames(in: (pendingAttributes + [code]).joined(separator: "\n")) + if !combinedAttributes.isDisjoint(with: ignoredAttributeNames) { + return nil + } + + if let propertyTokens = tokens(before: "var|let", in: code) { + let isTypeMember = propertyTokens.contains("static") || propertyTokens.contains("class") + let accessGroup = accessGroup(from: propertyTokens) + + return MemberDeclaration( + bucket: isTypeMember ? .typeProperty : .instanceProperty, + accessGroup: accessGroup, + isWeakProperty: propertyTokens.contains("weak") + ) + } + + if let methodTokens = tokens(before: "func", in: code) { + if methodTokens.contains("override") { + return nil + } + + let isTypeMember = methodTokens.contains("static") || methodTokens.contains("class") + let accessGroup = accessGroup(from: methodTokens) + + return MemberDeclaration( + bucket: isTypeMember ? .typeMethod : .instanceMethod, + accessGroup: accessGroup, + isWeakProperty: false + ) + } + + return nil +} + +private func check(filePath: String) -> Int { + guard filePath.hasSuffix(".swift") else { + return 0 + } + + guard let contents = try? String(contentsOfFile: filePath, encoding: .utf8) else { + return 0 + } + + var violationCount = 0 + var braceDepth = 0 + var inBlockComment = false + var contexts: [TypeContext] = [] + var pendingAttributes: [String] = [] + let lines = contents.components(separatedBy: .newlines) + + for (offset, line) in lines.enumerated() { + let lineNumber = offset + 1 + let code = codeWithoutCommentsAndStrings(from: line, inBlockComment: &inBlockComment) + let trimmedCode = code.trimmingCharacters(in: .whitespaces) + + while let context = contexts.last, braceDepth < context.bodyDepth { + contexts.removeLast() + } + + if trimmedCode.hasPrefix("@") { + pendingAttributes.append(trimmedCode) + } else if !trimmedCode.isEmpty, !trimmedCode.hasPrefix("#") { + if contexts.last?.bodyDepth == braceDepth, + let member = memberDeclaration(from: trimmedCode, pendingAttributes: pendingAttributes) { + if member.bucket == .instanceProperty || member.bucket == .typeProperty { + if member.isWeakProperty { + contexts[contexts.count - 1].markWeakProperty( + member.bucket, + accessGroup: member.accessGroup + ) + } else if contexts[contexts.count - 1].hasSeenWeakProperty( + member.bucket, + accessGroup: member.accessGroup + ) { + print("\(filePath):\(lineNumber):1: warning: Non-weak \(member.accessGroup.displayName) \(member.bucket.displayName) should be declared before weak \(member.bucket.pluralDisplayName) (property_access_order)") + violationCount += 1 + } + } + + if let moreRestrictiveAccessGroup = contexts[contexts.count - 1] + .hasSeenMoreRestrictiveAccessGroup( + than: member.accessGroup, + bucket: member.bucket + ) { + print("\(filePath):\(lineNumber):1: warning: \(member.accessGroup.displayName.capitalized) \(member.bucket.displayName) should be declared before \(moreRestrictiveAccessGroup.displayName) \(member.bucket.pluralDisplayName) (access_control_order)") + violationCount += 1 + } + + contexts[contexts.count - 1].markAccessGroup(member.accessGroup, bucket: member.bucket) + } + + pendingAttributes.removeAll() + } + + if opensTypeDeclaration(trimmedCode) { + contexts.append(TypeContext(bodyDepth: braceDepth + 1)) + } + + braceDepth += braceDelta(in: code) + } + + return violationCount +} + +let files = inputFiles() +_ = files.reduce(0) { count, filePath in + count + check(filePath: filePath) +} + +exit(0) From 892fea1f43e97504a10c1280752cc577ad8ed16a Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Wed, 17 Jun 2026 16:02:07 +0300 Subject: [PATCH 14/47] Make iOS static library paths checkout-name independent --- OsmAnd.xcodeproj/project.pbxproj | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index 7478ca370a..ebe3caf12f 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -4745,17 +4745,17 @@ 843E5CA41AB341E000BE14BE /* menu_share_icon@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "menu_share_icon@3x.png"; path = "Resources/Icons/menu_share_icon@3x.png"; sourceTree = ""; }; 843E5CA61AB341E000BE14BE /* menu_star_icon@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "menu_star_icon@3x.png"; path = "Resources/Icons/menu_star_icon@3x.png"; sourceTree = ""; }; 843E5CAA1AB341E000BE14BE /* search_icon@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "search_icon@3x.png"; path = "Resources/Icons/search_icon@3x.png"; sourceTree = ""; }; - 8440E2CD1ABEA7100088856A /* libOsmAndCore_static_standalone.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libOsmAndCore_static_standalone.a; path = "../../osmand/binaries/ios.clang-iphoneos/Debug/libOsmAndCore_static_standalone.a"; sourceTree = ""; }; - 8440E2CF1ABEA7200088856A /* libarchive_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libarchive_static.a; path = "../../osmand/binaries/ios.clang-iphoneos/Debug/libarchive_static.a"; sourceTree = ""; }; - 8440E2D11ABEA7360088856A /* libexpat_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libexpat_static.a; path = "../../osmand/binaries/ios.clang-iphoneos/Debug/libexpat_static.a"; sourceTree = ""; }; - 8440E2D21ABEA7360088856A /* libgdal_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgdal_static.a; path = "../../osmand/binaries/ios.clang-iphoneos/Debug/libgdal_static.a"; sourceTree = ""; }; - 8440E2D31ABEA7360088856A /* libgif_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgif_static.a; path = "../../osmand/binaries/ios.clang-iphoneos/Debug/libgif_static.a"; sourceTree = ""; }; - 8440E2D41ABEA7360088856A /* libicu4c_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libicu4c_static.a; path = "../../osmand/binaries/ios.clang-iphoneos/Debug/libicu4c_static.a"; sourceTree = ""; }; - 8440E2D51ABEA7360088856A /* libjpeg_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libjpeg_static.a; path = "../../osmand/binaries/ios.clang-iphoneos/Debug/libjpeg_static.a"; sourceTree = ""; }; - 8440E2D61ABEA7360088856A /* libpng_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libpng_static.a; path = "../../osmand/binaries/ios.clang-iphoneos/Debug/libpng_static.a"; sourceTree = ""; }; - 8440E2D71ABEA7360088856A /* libprotobuf_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libprotobuf_static.a; path = "../../osmand/binaries/ios.clang-iphoneos/Debug/libprotobuf_static.a"; sourceTree = ""; }; - 8440E2D81ABEA7360088856A /* libskia_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libskia_static.a; path = "../../osmand/binaries/ios.clang-iphoneos/Debug/libskia_static.a"; sourceTree = ""; }; - 8440E2D91ABEA7360088856A /* libz_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libz_static.a; path = "../../osmand/binaries/ios.clang-iphoneos/Debug/libz_static.a"; sourceTree = ""; }; + 8440E2CD1ABEA7100088856A /* libOsmAndCore_static_standalone.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libOsmAndCore_static_standalone.a; path = "../binaries/ios.clang-iphoneos/Debug/libOsmAndCore_static_standalone.a"; sourceTree = ""; }; + 8440E2CF1ABEA7200088856A /* libarchive_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libarchive_static.a; path = "../binaries/ios.clang-iphoneos/Debug/libarchive_static.a"; sourceTree = ""; }; + 8440E2D11ABEA7360088856A /* libexpat_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libexpat_static.a; path = "../binaries/ios.clang-iphoneos/Debug/libexpat_static.a"; sourceTree = ""; }; + 8440E2D21ABEA7360088856A /* libgdal_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgdal_static.a; path = "../binaries/ios.clang-iphoneos/Debug/libgdal_static.a"; sourceTree = ""; }; + 8440E2D31ABEA7360088856A /* libgif_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgif_static.a; path = "../binaries/ios.clang-iphoneos/Debug/libgif_static.a"; sourceTree = ""; }; + 8440E2D41ABEA7360088856A /* libicu4c_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libicu4c_static.a; path = "../binaries/ios.clang-iphoneos/Debug/libicu4c_static.a"; sourceTree = ""; }; + 8440E2D51ABEA7360088856A /* libjpeg_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libjpeg_static.a; path = "../binaries/ios.clang-iphoneos/Debug/libjpeg_static.a"; sourceTree = ""; }; + 8440E2D61ABEA7360088856A /* libpng_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libpng_static.a; path = "../binaries/ios.clang-iphoneos/Debug/libpng_static.a"; sourceTree = ""; }; + 8440E2D71ABEA7360088856A /* libprotobuf_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libprotobuf_static.a; path = "../binaries/ios.clang-iphoneos/Debug/libprotobuf_static.a"; sourceTree = ""; }; + 8440E2D81ABEA7360088856A /* libskia_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libskia_static.a; path = "../binaries/ios.clang-iphoneos/Debug/libskia_static.a"; sourceTree = ""; }; + 8440E2D91ABEA7360088856A /* libz_static.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libz_static.a; path = "../binaries/ios.clang-iphoneos/Debug/libz_static.a"; sourceTree = ""; }; 8441CB131B11EBA30033AC95 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; 8441CB2D1B1370580033AC95 /* selection_checked@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "selection_checked@2x.png"; path = "Resources/Icons/selection_checked@2x.png"; sourceTree = ""; }; 8441CB2E1B1370580033AC95 /* selection_checked@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "selection_checked@3x.png"; path = "Resources/Icons/selection_checked@3x.png"; sourceTree = ""; }; From 90236258195473004365eb3979db5e9dfe1a9122 Mon Sep 17 00:00:00 2001 From: D M Date: Wed, 17 Jun 2026 08:45:47 +0200 Subject: [PATCH 15/47] Translated using Weblate (Serbian (Latin script)) Currently translated at 100.0% (4028 of 4028 strings) --- Resources/Localizations/sr-Latn.lproj/Localizable.strings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/Localizations/sr-Latn.lproj/Localizable.strings b/Resources/Localizations/sr-Latn.lproj/Localizable.strings index 3aaa485368..babfdb6213 100644 --- a/Resources/Localizations/sr-Latn.lproj/Localizable.strings +++ b/Resources/Localizations/sr-Latn.lproj/Localizable.strings @@ -3000,7 +3000,7 @@ "longest_distance_first" = "Prvo najduže rastojanje"; "shortest_distance_first" = "Prvo najkraće rastojanje"; "longest_duration_first" = "Prvo najduže trajanje"; -"shorter_duration_first" = "Prvo najkraće trajanje"; +"shorter_duration_first" = "Prvo kraće trajanje"; "route_preferred_terrain_any" = "Profil terena se ne uzima u obzir pri izboru rute."; "route_preferred_terrain_hilly" = "Rute sa brdovitim profilom reljefa."; "history_clear_alert_title" = "Izbriši svu istoriju"; From 26adfa4896a623be8c36fa6fb67904440245897c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=98=86Verdulo?= Date: Tue, 16 Jun 2026 23:58:36 +0200 Subject: [PATCH 16/47] Translated using Weblate (Esperanto) Currently translated at 65.4% (2636 of 4028 strings) --- Resources/Localizations/eo.lproj/Localizable.strings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/Localizations/eo.lproj/Localizable.strings b/Resources/Localizations/eo.lproj/Localizable.strings index 5d403bbb95..bcd19cf3a8 100644 --- a/Resources/Localizations/eo.lproj/Localizable.strings +++ b/Resources/Localizations/eo.lproj/Localizable.strings @@ -77,7 +77,7 @@ "settings_preset_descr" = "Map-vido kaj navigadaj agordoj estas memoritaj aparte por iu profilo de uzado. Agordi vian implicitan profilon tie ĉi."; "unit_of_length" = "Unuoj de longo"; "unit_of_length_descr" = "Ŝanĝi unuojn por reprezenti distancoj."; -"coords_format" = "Koordinat-formo"; +"coords_format" = "Formo de koordinatoj"; "coordinates" = "Koordinatoj"; "coords_format_descr" = "Formo de geografiaj koordinatoj."; "rotate_map_to" = "Map-orientiĝo"; From 28fa1c9277da6ca9b4b70655816793bc96a87154 Mon Sep 17 00:00:00 2001 From: D M Date: Wed, 17 Jun 2026 08:45:03 +0200 Subject: [PATCH 17/47] Translated using Weblate (Serbian) Currently translated at 100.0% (4028 of 4028 strings) --- Resources/Localizations/sr.lproj/Localizable.strings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/Localizations/sr.lproj/Localizable.strings b/Resources/Localizations/sr.lproj/Localizable.strings index 811c88dd06..d373486a29 100644 --- a/Resources/Localizations/sr.lproj/Localizable.strings +++ b/Resources/Localizations/sr.lproj/Localizable.strings @@ -2836,7 +2836,7 @@ "track_sort_az" = "Име А - З"; "oldest_date_first" = "Најпре најстарији датум"; "longest_distance_first" = "Најпре најдуже растојање"; -"shorter_duration_first" = "Најпре најкраће трајање"; +"shorter_duration_first" = "Најпре краће трајање"; "history_clear_alert_title" = "Избриши сву историју"; "search_history_disabled" = "Претрага историје је онемогућена"; "enable_search_history" = "Можете омогућити претрагу историје у Подешавањима"; From e8f089b702b3709869b1950886803f84fe90a290 Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Wed, 17 Jun 2026 22:23:18 +0200 Subject: [PATCH 18/47] [WIP] Segment route --- .../en.lproj/Localizable.strings | 5 + .../PlanRoute/OAPlanRouteEditingBridge.h | 72 +++ .../PlanRoute/OAPlanRouteEditingBridge.mm | 477 ++++++++++++++++++ .../PlanRouteEditingContextDataProvider.swift | 121 +++++ .../PlanRoute/PlanRouteModels.swift | 30 +- .../PlanRouteScrollableViewController.swift | 4 +- .../PlanRoute/PlanRouteStubDataProvider.swift | 46 +- .../Tabs/PlanRouteRouteViewController.swift | 435 ++++++++++++++-- Sources/OsmAnd Maps-Bridging-Header.h | 1 + 9 files changed, 1147 insertions(+), 44 deletions(-) create mode 100644 Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h create mode 100644 Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm create mode 100644 Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 28830c4347..0c21393dfa 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -1624,6 +1624,11 @@ "plan_route_view_directions" = "View directions"; "plan_route_no_points_title" = "No points added yet"; "plan_route_no_points_descr" = "Tap anywhere on the map or use search to add the first points of your route."; +"shared_string_sort" = "Sort"; +"shared_string_manual" = "Manual"; +"change_mode" = "Change mode"; +"set_single_mode" = "Set single mode"; +"delete_segment" = "Delete segment"; "coord_input_add_point" = "Add point"; "point_num" = "Point %d"; "points_count" = "Points:"; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h new file mode 100644 index 0000000000..8411aa5a78 --- /dev/null +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -0,0 +1,72 @@ +// +// OAPlanRouteEditingBridge.h +// OsmAnd Maps +// +// Created by OsmAnd on 17.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class OAApplicationMode; + +@interface OAPlanRoutePointData : NSObject + +@property (nonatomic, readonly) NSInteger globalIndex; +@property (nonatomic, readonly, copy) NSString *name; +@property (nonatomic, readonly) double distanceFromPrevious; +@property (nonatomic, readonly) double bearing; +@property (nonatomic, readonly) BOOL isStart; +@property (nonatomic, readonly) BOOL isDestination; + +@end + +@interface OAPlanRouteGroupData : NSObject + +@property (nonatomic, readonly, nullable) OAApplicationMode *appMode; +@property (nonatomic, readonly) double distance; +@property (nonatomic, readonly) NSInteger lastGlobalIndex; +@property (nonatomic, readonly) NSArray *points; + +@end + +@interface OAPlanRouteSegmentData : NSObject + +@property (nonatomic, readonly) NSInteger index; +@property (nonatomic, readonly) BOOL routed; +@property (nonatomic, readonly) BOOL multiMode; +@property (nonatomic, readonly, nullable) OAApplicationMode *singleMode; +@property (nonatomic, readonly) double distance; +@property (nonatomic, readonly) NSArray *groups; + +@end + +@interface OAPlanRouteEditingBridge : NSObject + +@property (nonatomic, readonly) BOOL hasContext; +@property (nonatomic, readonly) BOOL hasPoints; +@property (nonatomic, readonly) BOOL isAddNewSegmentAllowed; +@property (nonatomic, readonly) BOOL hasChanges; +@property (nonatomic, readonly) BOOL canUndo; +@property (nonatomic, readonly) BOOL canRedo; +@property (nonatomic, readonly) BOOL hasRoute; +@property (nonatomic, readonly) double routeDistance; + +- (void)prepareNewRoute; +- (void)openTrackWithFilePath:(NSString *)filePath; + +- (NSArray *)buildSegments; +- (NSArray *)availableModes; + +- (void)deletePointAtIndex:(NSInteger)index; +- (void)deleteSegmentWithPointIndexes:(NSArray *)indexes; +- (void)startNewSegment; +- (void)applyMode:(OAApplicationMode *)mode pointIndex:(NSInteger)pointIndex wholeRoute:(BOOL)wholeRoute; +- (void)sortSegmentDoorToDoorWithPointIndexes:(NSArray *)indexes; +- (void)selectPointAtIndex:(NSInteger)index; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm new file mode 100644 index 0000000000..a5ca57568a --- /dev/null +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -0,0 +1,477 @@ +// +// OAPlanRouteEditingBridge.mm +// OsmAnd Maps +// +// Created by OsmAnd on 17.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +#import "OAPlanRouteEditingBridge.h" +#import "Localization.h" +#import "OARootViewController.h" +#import "OAMapPanelViewController.h" +#import "OAMapViewController.h" +#import "OAMapLayers.h" +#import "OAMeasurementToolLayer.h" +#import "OAMeasurementEditingContext.h" +#import "OAMeasurementCommandManager.h" +#import "OAApplicationMode.h" +#import "OAMapUtils.h" +#import "OAGpxData.h" +#import "OsmAndSharedWrapper.h" +#import "OARemovePointCommand.h" +#import "OAReorderPointCommand.h" +#import "OAChangeRouteModeCommand.h" + +@class OAMeasurementToolLayer, OAMeasurementEditingContext; + +@interface OAPlanRoutePointData () + +- (instancetype)initWithGlobalIndex:(NSInteger)globalIndex + name:(NSString *)name + distanceFromPrevious:(double)distanceFromPrevious + bearing:(double)bearing + isStart:(BOOL)isStart + isDestination:(BOOL)isDestination; + +@end + +@interface OAPlanRouteGroupData () + +- (instancetype)initWithAppMode:(nullable OAApplicationMode *)appMode + distance:(double)distance + lastGlobalIndex:(NSInteger)lastGlobalIndex + points:(NSArray *)points; + +@end + +@interface OAPlanRouteSegmentData () + +- (instancetype)initWithIndex:(NSInteger)index + routed:(BOOL)routed + multiMode:(BOOL)multiMode + singleMode:(nullable OAApplicationMode *)singleMode + distance:(double)distance + groups:(NSArray *)groups; + +@end + +@interface OAPlanRouteEditingBridge () + +- (OAMeasurementToolLayer *)layer; +- (OAMeasurementEditingContext *)editingContext; +- (double)distanceFrom:(OASWptPt *)from to:(OASWptPt *)to; +- (double)bearingFrom:(OASWptPt *)from to:(OASWptPt *)to; +- (OAPlanRouteSegmentData *)buildSegmentWithIndex:(NSInteger)segmentIndex + pointIndexes:(NSArray *)pointIndexes + allPoints:(NSArray *)allPoints; +- (OAPlanRouteGroupData *)buildGroupWithKey:(NSString *)key + indexes:(NSArray *)indexes + allPoints:(NSArray *)allPoints; + +@end + +@implementation OAPlanRoutePointData + +- (instancetype)initWithGlobalIndex:(NSInteger)globalIndex + name:(NSString *)name + distanceFromPrevious:(double)distanceFromPrevious + bearing:(double)bearing + isStart:(BOOL)isStart + isDestination:(BOOL)isDestination +{ + self = [super init]; + if (self) + { + _globalIndex = globalIndex; + _name = [name copy]; + _distanceFromPrevious = distanceFromPrevious; + _bearing = bearing; + _isStart = isStart; + _isDestination = isDestination; + } + return self; +} + +@end + +@implementation OAPlanRouteGroupData + +- (instancetype)initWithAppMode:(OAApplicationMode *)appMode + distance:(double)distance + lastGlobalIndex:(NSInteger)lastGlobalIndex + points:(NSArray *)points +{ + self = [super init]; + if (self) + { + _appMode = appMode; + _distance = distance; + _lastGlobalIndex = lastGlobalIndex; + _points = points; + } + return self; +} + +@end + +@implementation OAPlanRouteSegmentData + +- (instancetype)initWithIndex:(NSInteger)index + routed:(BOOL)routed + multiMode:(BOOL)multiMode + singleMode:(OAApplicationMode *)singleMode + distance:(double)distance + groups:(NSArray *)groups +{ + self = [super init]; + if (self) + { + _index = index; + _routed = routed; + _multiMode = multiMode; + _singleMode = singleMode; + _distance = distance; + _groups = groups; + } + return self; +} + +@end + +@implementation OAPlanRouteEditingBridge + +- (OAMeasurementToolLayer *)layer +{ + return OARootViewController.instance.mapPanel.mapViewController.mapLayers.routePlanningLayer; +} + +- (OAMeasurementEditingContext *)editingContext +{ + return [self layer].editingCtx; +} + +- (BOOL)hasContext +{ + return [self editingContext] != nil; +} + +- (BOOL)hasPoints +{ + return [self editingContext].getPoints.count > 0; +} + +- (BOOL)isAddNewSegmentAllowed +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + return ctx != nil && [ctx isAddNewSegmentAllowed]; +} + +- (BOOL)hasChanges +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + return ctx != nil && [ctx hasChanges]; +} + +- (BOOL)canUndo +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + return ctx != nil && [ctx.commandManager canUndo]; +} + +- (BOOL)canRedo +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + return ctx != nil && [ctx.commandManager canRedo]; +} + +- (BOOL)hasRoute +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + return ctx != nil && [ctx hasRoute]; +} + +- (double)routeDistance +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + return ctx != nil ? [ctx getRouteDistance] : 0; +} + +- (NSArray *)availableModes +{ + return [OAApplicationMode values]; +} + +- (void)prepareNewRoute +{ + OAMeasurementToolLayer *layer = [self layer]; + if (layer == nil) + return; + OAMeasurementEditingContext *ctx = [[OAMeasurementEditingContext alloc] init]; + layer.editingCtx = ctx; + [ctx.commandManager setMeasurementLayer:layer]; + [layer updateLayer]; +} + +- (void)openTrackWithFilePath:(NSString *)filePath +{ + OAMeasurementToolLayer *layer = [self layer]; + if (layer == nil) + return; + + OAMeasurementEditingContext *ctx = [[OAMeasurementEditingContext alloc] init]; + + OASGpxFile *gpxFile = nil; + if (filePath.length > 0) + { + OASKFile *file = [[OASKFile alloc] initWithFilePath:filePath]; + gpxFile = [OASGpxUtilities.shared loadGpxFileFile:file]; + } + OAGpxData *gpxData = gpxFile != nil ? [[OAGpxData alloc] initWithFile:gpxFile] : nil; + ctx.gpxData = gpxData; + + NSArray *routePoints = gpxData.gpxFile.getRoutePoints; + if (routePoints.count > 0) + { + OAApplicationMode *appMode = [OAApplicationMode valueOfStringKey:routePoints.lastObject.getProfileType def:nil]; + if (appMode != nil) + ctx.appMode = appMode; + } + + layer.editingCtx = ctx; + [ctx.commandManager setMeasurementLayer:layer]; + [ctx addPoints]; + [layer updateLayer]; +} + +- (double)distanceFrom:(OASWptPt *)from to:(OASWptPt *)to +{ + return [OAMapUtils getDistance:from.lat lon1:from.lon lat2:to.lat lon2:to.lon]; +} + +- (double)bearingFrom:(OASWptPt *)from to:(OASWptPt *)to +{ + double lat1 = from.lat * M_PI / 180.0; + double lat2 = to.lat * M_PI / 180.0; + double deltaLon = (to.lon - from.lon) * M_PI / 180.0; + double y = sin(deltaLon) * cos(lat2); + double x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(deltaLon); + double degrees = atan2(y, x) * 180.0 / M_PI; + return fmod(degrees + 360.0, 360.0); +} + +- (NSArray *)buildSegments +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return @[]; + NSArray *points = ctx.getPoints; + if (points.count == 0) + return @[]; + + NSMutableArray *result = [NSMutableArray array]; + NSMutableArray *segmentIndexes = [NSMutableArray array]; + NSInteger segmentNumber = 0; + for (NSInteger i = 0; i < (NSInteger) points.count; i++) + { + [segmentIndexes addObject:@(i)]; + OASWptPt *point = points[i]; + BOOL last = i == (NSInteger) points.count - 1; + if (point.isGap || last) + { + [result addObject:[self buildSegmentWithIndex:segmentNumber pointIndexes:segmentIndexes allPoints:points]]; + segmentIndexes = [NSMutableArray array]; + segmentNumber++; + } + } + return result; +} + +- (OAPlanRouteSegmentData *)buildSegmentWithIndex:(NSInteger)segmentIndex + pointIndexes:(NSArray *)pointIndexes + allPoints:(NSArray *)allPoints +{ + NSMutableArray *groups = [NSMutableArray array]; + NSMutableArray *currentIndexes = [NSMutableArray array]; + NSString *currentKey = nil; + BOOL hasCurrent = NO; + + for (NSNumber *indexNumber in pointIndexes) + { + NSInteger index = indexNumber.integerValue; + NSString *key = allPoints[index].getProfileType ?: @""; + if (!hasCurrent) + { + currentKey = key; + hasCurrent = YES; + } + if (![key isEqualToString:currentKey] && currentIndexes.count > 0) + { + [groups addObject:[self buildGroupWithKey:currentKey indexes:currentIndexes allPoints:allPoints]]; + currentIndexes = [NSMutableArray array]; + currentKey = key; + } + [currentIndexes addObject:indexNumber]; + } + if (currentIndexes.count > 0) + [groups addObject:[self buildGroupWithKey:currentKey indexes:currentIndexes allPoints:allPoints]]; + + NSInteger routedCount = 0; + OAApplicationMode *singleMode = nil; + double distance = 0; + for (OAPlanRouteGroupData *group in groups) + { + distance += group.distance; + if (group.appMode != nil) + { + routedCount++; + if (singleMode == nil) + singleMode = group.appMode; + } + } + BOOL routed = routedCount > 0; + BOOL multiMode = routedCount > 1; + return [[OAPlanRouteSegmentData alloc] initWithIndex:segmentIndex + routed:routed + multiMode:multiMode + singleMode:multiMode ? nil : singleMode + distance:distance + groups:groups]; +} + +- (OAPlanRouteGroupData *)buildGroupWithKey:(NSString *)key + indexes:(NSArray *)indexes + allPoints:(NSArray *)allPoints +{ + OAApplicationMode *appMode = nil; + if (key.length > 0) + appMode = [OAApplicationMode valueOfStringKey:key def:OAApplicationMode.DEFAULT]; + + NSMutableArray *points = [NSMutableArray array]; + double groupDistance = 0; + for (NSNumber *indexNumber in indexes) + { + NSInteger index = indexNumber.integerValue; + OASWptPt *point = allPoints[index]; + BOOL isStart = index == 0; + BOOL isDestination = index == (NSInteger) allPoints.count - 1; + double legDistance = 0; + double bearing = 0; + if (index > 0) + { + OASWptPt *previous = allPoints[index - 1]; + if (!previous.isGap) + { + legDistance = [self distanceFrom:previous to:point]; + bearing = [self bearingFrom:previous to:point]; + groupDistance += legDistance; + } + } + NSString *name = point.name.length > 0 ? point.name : [NSString stringWithFormat:@"%@ %ld", OALocalizedString(@"shared_string_waypoint"), (long) (index + 1)]; + [points addObject:[[OAPlanRoutePointData alloc] initWithGlobalIndex:index + name:name + distanceFromPrevious:legDistance + bearing:bearing + isStart:isStart + isDestination:isDestination]]; + } + NSInteger lastIndex = indexes.lastObject.integerValue; + return [[OAPlanRouteGroupData alloc] initWithAppMode:appMode distance:groupDistance lastGlobalIndex:lastIndex points:points]; +} + +- (void)deletePointAtIndex:(NSInteger)index +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + [ctx.commandManager execute:[[OARemovePointCommand alloc] initWithLayer:layer position:index]]; + [layer updateLayer]; +} + +- (void)deleteSegmentWithPointIndexes:(NSArray *)indexes +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + NSArray *sorted = [indexes sortedArrayUsingComparator:^NSComparisonResult(NSNumber *a, NSNumber *b) { + return [b compare:a]; + }]; + for (NSNumber *indexNumber in sorted) + [ctx.commandManager execute:[[OARemovePointCommand alloc] initWithLayer:layer position:indexNumber.integerValue]]; + [layer updateLayer]; +} + +- (void)startNewSegment +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + [ctx splitPoints:ctx.getBeforePoints.count after:YES]; + [layer updateLayer]; +} + +- (void)applyMode:(OAApplicationMode *)mode pointIndex:(NSInteger)pointIndex wholeRoute:(BOOL)wholeRoute +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + ctx.appMode = mode; + EOAChangeRouteType type = wholeRoute ? EOAChangeRouteWhole : EOAChangeRouteNextSegment; + [ctx.commandManager execute:[[OAChangeRouteModeCommand alloc] initWithLayer:layer appMode:mode changeRouteType:type pointIndex:pointIndex]]; + [layer updateLayer]; +} + +- (void)selectPointAtIndex:(NSInteger)index +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + ctx.selectedPointPosition = index; +} + +- (void)sortSegmentDoorToDoorWithPointIndexes:(NSArray *)indexes +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil || indexes.count < 3) + return; + + NSArray *points = ctx.getPoints; + NSMutableArray *remaining = [NSMutableArray arrayWithArray:[indexes subarrayWithRange:NSMakeRange(1, indexes.count - 1)]]; + NSMutableArray *ordered = [NSMutableArray arrayWithObject:indexes.firstObject]; + NSInteger currentIndex = indexes.firstObject.integerValue; + while (remaining.count > 0) + { + NSInteger bestPosition = 0; + double bestDistance = DBL_MAX; + for (NSInteger i = 0; i < (NSInteger) remaining.count; i++) + { + double dist = [self distanceFrom:points[currentIndex] to:points[remaining[i].integerValue]]; + if (dist < bestDistance) + { + bestDistance = dist; + bestPosition = i; + } + } + NSNumber *next = remaining[bestPosition]; + [remaining removeObjectAtIndex:bestPosition]; + [ordered addObject:next]; + currentIndex = next.integerValue; + } + + NSInteger base = indexes.firstObject.integerValue; + for (NSInteger target = 0; target < (NSInteger) ordered.count; target++) + { + NSInteger from = ordered[target].integerValue; + NSInteger to = base + target; + if (from != to) + [ctx.commandManager execute:[[OAReorderPointCommand alloc] initWithLayer:layer from:from to:to]]; + } + [layer updateLayer]; +} + +@end diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift new file mode 100644 index 0000000000..9216b62a07 --- /dev/null +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -0,0 +1,121 @@ +// +// PlanRouteEditingContextDataProvider.swift +// OsmAnd Maps +// +// Created by OsmAnd on 17.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { + let mode: PlanRouteMode + + private let bridge = OAPlanRouteEditingBridge() + + init(mode: PlanRouteMode = .newRoute, filePath: String? = nil) { + self.mode = mode + if mode.isEditTrack, let filePath { + bridge.openTrack(withFilePath: filePath) + } else { + bridge.prepareNewRoute() + } + } + + var hasChanges: Bool { + bridge.hasChanges + } + + var canUndo: Bool { + bridge.canUndo + } + + var canRedo: Bool { + bridge.canRedo + } + + var routeInfo: PlanRouteInfo { + PlanRouteInfo(isNewRoute: mode.isNewRoute, + isStraightLine: false, + hasRoute: bridge.hasRoute, + totalDistance: bridge.routeDistance, + duration: 0, + arrivalTime: nil, + uphill: 0, + downhill: 0, + mapCenterDistance: 0, + bearing: 0) + } + + var elevationData: PlanRouteElevationData? { + nil + } + + var poiPoints: [PlanRoutePoint] { + [] + } + + var routeSegments: [PlanRouteSegment] { + bridge.buildSegments().map { mapSegment($0) } + } + + var canStartNewSegment: Bool { + bridge.isAddNewSegmentAllowed + } + + var availableModes: [OAApplicationMode] { + bridge.availableModes() + } + + func deleteRoutePoint(at index: Int) { + bridge.deletePoint(at: index) + } + + func deleteSegment(pointIndexes: [Int]) { + bridge.deleteSegment(withPointIndexes: pointIndexes.map { NSNumber(value: $0) }) + } + + func startNewSegment() { + bridge.startNewSegment() + } + + func applyMode(_ mode: OAApplicationMode, pointIndex: Int, wholeRoute: Bool) { + bridge.apply(mode, pointIndex: pointIndex, wholeRoute: wholeRoute) + } + + func sortDoorToDoor(pointIndexes: [Int]) { + bridge.sortSegmentDoorToDoor(withPointIndexes: pointIndexes.map { NSNumber(value: $0) }) + } + + func saveSegment(pointIndexes: [Int]) { + } + + func selectRoutePoint(at index: Int) { + bridge.selectPoint(at: index) + } + + private func mapSegment(_ segment: OAPlanRouteSegmentData) -> PlanRouteSegment { + PlanRouteSegment(index: segment.index, + groups: segment.groups.map { mapGroup($0) }, + routed: segment.routed, + multiMode: segment.multiMode, + singleMode: segment.singleMode, + distance: segment.distance) + } + + private func mapGroup(_ group: OAPlanRouteGroupData) -> PlanRouteProfileGroup { + PlanRouteProfileGroup(appMode: group.appMode, + distance: group.distance, + lastPointIndex: group.lastGlobalIndex, + points: group.points.map { mapPoint($0) }) + } + + private func mapPoint(_ point: OAPlanRoutePointData) -> PlanRoutePoint { + PlanRoutePoint(index: point.globalIndex, + name: point.name, + distanceFromPrevious: point.distanceFromPrevious, + bearing: point.bearing, + isStart: point.isStart, + isDestination: point.isDestination) + } +} diff --git a/Sources/Controllers/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/PlanRoute/PlanRouteModels.swift index 1bf35b4a78..3a70418135 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteModels.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteModels.swift @@ -138,9 +138,24 @@ struct PlanRoutePoint { let isDestination: Bool } +struct PlanRouteProfileGroup { + let appMode: OAApplicationMode? + let distance: Double + let lastPointIndex: Int + let points: [PlanRoutePoint] +} + struct PlanRouteSegment { let index: Int - let points: [PlanRoutePoint] + let groups: [PlanRouteProfileGroup] + let routed: Bool + let multiMode: Bool + let singleMode: OAApplicationMode? + let distance: Double + + var pointIndexes: [Int] { + groups.flatMap { $0.points.map { $0.index } } + } } struct PlanRouteElevationData { @@ -160,8 +175,17 @@ protocol PlanRouteAnalyzeDataSource: AnyObject { protocol PlanRoutePointsDataSource: AnyObject { var routeInfo: PlanRouteInfo { get } - var segments: [PlanRouteSegment] { get } - var routePoints: [PlanRoutePoint] { get } + var routeSegments: [PlanRouteSegment] { get } + var canStartNewSegment: Bool { get } + var availableModes: [OAApplicationMode] { get } + + func deleteRoutePoint(at index: Int) + func deleteSegment(pointIndexes: [Int]) + func startNewSegment() + func applyMode(_ mode: OAApplicationMode, pointIndex: Int, wholeRoute: Bool) + func sortDoorToDoor(pointIndexes: [Int]) + func saveSegment(pointIndexes: [Int]) + func selectRoutePoint(at index: Int) } protocol PlanRouteDataProvider: PlanRoutePoiDataSource, PlanRouteAnalyzeDataSource, PlanRoutePointsDataSource { diff --git a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift index d37e41a54e..b7f55d0251 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift @@ -47,13 +47,13 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController @objc(showNewRoute) static func showNewRoute() { - showPlanRoute(dataProvider: PlanRouteStubDataProvider(mode: .newRoute)) + showPlanRoute(dataProvider: PlanRouteEditingContextDataProvider(mode: .newRoute)) } @objc(openExistingTrackWithFilePath:) static func openExistingTrack(filePath: String) { let fileName = ((filePath as NSString).lastPathComponent as NSString).deletingPathExtension - showPlanRoute(dataProvider: PlanRouteStubDataProvider(mode: .editTrack(fileName: fileName))) + showPlanRoute(dataProvider: PlanRouteEditingContextDataProvider(mode: .editTrack(fileName: fileName), filePath: filePath)) } private static func showPlanRoute(dataProvider: PlanRouteDataProvider) { diff --git a/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift index 0342b1470f..2620d8914e 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift @@ -30,7 +30,7 @@ final class PlanRouteStubDataProvider: PlanRouteDataProvider { var routeInfo: PlanRouteInfo { PlanRouteInfo(isNewRoute: mode.isNewRoute, isStraightLine: false, - hasRoute: !routePoints.isEmpty, + hasRoute: !routeSegments.isEmpty, totalDistance: 0, duration: 0, arrivalTime: nil, @@ -48,11 +48,47 @@ final class PlanRouteStubDataProvider: PlanRouteDataProvider { [] } - var routePoints: [PlanRoutePoint] { - [] + var routeSegments: [PlanRouteSegment] { + guard mode.isEditTrack else { return [] } + return [sampleSegment] } - var segments: [PlanRouteSegment] { - [] + var canStartNewSegment: Bool { + mode.isEditTrack + } + + private var sampleSegment: PlanRouteSegment { + let cyclingPoints = [ + PlanRoutePoint(index: 0, name: "Point - 1", distanceFromPrevious: 0, bearing: 100, isStart: true, isDestination: false), + PlanRoutePoint(index: 1, name: "Point - 2", distanceFromPrevious: 100, bearing: 100, isStart: false, isDestination: false) + ] + let walkingPoints = [ + PlanRoutePoint(index: 2, name: "Point - 3", distanceFromPrevious: 200, bearing: 100, isStart: false, isDestination: false), + PlanRoutePoint(index: 3, name: "Point - 4", distanceFromPrevious: 5000, bearing: 100, isStart: false, isDestination: false), + PlanRoutePoint(index: 4, name: "Point - 5", distanceFromPrevious: 3580, bearing: 100, isStart: false, isDestination: true) + ] + let groups = [ + PlanRouteProfileGroup(appMode: OAApplicationMode.bicycle(), distance: 53000, lastPointIndex: 1, points: cyclingPoints), + PlanRouteProfileGroup(appMode: OAApplicationMode.pedestrian(), distance: 120000, lastPointIndex: 4, points: walkingPoints) + ] + return PlanRouteSegment(index: 0, groups: groups, routed: true, multiMode: true, singleMode: nil, distance: 173000) } + + var availableModes: [OAApplicationMode] { + OAApplicationMode.values() + } + + func deleteRoutePoint(at index: Int) {} + + func deleteSegment(pointIndexes: [Int]) {} + + func startNewSegment() {} + + func applyMode(_ mode: OAApplicationMode, pointIndex: Int, wholeRoute: Bool) {} + + func sortDoorToDoor(pointIndexes: [Int]) {} + + func saveSegment(pointIndexes: [Int]) {} + + func selectRoutePoint(at index: Int) {} } diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift index ca6e60cc94..32896c1982 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift @@ -11,10 +11,27 @@ import UIKit final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent { let planRouteTab: PlanRouteTab = .route + private enum Row { + case profileGroup(PlanRouteProfileGroup, segment: PlanRouteSegment) + case point(PlanRoutePoint) + case empty + } + + private struct SectionModel { + let headerTitle: String? + let headerSubtitle: String? + let headerMenu: UIMenu? + let rows: [Row] + let isStartNewSegment: Bool + } + + private static let separatorLeftInset: CGFloat = 76 + private static let bottomContentInset: CGFloat = 72 + private weak var dataSource: PlanRoutePointsDataSource? private let tableView = UITableView(frame: .zero, style: .insetGrouped) - private var points: [PlanRoutePoint] = [] + private var sections: [SectionModel] = [] init(dataSource: PlanRoutePointsDataSource?) { self.dataSource = dataSource @@ -33,7 +50,7 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent func reloadData() { guard isViewLoaded else { return } - points = dataSource?.routePoints ?? [] + sections = buildSections() tableView.reloadData() } @@ -42,10 +59,14 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent tableView.backgroundColor = .clear tableView.dataSource = self tableView.delegate = self - tableView.separatorInset = UIEdgeInsets(top: 0, left: 76, bottom: 0, right: 0) - tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 72, right: 0) + tableView.separatorInset = UIEdgeInsets(top: 0, left: Self.separatorLeftInset, bottom: 0, right: 0) + tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: Self.bottomContentInset, right: 0) + tableView.sectionHeaderTopPadding = 0 tableView.register(PlanRoutePointCell.self, forCellReuseIdentifier: PlanRoutePointCell.cellReuseId) + tableView.register(PlanRouteProfileGroupCell.self, forCellReuseIdentifier: PlanRouteProfileGroupCell.cellReuseId) tableView.register(PlanRouteEmptyCell.self, forCellReuseIdentifier: PlanRouteEmptyCell.cellReuseId) + tableView.register(PlanRouteStartSegmentCell.self, forCellReuseIdentifier: PlanRouteStartSegmentCell.cellReuseId) + tableView.register(PlanRouteSegmentHeaderView.self, forHeaderFooterViewReuseIdentifier: PlanRouteSegmentHeaderView.reuseId) tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -55,58 +76,405 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) } + + private func buildSections() -> [SectionModel] { + let segments = dataSource?.routeSegments ?? [] + guard !segments.isEmpty else { + return [SectionModel(headerTitle: localizedString("route_points"), + headerSubtitle: nil, + headerMenu: nil, + rows: [.empty], + isStartNewSegment: false)] + } + + let multipleSegments = segments.count > 1 + var result: [SectionModel] = segments.map { makeSection(for: $0, multipleSegments: multipleSegments) } + if dataSource?.canStartNewSegment ?? false { + result.append(SectionModel(headerTitle: nil, + headerSubtitle: nil, + headerMenu: nil, + rows: [], + isStartNewSegment: true)) + } + return result + } + + private func makeSection(for segment: PlanRouteSegment, multipleSegments: Bool) -> SectionModel { + var rows: [Row] = [] + if segment.multiMode { + for group in segment.groups { + if group.appMode != nil { + rows.append(.profileGroup(group, segment: segment)) + } + rows.append(contentsOf: group.points.map { Row.point($0) }) + } + } else { + for group in segment.groups { + rows.append(contentsOf: group.points.map { Row.point($0) }) + } + } + + let title: String + if segment.routed || multipleSegments { + title = String(format: localizedString("segments_count"), segment.index + 1) + } else { + title = localizedString("route_points") + } + + var subtitle: String? + if segment.routed, !segment.multiMode, let mode = segment.singleMode { + subtitle = "\(mode.toHumanString()) • \(formattedDistance(segment.distance))" + } + + return SectionModel(headerTitle: title, + headerSubtitle: subtitle, + headerMenu: makeSegmentMenu(for: segment), + rows: rows, + isStartNewSegment: false) + } + + private func makeSegmentMenu(for segment: PlanRouteSegment) -> UIMenu { + var children: [UIMenuElement] = [] + if segment.multiMode { + children.append(UIAction(title: localizedString("set_single_mode"), + image: .templateImageNamed("ic_custom_point_to_point")) { [weak self] _ in + self?.setSingleMode(for: segment) + }) + } else { + children.append(UIAction(title: localizedString("change_mode"), + subtitle: segment.singleMode?.toHumanString(), + image: segment.singleMode?.getIcon()) { [weak self] _ in + self?.presentModePicker(pointIndex: segment.pointIndexes.last ?? 0, wholeRoute: true) + }) + } + children.append(makeSortMenu(pointIndexes: segment.pointIndexes)) + children.append(UIAction(title: localizedString("plan_route_save_as"), + image: .templateImageNamed("ic_custom_save_to_file")) { [weak self] _ in + self?.dataSource?.saveSegment(pointIndexes: segment.pointIndexes) + }) + children.append(UIAction(title: localizedString("delete_segment"), + image: .templateImageNamed("ic_custom_trash_outlined"), + attributes: .destructive) { [weak self] _ in + self?.deleteSegment(pointIndexes: segment.pointIndexes) + }) + return UIMenu(children: children) + } + + private func makeGroupMenu(for group: PlanRouteProfileGroup, in segment: PlanRouteSegment) -> UIMenu { + let groupIndexes = group.points.map { $0.index } + let changeMode = UIAction(title: localizedString("change_mode"), + subtitle: group.appMode?.toHumanString(), + image: group.appMode?.getIcon()) { [weak self] _ in + self?.presentModePicker(pointIndex: group.lastPointIndex, wholeRoute: false) + } + let saveAs = UIAction(title: localizedString("plan_route_save_as"), + image: .templateImageNamed("ic_custom_save_to_file")) { [weak self] _ in + self?.dataSource?.saveSegment(pointIndexes: groupIndexes) + } + let deleteSegment = UIAction(title: localizedString("delete_segment"), + image: .templateImageNamed("ic_custom_trash_outlined"), + attributes: .destructive) { [weak self] _ in + self?.deleteSegment(pointIndexes: groupIndexes) + } + return UIMenu(children: [changeMode, makeSortMenu(pointIndexes: segment.pointIndexes), saveAs, deleteSegment]) + } + + private func makeSortMenu(pointIndexes: [Int]) -> UIMenu { + let manual = UIAction(title: localizedString("shared_string_manual"), + image: .templateImageNamed("ic_custom_direction_manual"), + state: .on) { _ in } + let doorToDoor = UIAction(title: localizedString("intermediate_items_sort_by_distance"), + image: .templateImageNamed("ic_custom_sort_door_to_door")) { [weak self] _ in + self?.dataSource?.sortDoorToDoor(pointIndexes: pointIndexes) + self?.reloadData() + } + return UIMenu(title: localizedString("shared_string_sort"), children: [manual, doorToDoor]) + } + + private func presentModePicker(pointIndex: Int, wholeRoute: Bool) { + guard let dataSource else { return } + let alert = UIAlertController(title: localizedString("change_mode"), message: nil, preferredStyle: .actionSheet) + for mode in dataSource.availableModes { + alert.addAction(UIAlertAction(title: mode.toHumanString(), style: .default) { [weak self] _ in + dataSource.applyMode(mode, pointIndex: pointIndex, wholeRoute: wholeRoute) + self?.reloadData() + }) + } + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func setSingleMode(for segment: PlanRouteSegment) { + guard let mode = segment.groups.compactMap({ $0.appMode }).first else { return } + dataSource?.applyMode(mode, pointIndex: segment.pointIndexes.last ?? 0, wholeRoute: true) + reloadData() + } + + private func deleteSegment(pointIndexes: [Int]) { + dataSource?.deleteSegment(pointIndexes: pointIndexes) + reloadData() + } + + private func deletePoint(at index: Int) { + dataSource?.deleteRoutePoint(at: index) + reloadData() + } + + private func startNewSegment() { + dataSource?.startNewSegment() + reloadData() + } + + private func formattedDistance(_ meters: Double) -> String { + OAOsmAndFormatter.getFormattedDistance(Float(meters)) + } } // MARK: - UITableViewDataSource extension PlanRouteRouteViewController: UITableViewDataSource { func numberOfSections(in tableView: UITableView) -> Int { - points.isEmpty ? 1 : 2 + sections.count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - section == 0 ? max(points.count, 1) : 1 - } - - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - section == 0 ? localizedString("route_points") : nil + let model = sections[section] + return model.isStartNewSegment ? 1 : model.rows.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if indexPath.section == 0, points.isEmpty { - guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRouteEmptyCell.cellReuseId, for: indexPath) as? PlanRouteEmptyCell else { + let section = sections[indexPath.section] + if section.isStartNewSegment { + guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRouteStartSegmentCell.cellReuseId, for: indexPath) as? PlanRouteStartSegmentCell else { return UITableViewCell() } return cell } - if indexPath.section == 1 { - let cell = UITableViewCell(style: .default, reuseIdentifier: nil) - cell.textLabel?.text = localizedString("gpx_start_new_segment") - cell.textLabel?.textColor = .iconColorActive - cell.textLabel?.font = .scaledSystemFont(ofSize: 17) - cell.backgroundColor = .groupBg + switch section.rows[indexPath.row] { + case .empty: + guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRouteEmptyCell.cellReuseId, for: indexPath) as? PlanRouteEmptyCell else { + return UITableViewCell() + } + return cell + case let .profileGroup(group, segment): + guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRouteProfileGroupCell.cellReuseId, for: indexPath) as? PlanRouteProfileGroupCell else { + return UITableViewCell() + } + let mode = group.appMode + cell.configure(title: mode?.toHumanString() ?? "", + distanceText: formattedDistance(group.distance), + icon: mode?.getIcon(), + tintColor: mode?.getProfileColor() ?? .iconColorActive, + menu: makeGroupMenu(for: group, in: segment)) + return cell + case let .point(point): + guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRoutePointCell.cellReuseId, for: indexPath) as? PlanRoutePointCell else { + return UITableViewCell() + } + cell.configure(with: point) + cell.onDelete = { [weak self] in + self?.deletePoint(at: point.index) + } return cell } - guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRoutePointCell.cellReuseId, for: indexPath) as? PlanRoutePointCell else { - return UITableViewCell() - } - cell.configure(with: points[indexPath.row]) - cell.onDelete = { [weak self] in - print("[PlanRoute] Delete point at index: \(indexPath.row)") - self?.reloadData() - } - return cell } } // MARK: - UITableViewDelegate extension PlanRouteRouteViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let title = sections[section].headerTitle else { return nil } + guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: PlanRouteSegmentHeaderView.reuseId) as? PlanRouteSegmentHeaderView else { + return nil + } + header.configure(title: title, subtitle: sections[section].headerSubtitle, menu: sections[section].headerMenu) + return header + } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - if indexPath.section == 1 { - print("[PlanRoute] Start new segment tapped") - } else if !points.isEmpty { - print("[PlanRoute] Selected point at index: \(indexPath.row)") + let section = sections[indexPath.section] + if section.isStartNewSegment { + startNewSegment() + return + } + if case let .point(point) = section.rows[indexPath.row] { + dataSource?.selectRoutePoint(at: point.index) + } + } +} + +final class PlanRouteSegmentHeaderView: UITableViewHeaderFooterView { + static let reuseId = "PlanRouteSegmentHeaderView" + + private static let optionsButtonSize: CGFloat = 30 + + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let optionsButton = UIButton(type: .system) + + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String, subtitle: String?, menu: UIMenu?) { + titleLabel.text = title + subtitleLabel.text = subtitle + subtitleLabel.isHidden = subtitle?.isEmpty ?? true + optionsButton.menu = menu + optionsButton.isHidden = menu == nil + } + + private func setupView() { + titleLabel.font = .scaledSystemFont(ofSize: 22, weight: .bold) + titleLabel.textColor = .textColorPrimary + titleLabel.numberOfLines = 1 + + subtitleLabel.font = .scaledSystemFont(ofSize: 13) + subtitleLabel.textColor = .textColorSecondary + subtitleLabel.numberOfLines = 1 + + let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + textStack.axis = .vertical + textStack.spacing = 2 + + var configuration = UIButton.Configuration.plain() + configuration.image = UIImage(systemName: "ellipsis") + configuration.baseForegroundColor = .iconColorActive + configuration.background.backgroundColor = .groupBgColorSecondary + configuration.background.cornerRadius = Self.optionsButtonSize / 2 + optionsButton.configuration = configuration + optionsButton.showsMenuAsPrimaryAction = true + + [textStack, optionsButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) } + + NSLayoutConstraint.activate([ + textStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + textStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + textStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + + optionsButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + optionsButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + optionsButton.leadingAnchor.constraint(greaterThanOrEqualTo: textStack.trailingAnchor, constant: 12), + optionsButton.widthAnchor.constraint(equalToConstant: Self.optionsButtonSize), + optionsButton.heightAnchor.constraint(equalToConstant: Self.optionsButtonSize) + ]) + } +} + +final class PlanRouteProfileGroupCell: UITableViewCell { + static let cellReuseId = "PlanRouteProfileGroupCell" + + private static let iconSize: CGFloat = 24 + private static let optionsButtonSize: CGFloat = 30 + + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let distanceLabel = UILabel() + private let optionsButton = UIButton(type: .system) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String, distanceText: String, icon: UIImage?, tintColor: UIColor, menu: UIMenu) { + iconView.image = icon?.withRenderingMode(.alwaysTemplate) + iconView.tintColor = tintColor + titleLabel.text = title + distanceLabel.text = distanceText + optionsButton.menu = menu + } + + private func setupCell() { + backgroundColor = .groupBg + selectionStyle = .none + + iconView.contentMode = .scaleAspectFit + + titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .medium) + titleLabel.textColor = .textColorPrimary + + distanceLabel.font = .scaledSystemFont(ofSize: 15) + distanceLabel.textColor = .textColorSecondary + distanceLabel.setContentHuggingPriority(.required, for: .horizontal) + + var configuration = UIButton.Configuration.plain() + configuration.image = UIImage(systemName: "ellipsis") + configuration.baseForegroundColor = .iconColorActive + configuration.background.backgroundColor = .groupBgColorSecondary + configuration.background.cornerRadius = Self.optionsButtonSize / 2 + optionsButton.configuration = configuration + optionsButton.showsMenuAsPrimaryAction = true + + [iconView, titleLabel, distanceLabel, optionsButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: Self.iconSize), + iconView.heightAnchor.constraint(equalToConstant: Self.iconSize), + + titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 16), + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + titleLabel.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 10), + + distanceLabel.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 8), + distanceLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + optionsButton.leadingAnchor.constraint(equalTo: distanceLabel.trailingAnchor, constant: 12), + optionsButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + optionsButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + optionsButton.widthAnchor.constraint(equalToConstant: Self.optionsButtonSize), + optionsButton.heightAnchor.constraint(equalToConstant: Self.optionsButtonSize) + ]) + } +} + +final class PlanRouteStartSegmentCell: UITableViewCell { + static let cellReuseId = "PlanRouteStartSegmentCell" + + private let titleLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupCell() { + backgroundColor = .groupBg + selectionStyle = .default + + titleLabel.text = localizedString("gpx_start_new_segment") + titleLabel.font = .scaledSystemFont(ofSize: 17) + titleLabel.textColor = .iconColorActive + titleLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(titleLabel) + + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 14), + titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -14) + ]) } } @@ -148,11 +516,10 @@ final class PlanRoutePointCell: UITableViewCell { deleteButton.tintColor = .systemRed deleteButton.addTarget(self, action: #selector(onDeleteTapped), for: .touchUpInside) + numberContainer.backgroundColor = .iconColorActive numberContainer.layer.cornerRadius = Self.circleSize / 2 - numberContainer.layer.borderWidth = 2 - numberContainer.layer.borderColor = UIColor.iconColorActive.cgColor numberLabel.font = .scaledSystemFont(ofSize: 13, weight: .semibold) - numberLabel.textColor = .iconColorActive + numberLabel.textColor = .white numberLabel.textAlignment = .center numberLabel.translatesAutoresizingMaskIntoConstraints = false numberContainer.addSubview(numberLabel) diff --git a/Sources/OsmAnd Maps-Bridging-Header.h b/Sources/OsmAnd Maps-Bridging-Header.h index 4e8a4f659d..d219150c0b 100644 --- a/Sources/OsmAnd Maps-Bridging-Header.h +++ b/Sources/OsmAnd Maps-Bridging-Header.h @@ -183,6 +183,7 @@ #import "OATrackSegmentsViewController.h" #import "OAOsmUploadGPXViewConroller.h" #import "OARoutePlanningHudViewController.h" +#import "OAPlanRouteEditingBridge.h" #import "OASaveTrackViewController.h" #import "OASelectTrackFolderViewController.h" #import "OARecordSettingsBottomSheetViewController.h" From e8cfeac0549b992f5e8887ff37e7967b56d9489d Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Thu, 18 Jun 2026 09:23:30 +0200 Subject: [PATCH 19/47] [WIP] Route fixes --- .../Controllers/PlanRoute/OAPlanRouteEditingBridge.h | 1 + .../Controllers/PlanRoute/OAPlanRouteEditingBridge.mm | 11 +++++++++++ .../PlanRouteEditingContextDataProvider.swift | 4 ++++ Sources/Controllers/PlanRoute/PlanRouteModels.swift | 1 + .../PlanRoute/PlanRouteScrollableViewController.swift | 3 ++- .../PlanRoute/PlanRouteStubDataProvider.swift | 2 ++ 6 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h index 8411aa5a78..4073054953 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -56,6 +56,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)prepareNewRoute; - (void)openTrackWithFilePath:(NSString *)filePath; +- (void)addCenterPoint; - (NSArray *)buildSegments; - (NSArray *)availableModes; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm index a5ca57568a..88195a3340 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -19,6 +19,7 @@ #import "OAMapUtils.h" #import "OAGpxData.h" #import "OsmAndSharedWrapper.h" +#import "OAAddPointCommand.h" #import "OARemovePointCommand.h" #import "OAReorderPointCommand.h" #import "OAChangeRouteModeCommand.h" @@ -202,6 +203,16 @@ - (double)routeDistance return [OAApplicationMode values]; } +- (void)addCenterPoint +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + [ctx.commandManager execute:[[OAAddPointCommand alloc] initWithLayer:layer center:YES]]; + [layer updateLayer]; +} + - (void)prepareNewRoute { OAMeasurementToolLayer *layer = [self layer]; diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index 9216b62a07..bbb3a17891 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -67,6 +67,10 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { bridge.availableModes() } + func addRoutePoint() { + bridge.addCenterPoint() + } + func deleteRoutePoint(at index: Int) { bridge.deletePoint(at: index) } diff --git a/Sources/Controllers/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/PlanRoute/PlanRouteModels.swift index 3a70418135..cdcfdcc3ba 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteModels.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteModels.swift @@ -179,6 +179,7 @@ protocol PlanRoutePointsDataSource: AnyObject { var canStartNewSegment: Bool { get } var availableModes: [OAApplicationMode] { get } + func addRoutePoint() func deleteRoutePoint(at index: Int) func deleteSegment(pointIndexes: [Int]) func startNewSegment() diff --git a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift index b7f55d0251..f05d172c93 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift @@ -407,7 +407,8 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController } private func handleAddRoutePoint() { - print("[PlanRoute] Add route point tapped") + dataProvider.addRoutePoint() + reloadData() } private func handleMenuAction(_ action: PlanRouteMenuAction) { diff --git a/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift index 2620d8914e..4dc746148b 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift @@ -78,6 +78,8 @@ final class PlanRouteStubDataProvider: PlanRouteDataProvider { OAApplicationMode.values() } + func addRoutePoint() {} + func deleteRoutePoint(at index: Int) {} func deleteSegment(pointIndexes: [Int]) {} From 82d63f4692d6f7dd07a09d14bf42cf27fac1b6a0 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Thu, 18 Jun 2026 12:20:52 +0300 Subject: [PATCH 20/47] upd check_access_order.swift. Weak private instance property should be declared before lazy, computed, or observed instance properties --- Scripts/check_access_order.swift | 111 +++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 5 deletions(-) diff --git a/Scripts/check_access_order.swift b/Scripts/check_access_order.swift index bf4cb0fc23..4807a99fda 100644 --- a/Scripts/check_access_order.swift +++ b/Scripts/check_access_order.swift @@ -82,6 +82,8 @@ private struct TypeContext { let bodyDepth: Int var lastAccessGroup: [MemberBucket: AccessGroup] = [:] var sawWeakProperty: [AccessGroup: Set] = [:] + var sawAccessorProperty: [AccessGroup: Set] = [:] + var sawPostWeakProperty: [AccessGroup: Set] = [:] mutating func markAccessGroup(_ accessGroup: AccessGroup, bucket: MemberBucket) { let current = lastAccessGroup[bucket] @@ -94,6 +96,14 @@ private struct TypeContext { sawWeakProperty[accessGroup, default: []].insert(bucket) } + mutating func markAccessorProperty(_ bucket: MemberBucket, accessGroup: AccessGroup) { + sawAccessorProperty[accessGroup, default: []].insert(bucket) + } + + mutating func markPostWeakProperty(_ bucket: MemberBucket, accessGroup: AccessGroup) { + sawPostWeakProperty[accessGroup, default: []].insert(bucket) + } + func hasSeenMoreRestrictiveAccessGroup( than accessGroup: AccessGroup, bucket: MemberBucket @@ -107,12 +117,23 @@ private struct TypeContext { func hasSeenWeakProperty(_ bucket: MemberBucket, accessGroup: AccessGroup) -> Bool { sawWeakProperty[accessGroup]?.contains(bucket) == true } + + func hasSeenAccessorProperty(_ bucket: MemberBucket, accessGroup: AccessGroup) -> Bool { + sawAccessorProperty[accessGroup]?.contains(bucket) == true + } + + func hasSeenPostWeakProperty(_ bucket: MemberBucket, accessGroup: AccessGroup) -> Bool { + sawPostWeakProperty[accessGroup]?.contains(bucket) == true + } } private struct MemberDeclaration { let bucket: MemberBucket let accessGroup: AccessGroup let isWeakProperty: Bool + let isLazyProperty: Bool + let isAccessorProperty: Bool + let canFollowWeakProperty: Bool } private func accessGroup(from tokens: [String]) -> AccessGroup { @@ -263,7 +284,44 @@ private func tokens(before declarationKeyword: String, in code: String) -> [Stri return prefix.split { !$0.isLetter && !$0.isNumber && $0 != "_" }.map(String.init) } -private func memberDeclaration(from code: String, pendingAttributes: [String]) -> MemberDeclaration? { +private func propertyCanFollowWeakProperty( + declarationCode: String, + lineOffset: Int, + lines: [String] +) -> Bool { + guard let openingBrace = declarationCode.firstIndex(of: "{") else { + return false + } + + let declarationPrefix = declarationCode[.. MemberDeclaration? { let combinedAttributes = attributeNames(in: (pendingAttributes + [code]).joined(separator: "\n")) if !combinedAttributes.isDisjoint(with: ignoredAttributeNames) { return nil @@ -272,11 +330,15 @@ private func memberDeclaration(from code: String, pendingAttributes: [String]) - if let propertyTokens = tokens(before: "var|let", in: code) { let isTypeMember = propertyTokens.contains("static") || propertyTokens.contains("class") let accessGroup = accessGroup(from: propertyTokens) + let isLazyProperty = propertyTokens.contains("lazy") return MemberDeclaration( bucket: isTypeMember ? .typeProperty : .instanceProperty, accessGroup: accessGroup, - isWeakProperty: propertyTokens.contains("weak") + isWeakProperty: propertyTokens.contains("weak"), + isLazyProperty: isLazyProperty, + isAccessorProperty: propertyCanFollowWeak, + canFollowWeakProperty: propertyCanFollowWeak || isLazyProperty ) } @@ -291,7 +353,10 @@ private func memberDeclaration(from code: String, pendingAttributes: [String]) - return MemberDeclaration( bucket: isTypeMember ? .typeMethod : .instanceMethod, accessGroup: accessGroup, - isWeakProperty: false + isWeakProperty: false, + isLazyProperty: false, + isAccessorProperty: false, + canFollowWeakProperty: false ) } @@ -327,20 +392,56 @@ private func check(filePath: String) -> Int { pendingAttributes.append(trimmedCode) } else if !trimmedCode.isEmpty, !trimmedCode.hasPrefix("#") { if contexts.last?.bodyDepth == braceDepth, - let member = memberDeclaration(from: trimmedCode, pendingAttributes: pendingAttributes) { + let member = memberDeclaration( + from: trimmedCode, + pendingAttributes: pendingAttributes, + propertyCanFollowWeak: propertyCanFollowWeakProperty( + declarationCode: trimmedCode, + lineOffset: offset, + lines: lines + ) + ) { if member.bucket == .instanceProperty || member.bucket == .typeProperty { if member.isWeakProperty { + if contexts[contexts.count - 1].hasSeenPostWeakProperty( + member.bucket, + accessGroup: member.accessGroup + ) { + print("\(filePath):\(lineNumber):1: warning: Weak \(member.accessGroup.displayName) \(member.bucket.displayName) should be declared before lazy, computed, or observed \(member.bucket.pluralDisplayName) (property_access_order)") + violationCount += 1 + } contexts[contexts.count - 1].markWeakProperty( member.bucket, accessGroup: member.accessGroup ) - } else if contexts[contexts.count - 1].hasSeenWeakProperty( + } else if member.isLazyProperty, + contexts[contexts.count - 1].hasSeenAccessorProperty( + member.bucket, + accessGroup: member.accessGroup + ) { + print("\(filePath):\(lineNumber):1: warning: Lazy \(member.accessGroup.displayName) \(member.bucket.displayName) should be declared before computed or observed \(member.bucket.pluralDisplayName) (property_access_order)") + violationCount += 1 + } else if !member.canFollowWeakProperty, + contexts[contexts.count - 1].hasSeenWeakProperty( member.bucket, accessGroup: member.accessGroup ) { print("\(filePath):\(lineNumber):1: warning: Non-weak \(member.accessGroup.displayName) \(member.bucket.displayName) should be declared before weak \(member.bucket.pluralDisplayName) (property_access_order)") violationCount += 1 } + + if member.isAccessorProperty { + contexts[contexts.count - 1].markAccessorProperty( + member.bucket, + accessGroup: member.accessGroup + ) + } + if member.canFollowWeakProperty, !member.isWeakProperty { + contexts[contexts.count - 1].markPostWeakProperty( + member.bucket, + accessGroup: member.accessGroup + ) + } } if let moreRestrictiveAccessGroup = contexts[contexts.count - 1] From f4445e3953b3cf5b2b35b1cca68ca0d008772b90 Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Thu, 18 Jun 2026 16:11:11 +0200 Subject: [PATCH 21/47] [WIP] Route fixes --- .../en.lproj/Localizable.strings | 6 +- .../PlanRoute/OAPlanRouteEditingBridge.h | 5 + .../PlanRoute/OAPlanRouteEditingBridge.mm | 65 +++++++- .../PlanRouteEditingContextDataProvider.swift | 15 ++ .../PlanRoute/PlanRouteModels.swift | 4 + .../PlanRouteScrollableViewController.swift | 18 +- .../PlanRoute/PlanRouteStubDataProvider.swift | 8 + .../Tabs/PlanRouteRouteViewController.swift | 154 ++++++++++++------ 8 files changed, 218 insertions(+), 57 deletions(-) diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 0c21393dfa..4a97295102 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -1626,9 +1626,11 @@ "plan_route_no_points_descr" = "Tap anywhere on the map or use search to add the first points of your route."; "shared_string_sort" = "Sort"; "shared_string_manual" = "Manual"; -"change_mode" = "Change mode"; -"set_single_mode" = "Set single mode"; +"shared_string_point" = "Point"; +"change_mode" = "Change route type"; +"set_single_mode" = "Set single route type"; "delete_segment" = "Delete segment"; +"delete_section" = "Delete section"; "coord_input_add_point" = "Add point"; "point_num" = "Point %d"; "points_count" = "Points:"; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h index 4073054953..e028288745 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -45,6 +45,8 @@ NS_ASSUME_NONNULL_BEGIN @interface OAPlanRouteEditingBridge : NSObject +@property (nonatomic, copy, nullable) void (^onChange)(void); + @property (nonatomic, readonly) BOOL hasContext; @property (nonatomic, readonly) BOOL hasPoints; @property (nonatomic, readonly) BOOL isAddNewSegmentAllowed; @@ -57,11 +59,14 @@ NS_ASSUME_NONNULL_BEGIN - (void)prepareNewRoute; - (void)openTrackWithFilePath:(NSString *)filePath; - (void)addCenterPoint; +- (void)undo; +- (void)redo; - (NSArray *)buildSegments; - (NSArray *)availableModes; - (void)deletePointAtIndex:(NSInteger)index; +- (void)movePointFrom:(NSInteger)from to:(NSInteger)to; - (void)deleteSegmentWithPointIndexes:(NSArray *)indexes; - (void)startNewSegment; - (void)applyMode:(OAApplicationMode *)mode pointIndex:(NSInteger)pointIndex wholeRoute:(BOOL)wholeRoute; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm index 88195a3340..60a18ee5d2 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -7,6 +7,7 @@ // #import "OAPlanRouteEditingBridge.h" +#import #import "Localization.h" #import "OARootViewController.h" #import "OAMapPanelViewController.h" @@ -20,6 +21,7 @@ #import "OAGpxData.h" #import "OsmAndSharedWrapper.h" #import "OAAddPointCommand.h" +#import "OASplitPointsCommand.h" #import "OARemovePointCommand.h" #import "OAReorderPointCommand.h" #import "OAChangeRouteModeCommand.h" @@ -57,7 +59,7 @@ - (instancetype)initWithIndex:(NSInteger)index @end -@interface OAPlanRouteEditingBridge () +@interface OAPlanRouteEditingBridge () - (OAMeasurementToolLayer *)layer; - (OAMeasurementEditingContext *)editingContext; @@ -220,6 +222,7 @@ - (void)prepareNewRoute return; OAMeasurementEditingContext *ctx = [[OAMeasurementEditingContext alloc] init]; layer.editingCtx = ctx; + layer.delegate = self; [ctx.commandManager setMeasurementLayer:layer]; [layer updateLayer]; } @@ -250,6 +253,7 @@ - (void)openTrackWithFilePath:(NSString *)filePath } layer.editingCtx = ctx; + layer.delegate = self; [ctx.commandManager setMeasurementLayer:layer]; [ctx addPoints]; [layer updateLayer]; @@ -378,7 +382,7 @@ - (OAPlanRouteGroupData *)buildGroupWithKey:(NSString *)key groupDistance += legDistance; } } - NSString *name = point.name.length > 0 ? point.name : [NSString stringWithFormat:@"%@ %ld", OALocalizedString(@"shared_string_waypoint"), (long) (index + 1)]; + NSString *name = point.name.length > 0 ? point.name : [NSString stringWithFormat:@"%@ - %ld", OALocalizedString(@"shared_string_point"), (long) (index + 1)]; [points addObject:[[OAPlanRoutePointData alloc] initWithGlobalIndex:index name:name distanceFromPrevious:legDistance @@ -400,6 +404,16 @@ - (void)deletePointAtIndex:(NSInteger)index [layer updateLayer]; } +- (void)movePointFrom:(NSInteger)from to:(NSInteger)to +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil || from == to) + return; + [ctx.commandManager execute:[[OAReorderPointCommand alloc] initWithLayer:layer from:from to:to]]; + [layer updateLayer]; +} + - (void)deleteSegmentWithPointIndexes:(NSArray *)indexes { OAMeasurementToolLayer *layer = [self layer]; @@ -418,9 +432,31 @@ - (void)startNewSegment { OAMeasurementToolLayer *layer = [self layer]; OAMeasurementEditingContext *ctx = [self editingContext]; - if (ctx == nil) + if (ctx == nil || ctx.getPointsCount == 0) + return; + ctx.selectedPointPosition = ctx.getPointsCount - 1; + [ctx.commandManager execute:[[OASplitPointsCommand alloc] initWithLayer:layer after:YES]]; + ctx.selectedPointPosition = -1; + [layer updateLayer]; +} + +- (void)undo +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil || ![ctx.commandManager canUndo]) return; - [ctx splitPoints:ctx.getBeforePoints.count after:YES]; + [ctx.commandManager undo]; + [layer updateLayer]; +} + +- (void)redo +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil || ![ctx.commandManager canRedo]) + return; + [ctx.commandManager redo]; [layer updateLayer]; } @@ -485,4 +521,25 @@ - (void)sortSegmentDoorToDoorWithPointIndexes:(NSArray *)indexes [layer updateLayer]; } +#pragma mark - OAMeasurementLayerDelegate + +- (void)onMeasure:(double)distance bearing:(double)bearing +{ +} + +- (void)onTouch:(CLLocationCoordinate2D)coordinate longPress:(BOOL)longPress +{ + if (longPress) + return; + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + layer.pressPointLocation = [[CLLocation alloc] initWithLatitude:coordinate.latitude longitude:coordinate.longitude]; + [ctx.commandManager execute:[[OAAddPointCommand alloc] initWithLayer:layer center:NO]]; + [layer updateLayer]; + if (self.onChange) + self.onChange(); +} + @end diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index bbb3a17891..48b28f33ed 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -11,10 +11,13 @@ import UIKit final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { let mode: PlanRouteMode + var onDataChanged: (() -> Void)? + private let bridge = OAPlanRouteEditingBridge() init(mode: PlanRouteMode = .newRoute, filePath: String? = nil) { self.mode = mode + bridge.onChange = { [weak self] in self?.onDataChanged?() } if mode.isEditTrack, let filePath { bridge.openTrack(withFilePath: filePath) } else { @@ -71,6 +74,18 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { bridge.addCenterPoint() } + func undo() { + bridge.undo() + } + + func redo() { + bridge.redo() + } + + func moveRoutePoint(from: Int, to: Int) { + bridge.movePoint(from: from, to: to) + } + func deleteRoutePoint(at index: Int) { bridge.deletePoint(at: index) } diff --git a/Sources/Controllers/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/PlanRoute/PlanRouteModels.swift index cdcfdcc3ba..b0d340a870 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteModels.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteModels.swift @@ -180,6 +180,9 @@ protocol PlanRoutePointsDataSource: AnyObject { var availableModes: [OAApplicationMode] { get } func addRoutePoint() + func undo() + func redo() + func moveRoutePoint(from: Int, to: Int) func deleteRoutePoint(at index: Int) func deleteSegment(pointIndexes: [Int]) func startNewSegment() @@ -194,6 +197,7 @@ protocol PlanRouteDataProvider: PlanRoutePoiDataSource, PlanRouteAnalyzeDataSour var hasChanges: Bool { get } var canUndo: Bool { get } var canRedo: Bool { get } + var onDataChanged: (() -> Void)? { get set } } protocol PlanRouteTabContent: AnyObject { diff --git a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift index f05d172c93..cb6ea424ab 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift @@ -73,6 +73,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController setupBottomToolbar() setupContent() setupTopToolbar() + dataProvider.onDataChanged = { [weak self] in self?.reloadData() } selectTab(.default) reloadData() } @@ -150,6 +151,8 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController func reloadData() { topPartView.configure(with: dataProvider.routeInfo) + bottomToolbar.isUndoEnabled = dataProvider.canUndo + bottomToolbar.isRedoEnabled = dataProvider.canRedo currentTabViewController.flatMap { $0 as? PlanRouteTabContent }?.reloadData() } @@ -181,6 +184,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController ]) let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + panRecognizer.delegate = self sheetView.addGestureRecognizer(panRecognizer) } @@ -399,11 +403,13 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController } private func handleUndo() { - print("[PlanRoute] Undo tapped") + dataProvider.undo() + reloadData() } private func handleRedo() { - print("[PlanRoute] Redo tapped") + dataProvider.redo() + reloadData() } private func handleAddRoutePoint() { @@ -444,3 +450,11 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController } } } + +// MARK: - UIGestureRecognizerDelegate +extension PlanRouteScrollableViewController: UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + let location = gestureRecognizer.location(in: tabContainerView) + return !tabContainerView.bounds.contains(location) + } +} diff --git a/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift index 4dc746148b..45794d17d6 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift @@ -11,6 +11,8 @@ import UIKit final class PlanRouteStubDataProvider: PlanRouteDataProvider { let mode: PlanRouteMode + var onDataChanged: (() -> Void)? + init(mode: PlanRouteMode = .newRoute) { self.mode = mode } @@ -80,6 +82,12 @@ final class PlanRouteStubDataProvider: PlanRouteDataProvider { func addRoutePoint() {} + func undo() {} + + func redo() {} + + func moveRoutePoint(from: Int, to: Int) {} + func deleteRoutePoint(at index: Int) {} func deleteSegment(pointIndexes: [Int]) {} diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift index 32896c1982..6ddabf02da 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift @@ -13,7 +13,7 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent private enum Row { case profileGroup(PlanRouteProfileGroup, segment: PlanRouteSegment) - case point(PlanRoutePoint) + case point(PlanRoutePoint, color: UIColor) case empty } @@ -56,9 +56,11 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent private func setupTableView() { view.backgroundColor = .clear - tableView.backgroundColor = .clear + tableView.backgroundColor = .viewBg tableView.dataSource = self tableView.delegate = self + tableView.isEditing = true + tableView.allowsSelectionDuringEditing = true tableView.separatorInset = UIEdgeInsets(top: 0, left: Self.separatorLeftInset, bottom: 0, right: 0) tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: Self.bottomContentInset, right: 0) tableView.sectionHeaderTopPadding = 0 @@ -82,7 +84,7 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent guard !segments.isEmpty else { return [SectionModel(headerTitle: localizedString("route_points"), headerSubtitle: nil, - headerMenu: nil, + headerMenu: makeRouteTypeMenu(pointIndex: 0), rows: [.empty], isStartNewSegment: false)] } @@ -101,17 +103,12 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent private func makeSection(for segment: PlanRouteSegment, multipleSegments: Bool) -> SectionModel { var rows: [Row] = [] - if segment.multiMode { - for group in segment.groups { - if group.appMode != nil { - rows.append(.profileGroup(group, segment: segment)) - } - rows.append(contentsOf: group.points.map { Row.point($0) }) - } - } else { - for group in segment.groups { - rows.append(contentsOf: group.points.map { Row.point($0) }) + for group in segment.groups { + let color = group.appMode?.getProfileColor() ?? .iconColorActive + if segment.multiMode, group.appMode != nil { + rows.append(.profileGroup(group, segment: segment)) } + rows.append(contentsOf: group.points.map { Row.point($0, color: color) }) } let title: String @@ -123,7 +120,8 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent var subtitle: String? if segment.routed, !segment.multiMode, let mode = segment.singleMode { - subtitle = "\(mode.toHumanString()) • \(formattedDistance(segment.distance))" + let modeName = mode.toHumanString() ?? "" + subtitle = "\(modeName) • \(formattedDistance(segment.distance))" } return SectionModel(headerTitle: title, @@ -162,33 +160,38 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent private func makeGroupMenu(for group: PlanRouteProfileGroup, in segment: PlanRouteSegment) -> UIMenu { let groupIndexes = group.points.map { $0.index } - let changeMode = UIAction(title: localizedString("change_mode"), - subtitle: group.appMode?.toHumanString(), - image: group.appMode?.getIcon()) { [weak self] _ in + let changeRouteType = UIAction(title: localizedString("change_mode"), + subtitle: group.appMode?.toHumanString(), + image: group.appMode?.getIcon()) { [weak self] _ in self?.presentModePicker(pointIndex: group.lastPointIndex, wholeRoute: false) } - let saveAs = UIAction(title: localizedString("plan_route_save_as"), - image: .templateImageNamed("ic_custom_save_to_file")) { [weak self] _ in - self?.dataSource?.saveSegment(pointIndexes: groupIndexes) - } - let deleteSegment = UIAction(title: localizedString("delete_segment"), + let deleteSection = UIAction(title: localizedString("delete_section"), image: .templateImageNamed("ic_custom_trash_outlined"), attributes: .destructive) { [weak self] _ in self?.deleteSegment(pointIndexes: groupIndexes) } - return UIMenu(children: [changeMode, makeSortMenu(pointIndexes: segment.pointIndexes), saveAs, deleteSegment]) + return UIMenu(children: [changeRouteType, makeSortMenu(pointIndexes: groupIndexes), deleteSection]) + } + + private func makeRouteTypeMenu(pointIndex: Int) -> UIMenu { + let changeRouteType = UIAction(title: localizedString("change_mode"), + image: .templateImageNamed("ic_custom_point_to_point")) { [weak self] _ in + self?.presentModePicker(pointIndex: pointIndex, wholeRoute: true) + } + return UIMenu(children: [changeRouteType]) } private func makeSortMenu(pointIndexes: [Int]) -> UIMenu { + let sortImage = UIImage(systemName: "arrow.up.arrow.down") let manual = UIAction(title: localizedString("shared_string_manual"), - image: .templateImageNamed("ic_custom_direction_manual"), + image: sortImage, state: .on) { _ in } let doorToDoor = UIAction(title: localizedString("intermediate_items_sort_by_distance"), image: .templateImageNamed("ic_custom_sort_door_to_door")) { [weak self] _ in self?.dataSource?.sortDoorToDoor(pointIndexes: pointIndexes) self?.reloadData() } - return UIMenu(title: localizedString("shared_string_sort"), children: [manual, doorToDoor]) + return UIMenu(title: localizedString("shared_string_sort"), image: sortImage, children: [manual, doorToDoor]) } private func presentModePicker(pointIndex: Int, wholeRoute: Bool) { @@ -226,7 +229,7 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent } private func formattedDistance(_ meters: Double) -> String { - OAOsmAndFormatter.getFormattedDistance(Float(meters)) + OAOsmAndFormatter.getFormattedDistance(Float(meters)) ?? "" } } @@ -266,11 +269,11 @@ extension PlanRouteRouteViewController: UITableViewDataSource { tintColor: mode?.getProfileColor() ?? .iconColorActive, menu: makeGroupMenu(for: group, in: segment)) return cell - case let .point(point): + case let .point(point, color): guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRoutePointCell.cellReuseId, for: indexPath) as? PlanRoutePointCell else { return UITableViewCell() } - cell.configure(with: point) + cell.configure(with: point, tintColor: color) cell.onDelete = { [weak self] in self?.deletePoint(at: point.index) } @@ -297,10 +300,66 @@ extension PlanRouteRouteViewController: UITableViewDelegate { startNewSegment() return } - if case let .point(point) = section.rows[indexPath.row] { + if case let .point(point, _) = section.rows[indexPath.row] { dataSource?.selectRoutePoint(at: point.index) } } + + func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + .none + } + + func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { + false + } + + func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { + guard !sections[indexPath.section].isStartNewSegment else { return false } + if case .point = sections[indexPath.section].rows[indexPath.row] { + return true + } + return false + } + + func tableView(_ tableView: UITableView, + targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, + toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath { + let rows = sections[sourceIndexPath.section].rows + let pointRows = rows.indices.filter { if case .point = rows[$0] { return true } else { return false } } + guard let first = pointRows.first, let last = pointRows.last else { return sourceIndexPath } + if proposedDestinationIndexPath.section != sourceIndexPath.section { + let row = proposedDestinationIndexPath.section < sourceIndexPath.section ? first : last + return IndexPath(row: row, section: sourceIndexPath.section) + } + let clampedRow = min(max(proposedDestinationIndexPath.row, first), last) + return IndexPath(row: clampedRow, section: sourceIndexPath.section) + } + + func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { + let rows = sections[sourceIndexPath.section].rows + let pointGlobals: [Int] = rows.compactMap { row in + if case let .point(point, _) = row { return point.index } + return nil + } + let fromPosition = pointPosition(in: rows, beforeRow: sourceIndexPath.row) + let toPosition = pointPosition(in: rows, beforeRow: destinationIndexPath.row) + guard pointGlobals.indices.contains(fromPosition), pointGlobals.indices.contains(toPosition) else { + reloadData() + return + } + dataSource?.moveRoutePoint(from: pointGlobals[fromPosition], to: pointGlobals[toPosition]) + reloadData() + } + + private func pointPosition(in rows: [Row], beforeRow row: Int) -> Int { + var count = 0 + for index in 0.. String { if point.isStart { - return localizedString("starting_point") + return localizedString("start_point") } - let distance = OAOsmAndFormatter.getFormattedDistance(Float(point.distanceFromPrevious)) + let distance = OAOsmAndFormatter.getFormattedDistance(Float(point.distanceFromPrevious)) ?? "" if point.isDestination { return "\(distance) • \(localizedString("route_descr_destination"))" } From ca1f7d9a69ddd7cefad06e9358e124d7b5c915a5 Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Thu, 18 Jun 2026 16:34:54 +0200 Subject: [PATCH 22/47] [WIP] UI fixes --- .../PlanRoute/Tabs/PlanRouteRouteViewController.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift index 6ddabf02da..db1398c2ff 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift @@ -61,6 +61,7 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent tableView.delegate = self tableView.isEditing = true tableView.allowsSelectionDuringEditing = true + tableView.alwaysBounceVertical = true tableView.separatorInset = UIEdgeInsets(top: 0, left: Self.separatorLeftInset, bottom: 0, right: 0) tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: Self.bottomContentInset, right: 0) tableView.sectionHeaderTopPadding = 0 @@ -564,8 +565,7 @@ final class PlanRoutePointCell: UITableViewCell { func configure(with point: PlanRoutePoint, tintColor: UIColor) { numberLabel.text = "\(point.index + 1)" - numberLabel.textColor = tintColor - numberContainer.layer.borderColor = tintColor.cgColor + numberContainer.backgroundColor = tintColor titleLabel.text = point.name subtitleLabel.text = subtitle(for: point) } @@ -579,12 +579,11 @@ final class PlanRoutePointCell: UITableViewCell { deleteButton.tintColor = .systemRed deleteButton.addTarget(self, action: #selector(onDeleteTapped), for: .touchUpInside) - numberContainer.backgroundColor = .clear numberContainer.layer.cornerRadius = Self.circleSize / 2 - numberContainer.layer.borderWidth = 1.5 - numberContainer.layer.borderColor = UIColor.iconColorActive.cgColor + numberContainer.layer.borderWidth = 2 + numberContainer.layer.borderColor = UIColor.white.cgColor numberLabel.font = .scaledSystemFont(ofSize: 13, weight: .semibold) - numberLabel.textColor = .iconColorActive + numberLabel.textColor = .white numberLabel.textAlignment = .center numberLabel.translatesAutoresizingMaskIntoConstraints = false numberContainer.addSubview(numberLabel) From 946b10fc5738c7bc3c9634f4034f269bd22bc687 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 18 Jun 2026 18:05:51 +0300 Subject: [PATCH 23/47] Force common words comparator (#5471) * Force common words comparator * Force common words comparator * Fix after review * Improve --- Sources/Search/OASearchCoreFactory.mm | 3 +- Sources/Search/OASearchPhrase.mm | 73 ++++++++++++++++++--------- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/Sources/Search/OASearchCoreFactory.mm b/Sources/Search/OASearchCoreFactory.mm index 20bb90dd62..0c4efbe6c3 100644 --- a/Sources/Search/OASearchCoreFactory.mm +++ b/Sources/Search/OASearchCoreFactory.mm @@ -2209,8 +2209,7 @@ - (BOOL) search:(OASearchPhrase *)phrase resultMatcher:(OASearchResultMatcher *) [resultMatcher publish:res]; } } - QString streetIntersection = QString::fromNSString([phrase getUnknownWordToSearch]); - OANameStringMatcher *streetMatch = [phrase getMainUnknownNameStringMatcher]; + QString streetIntersection = s->intersectedStreets.size() > 0 ? QString::fromNSString([phrase getUnknownWordToSearch]) : QString(); if (streetIntersection.isEmpty() || (!streetIntersection[0].isDigit() && OsmAnd::CommonWords::getCommonSearch(streetIntersection) == -1)) { for (const auto& street : s->intersectedStreets) diff --git a/Sources/Search/OASearchPhrase.mm b/Sources/Search/OASearchPhrase.mm index 852c7ce20d..c3b24c75b9 100644 --- a/Sources/Search/OASearchPhrase.mm +++ b/Sources/Search/OASearchPhrase.mm @@ -43,6 +43,8 @@ static NSArray *CHARS_TO_NORMALIZE_KEY = @[@"’", @"ʼ", @"(", @")", @"´", @"`", @"′", @"‵", @"ʹ"]; // remove () subcities static NSArray *CHARS_TO_NORMALIZE_VALUE = @[@"'", @"'", @" ", @" ", @"'", @"'", @"'", @"'", @"'"]; +static NSCache *sCommonWordWeightCache = nil; + @interface OASearchPhrase () @property (nonatomic) OACollatorStringMatcher *clt; @@ -79,8 +81,6 @@ @interface OASearchPhrase () @end -static NSComparator _OACommonWordsComparator = nil; - @implementation OASearchPhrase { NSMutableArray *_indexes; @@ -113,30 +113,55 @@ + (void) initialize @"и", // Don't add short names ! issues for perfect matching "Drive A", ... nil]; - _OACommonWordsComparator = ^NSComparisonResult(NSString * _Nonnull o1, NSString * _Nonnull o2) - { - int i1 = OsmAnd::CommonWords::getCommonSearch(QString::fromNSString([o1 lowercaseString])); - int i2 = OsmAnd::CommonWords::getCommonSearch(QString::fromNSString([o2 lowercaseString])); - - if (i1 != i2) - { - if (i1 == -1) - return NSOrderedAscending; - else if (i2 == -1) - return NSOrderedDescending; - - return [OAUtilities compareInt:i2 y:i1]; - } - - // compare length without numbers to not include house numbers - return [OAUtilities compareInt:[OASearchPhrase lengthWithoutNumbers:o2] y:[OASearchPhrase lengthWithoutNumbers:o1]]; - }; + sCommonWordWeightCache = [NSCache new]; + sCommonWordWeightCache.countLimit = 100; } } -- (NSComparator) commonWordsComparator +- (void) sortCommonWords:(NSMutableArray *)searchWords { - return _OACommonWordsComparator; + if (searchWords.count <= 1) + { + return; + } + NSMutableDictionary *weights = [NSMutableDictionary dictionaryWithCapacity:searchWords.count]; + for (NSString *w in searchWords) + { + if (w.length == 0) + { + continue; + } + NSString *key = [w lowercaseString]; + if (weights[w] == nil) + { + NSNumber *cached = [sCommonWordWeightCache objectForKey:key]; + if (cached) + { + weights[w] = cached; + } + else + { + int value = OsmAnd::CommonWords::getCommonSearch(QString::fromNSString(key)); + NSNumber *num = @(value); + weights[w] = num; + [sCommonWordWeightCache setObject:num forKey:key]; + } + } + } + [searchWords sortUsingComparator:^NSComparisonResult(NSString * _Nonnull o1, NSString * _Nonnull o2) + { + int i1 = (int)weights[o1].integerValue; + int i2 = (int)weights[o2].integerValue; + if (i1 != i2) + { + if (i1 == -1) + return NSOrderedAscending; + else if (i2 == -1) + return NSOrderedDescending; + return [OAUtilities compareInt:i2 y:i1]; + } + return [OAUtilities compareInt:[OASearchPhrase lengthWithoutNumbers:o2] y:[OASearchPhrase lengthWithoutNumbers:o1]]; + }]; } + (OASearchPhrase *) emptyPhrase @@ -362,7 +387,7 @@ - (void) calcMainUnknownWordToSearch _mainUnknownSearchWordComplete = YES; NSMutableArray *searchWords = [NSMutableArray arrayWithArray:unknownSearchWords]; [searchWords insertObject:_firstUnknownSearchWord atIndex:0]; - [searchWords sortUsingComparator:self.commonWordsComparator]; + [self sortCommonWords:searchWords]; for (NSString *s in searchWords) { if (s.length > 0) @@ -1144,7 +1169,7 @@ + (NSString *) ALLDELIMITERS - (NSString *) selectMainUnknownWordToSearch:(NSMutableArray *)searchWords { - [searchWords sortUsingComparator:self.commonWordsComparator]; + [self sortCommonWords:searchWords]; for (NSString *s in searchWords) { From 8f9fb014019fead77ba2cf6b5624dbce7e198f6c Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Fri, 19 Jun 2026 16:33:12 +0300 Subject: [PATCH 24/47] Fix crash on invalid GPX split interval --- Sources/Controllers/Map/Layers/OAGPXLayer.mm | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/Controllers/Map/Layers/OAGPXLayer.mm b/Sources/Controllers/Map/Layers/OAGPXLayer.mm index c7e7a710ba..917e946d64 100644 --- a/Sources/Controllers/Map/Layers/OAGPXLayer.mm +++ b/Sources/Controllers/Map/Layers/OAGPXLayer.mm @@ -1087,10 +1087,18 @@ - (void)configureVisualization3dPositionType:(EOAGPX3DLineVisualizationPositionT - (void)processSplitLabels:(OASGpxDataItem *)gpx doc:(OASGpxFile *)doc { + double splitInterval = gpx.splitInterval; + + if (splitInterval <= 0 || !isfinite(splitInterval)) + return; + + GPXDataItemGPXFileWrapper *dataWrapper = [[GPXDataItemGPXFileWrapper alloc] initWithGpxDataItem:gpx gpxFile:doc]; + BOOL joinSegments = gpx.joinSegments; + EOAGpxSplitType splitType = dataWrapper.splitType; + NSBlockOperation* operation = [[NSBlockOperation alloc] init]; __weak NSBlockOperation* weakOperation = operation; OAAtomicInteger *splitCounter = _splitCounter; - GPXDataItemGPXFileWrapper *dataWrapper = [[GPXDataItemGPXFileWrapper alloc] initWithGpxDataItem:gpx gpxFile:doc]; [operation addExecutionBlock:^{ if (splitCounter != _splitCounter || weakOperation.isCancelled) return; @@ -1098,14 +1106,14 @@ - (void)processSplitLabels:(OASGpxDataItem *)gpx doc:(OASGpxFile *)doc NSArray *splitData = nil; BOOL splitByTime = NO; BOOL splitByDistance = NO; - switch (dataWrapper.splitType) { + switch (splitType) { case EOAGpxSplitTypeDistance: { NSMutableArray *array = [NSMutableArray array]; for (OASTrack *subtrack in document.tracks) { for (OASTrkSegment *segment in subtrack.segments) { - [array addObjectsFromArray:[segment splitByDistanceMeters:gpx.splitInterval joinSegments:gpx.joinSegments pointsAnalyser:[OASPlatformUtil.shared getTrackPointsAnalyser]]]; + [array addObjectsFromArray:[segment splitByDistanceMeters:splitInterval joinSegments:joinSegments pointsAnalyser:[OASPlatformUtil.shared getTrackPointsAnalyser]]]; } } splitData = [array copy]; @@ -1118,7 +1126,7 @@ - (void)processSplitLabels:(OASGpxDataItem *)gpx doc:(OASGpxFile *)doc { for (OASTrkSegment *segment in subtrack.segments) { - [array addObjectsFromArray:[segment splitByTimeSeconds:gpx.splitInterval joinSegments:gpx.joinSegments pointsAnalyser:[OASPlatformUtil.shared getTrackPointsAnalyser]]]; + [array addObjectsFromArray:[segment splitByTimeSeconds:splitInterval joinSegments:joinSegments pointsAnalyser:[OASPlatformUtil.shared getTrackPointsAnalyser]]]; } } splitData = [array copy]; From 1550837f5cad7a6658c03ee424f210aad806bbdf Mon Sep 17 00:00:00 2001 From: alex-dev Date: Sat, 20 Jun 2026 10:44:56 +0300 Subject: [PATCH 25/47] Fix top places layer deadlock --- .../OAPOILayerTopPlacesProvider.m | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/Sources/Controllers/Map/Layers/OAPOILayerTopPlacesProvider/OAPOILayerTopPlacesProvider.m b/Sources/Controllers/Map/Layers/OAPOILayerTopPlacesProvider/OAPOILayerTopPlacesProvider.m index 6c71e95659..3f9b13d5a9 100644 --- a/Sources/Controllers/Map/Layers/OAPOILayerTopPlacesProvider/OAPOILayerTopPlacesProvider.m +++ b/Sources/Controllers/Map/Layers/OAPOILayerTopPlacesProvider/OAPOILayerTopPlacesProvider.m @@ -75,6 +75,9 @@ @implementation OAPOILayerTopPlacesProvider NSMutableDictionary *_topPlacesImages; QList> _allPlaces; QList> _displayedPlaces; + QList> _publishedTopPlaces; + QList> _publishedDisplayedPlaces; + NSLock *_placesSnapshotLock; NSSet *_topPlaceIds; NSSet *_loadingImagePlaceIds; OsmAnd::AreaI _topPlacesBox; @@ -114,6 +117,7 @@ - (void)configure _enabled = NO; _textScale = 1.f; _displayDensityFactor = _mapViewController.mapView.displayDensityFactor; + _placesSnapshotLock = [[NSLock alloc] init]; _backgroundQueue = dispatch_queue_create("com.osmand.topplaces.background", DISPATCH_QUEUE_SERIAL); dispatch_queue_set_specific(_backgroundQueue, kTopPlacesStateQueueKey, kTopPlacesStateQueueKey, NULL); } @@ -219,19 +223,19 @@ - (void)updateSelectedTopPlaceId:(NSNumber *)placeId - (QList>)topPlaces { - __block QList> topPlaces; - [self performStateSync:^{ - topPlaces = _topPlaces; - }]; + QList> topPlaces; + [_placesSnapshotLock lock]; + topPlaces = _publishedTopPlaces; + [_placesSnapshotLock unlock]; return topPlaces; } - (QList>)displayedAmenities { - __block QList> displayedPlaces; - [self performStateSync:^{ - displayedPlaces = _displayedPlaces; - }]; + QList> displayedPlaces; + [_placesSnapshotLock lock]; + displayedPlaces = _publishedDisplayedPlaces; + [_placesSnapshotLock unlock]; return displayedPlaces; } @@ -242,12 +246,13 @@ - (BOOL)isOnStateQueue return dispatch_get_specific(kTopPlacesStateQueueKey) == kTopPlacesStateQueueKey; } -- (void)performStateSync:(dispatch_block_t)block +- (void)publishTopPlacesSnapshot:(const QList> &)topPlaces + displayedPlacesSnapshot:(const QList> &)displayedPlaces { - if ([self isOnStateQueue]) - block(); - else - dispatch_sync(_backgroundQueue, block); + [_placesSnapshotLock lock]; + _publishedTopPlaces = topPlaces; + _publishedDisplayedPlaces = displayedPlaces; + [_placesSnapshotLock unlock]; } - (BOOL)captureVisibleBounds:(OsmAnd::AreaI *)visibleBBox31 @@ -430,6 +435,7 @@ - (void)resetTopPlacesState _selectedTopPlaceId = nil; _visiblePlacesRefreshScheduled = NO; _renderedMarkerStates = nil; + [self publishTopPlacesSnapshot:_topPlaces displayedPlacesSnapshot:_displayedPlaces]; } - (void)refreshVisiblePlacesOnStateQueue @@ -475,9 +481,8 @@ - (void)updateTopPlacesCollection [_mapViewController runWithRenderSync:^{ [self clearMapMarkersCollectionLocked]; }]; - [self performStateSync:^{ - _displayedPlaces.clear(); - }]; + _displayedPlaces.clear(); + [self publishTopPlacesSnapshot:_topPlaces displayedPlacesSnapshot:_displayedPlaces]; return; } @@ -581,9 +586,8 @@ - (void)updateTopPlacesCollection } }]; - [self performStateSync:^{ - _displayedPlaces = displayedPlaces; - }]; + _displayedPlaces = displayedPlaces; + [self publishTopPlacesSnapshot:_topPlaces displayedPlacesSnapshot:_displayedPlaces]; } - (int32_t)truncatedTopPlaceId:(const std::shared_ptr &)topPlace From 8c1c827f8bcfb465c29126f26196fc88f1b82c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Astrid=20H=C3=B8ie=20Silden?= Date: Fri, 19 Jun 2026 13:38:27 +0200 Subject: [PATCH 26/47] Translated using Weblate (Norwegian Nynorsk) Currently translated at 9.1% (367 of 4028 strings) --- Resources/Localizations/nn.lproj/Localizable.strings | 1 + 1 file changed, 1 insertion(+) diff --git a/Resources/Localizations/nn.lproj/Localizable.strings b/Resources/Localizations/nn.lproj/Localizable.strings index e5418ba902..ed4eab7ec4 100644 --- a/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Resources/Localizations/nn.lproj/Localizable.strings @@ -462,3 +462,4 @@ "transport_stop" = "Stopp"; "poi_filter_fuel" = "Drivstoff"; "map_widget_distance" = "Avstand"; +"day_short" = "D"; From cd980fdf5d76a94e7e373157ec76e7bfd65778b1 Mon Sep 17 00:00:00 2001 From: vitaliy-sova-ios <38758123+vitaliy-sova-ios@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:16:00 +0300 Subject: [PATCH 27/47] Cloud sync checking process (#5474) * Improve GPX handling and async export UI Files from the database, preserving sorting for the export screen Added selected types to the file list request Added infoModifiedTime to check md5 - OAGpxSettingsItem Asynchronous file retrieval in the export screen * Remove infoModifiedTime * Code Review fixes --- .../Backup/LocalBackup/OASettingsHelper.mm | 32 ++++++++++---- Sources/Backup/OABackupHelper.mm | 9 ++++ Sources/Backup/OACollectLocalFilesTask.m | 1 + .../OAExportItemsViewController.mm | 43 +++++++++++++++++-- Sources/Helpers/OAGPXUIHelper.h | 4 +- Sources/Helpers/OAGPXUIHelper.mm | 38 +++++----------- 6 files changed, 86 insertions(+), 41 deletions(-) diff --git a/Sources/Backup/LocalBackup/OASettingsHelper.mm b/Sources/Backup/LocalBackup/OASettingsHelper.mm index 7e11231c49..3f6acbdace 100644 --- a/Sources/Backup/LocalBackup/OASettingsHelper.mm +++ b/Sources/Backup/LocalBackup/OASettingsHelper.mm @@ -226,7 +226,7 @@ - (void) exportSettings:(NSString *)fileDir fileName:(NSString *)fileName settin { NSMutableDictionary *typesMap = [NSMutableDictionary new]; [typesMap addEntriesFromDictionary:[self getSettingsItems:addProfiles]]; - [typesMap addEntriesFromDictionary:[self getMyPlacesItems]]; + [typesMap addEntriesFromDictionary:[self getMyPlacesItems:NO]]; [typesMap addEntriesFromDictionary:[self getResourcesItems]]; return [self getFilteredSettingsItems:typesMap settingsTypes:settingsTypes settingsItems:@[] doExport:doExport]; @@ -254,7 +254,7 @@ - (void) exportSettings:(NSString *)fileDir fileName:(NSString *)fileName settin MutableOrderedDictionary *dataList = [MutableOrderedDictionary new]; NSDictionary *settingsItems = [self getSettingsItems:addProfiles]; - NSDictionary *myPlacesItems = [self getMyPlacesItems]; + NSDictionary *myPlacesItems = [self getMyPlacesItems:YES]; NSDictionary *resourcesItems = [self getResourcesItems]; if (settingsItems.count > 0) @@ -348,7 +348,7 @@ - (void) exportSettings:(NSString *)fileDir fileName:(NSString *)fileName settin return settingsItems; } -- (NSDictionary *)getMyPlacesItems +- (NSDictionary *)getMyPlacesItems:(BOOL)sorted { MutableOrderedDictionary *myPlacesItems = [MutableOrderedDictionary new]; @@ -357,18 +357,34 @@ - (void) exportSettings:(NSString *)fileDir fileName:(NSString *)fileName settin myPlacesItems[OAExportSettingsType.FAVORITES] = favoriteGroups; NSFileManager *fileManager = NSFileManager.defaultManager; - NSArray *gpxInfoList = [OAGPXUIHelper getSortedGPXFilesInfo:OsmAndApp.instance.gpxPath selectedGpxList:nil absolutePath:YES]; - if (gpxInfoList.count > 0) + NSArray *gpsDataItems; + if (sorted) + { + gpsDataItems = [OAGPXUIHelper sortedGPXDataItems]; + } + else + { + gpsDataItems = [OAGPXDatabase.sharedDb getDataItems]; + } + + if (gpsDataItems) { NSMutableArray *files = [NSMutableArray new]; - for (OAGpxFileInfo *gpxInfo in gpxInfoList) + for (OASGpxDataItem *item in gpsDataItems) { - if ([fileManager fileExistsAtPath:gpxInfo.fileName]) - [files addObject:gpxInfo.fileName]; + NSString *filePath = item.file.absolutePath; + if (filePath.length == 0) + continue; + BOOL isDir = NO; + if (![fileManager fileExistsAtPath:filePath isDirectory:&isDir] || isDir) + continue; + [files addObject:filePath]; } + if (files.count > 0) myPlacesItems[OAExportSettingsType.TRACKS] = files; } + OAOsmEditingPlugin *osmEditingPlugin = (OAOsmEditingPlugin *) [OAPluginsHelper getPlugin:OAOsmEditingPlugin.class]; if (osmEditingPlugin) { diff --git a/Sources/Backup/OABackupHelper.mm b/Sources/Backup/OABackupHelper.mm index f8e6adbc90..fda5afa956 100644 --- a/Sources/Backup/OABackupHelper.mm +++ b/Sources/Backup/OABackupHelper.mm @@ -360,11 +360,20 @@ - (void) collectLocalFiles:(id)listener - (void) downloadFileList:(void(^)(NSInteger status, NSString *message, NSArray *remoteFiles))onComplete { [self checkRegistered]; + + NSMutableArray *enabledTypes = [NSMutableArray array]; + for (OAExportSettingsType *exportType in [OAExportSettingsType getEnabledTypes]) + { + if ([[BackupUtils getBackupTypePref:exportType] get]) + [enabledTypes addObject:exportType.itemName]; + } NSMutableDictionary *params = [NSMutableDictionary dictionary]; params[@"deviceid"] = self.getDeviceId; params[@"accessToken"] = self.getAccessToken; params[@"allVersions"] = @"true"; + if (enabledTypes.count > 0) + params[@"type"] = [enabledTypes componentsJoinedByString:@","]; OAOperationLog *operationLog = [[OAOperationLog alloc] initWithOperationName:@"downloadFileList" debug:BACKUP_DEBUG_LOGS]; [operationLog startOperation]; [OANetworkUtilities sendRequestWithUrl:LIST_FILES_URL params:params post:NO async:NO onComplete:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { diff --git a/Sources/Backup/OACollectLocalFilesTask.m b/Sources/Backup/OACollectLocalFilesTask.m index 26cf25a7eb..2227a67bb8 100644 --- a/Sources/Backup/OACollectLocalFilesTask.m +++ b/Sources/Backup/OACollectLocalFilesTask.m @@ -169,6 +169,7 @@ - (void) createLocalFile:(NSMutableArray *)result item:(OASetting localFile.item = item; localFile.fileName = fileName; localFile.localModifiedTime = lastModifiedTime; + if (_infos != nil) { OAUploadedFileInfo *fileInfo = _infos[[NSString stringWithFormat:@"%@___%@", [OASettingsItemType typeName:item.type], fileName]]; diff --git a/Sources/Controllers/Settings/ImportExport/OAExportItemsViewController.mm b/Sources/Controllers/Settings/ImportExport/OAExportItemsViewController.mm index 5ace75e2f9..7ab5ec9c13 100644 --- a/Sources/Controllers/Settings/ImportExport/OAExportItemsViewController.mm +++ b/Sources/Controllers/Settings/ImportExport/OAExportItemsViewController.mm @@ -38,6 +38,8 @@ @implementation OAExportItemsViewController BOOL _shouldOpenSettingsOnInit; BOOL _shouldOpenMyPlacesOnInit; BOOL _shouldOpenResourcesOnInit; + + BOOL _loadingItems; } #pragma mark - Initialization @@ -102,8 +104,9 @@ - (void)commonInit { _settingsHelper = [OASettingsHelper sharedInstance]; _state = EOAExportItemsViewControllerStateTypeInited; - self.itemsMap = [_settingsHelper getSettingsByCategory:YES]; - self.itemTypes = self.itemsMap.allKeys; + _loadingItems = YES; + self.itemsMap = @{}; + self.itemTypes = @[]; } - (void)postInit @@ -112,6 +115,28 @@ - (void)postInit [self updateSelectedProfile]; } +#pragma mark - Lifecycle + +- (void)viewDidLoad +{ + [super viewDidLoad]; + if (!_loadingItems) + return; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSDictionary *itemsMap = [_settingsHelper getSettingsByCategory:YES]; + dispatch_async(dispatch_get_main_queue(), ^{ + self.itemsMap = itemsMap; + self.itemTypes = itemsMap.allKeys; + _loadingItems = NO; + if (_appMode) + [self updateSelectedProfile]; + [self updateUI]; + [self updateNavbar]; + [self updateBottomButtons]; + }); + }); +} + #pragma mark - Base UI - (NSString *)getTitle @@ -121,6 +146,8 @@ - (NSString *)getTitle - (NSArray *)getRightNavbarButtons { + if (_loadingItems) + return nil; return _state == EOAExportItemsViewControllerStateTypeInited ? [super getRightNavbarButtons] : nil; } @@ -141,6 +168,8 @@ - (NSAttributedString *)getTableHeaderDescriptionAttr - (NSString *)getBottomButtonTitle { + if (_loadingItems) + return nil; return _state == EOAExportItemsViewControllerStateTypeInited ? [super getBottomButtonTitle] : @""; } @@ -148,7 +177,15 @@ - (NSString *)getBottomButtonTitle - (void)generateData { - if (_state == EOAExportItemsViewControllerStateTypeInited) + if (_loadingItems) + { + OATableCollapsableGroup *group = [[OATableCollapsableGroup alloc] init]; + group.type = [OAProgressTitleCell getCellIdentifier]; + group.groupName = OALocalizedString(@"shared_string_loading"); + self.data = @[group]; + return; + } + else if (_state == EOAExportItemsViewControllerStateTypeInited) { [super generateData]; diff --git a/Sources/Helpers/OAGPXUIHelper.h b/Sources/Helpers/OAGPXUIHelper.h index 928fec461f..d4ebbedd84 100644 --- a/Sources/Helpers/OAGPXUIHelper.h +++ b/Sources/Helpers/OAGPXUIHelper.h @@ -38,9 +38,7 @@ NS_ASSUME_NONNULL_BEGIN + (long) getSegmentTime:(OASTrkSegment *)segment; + (double) getSegmentDistance:(OASTrkSegment *)segment; -+ (NSArray *) getSortedGPXFilesInfo:(nullable NSString *)dir - selectedGpxList:(nullable NSArray *)selectedGpxList - absolutePath:(BOOL)absolutePath; ++ (NSArray *)sortedGPXDataItems; + (void) addAppearanceToGpx:(OASGpxFile *)gpxFile gpxItem:(OASGpxDataItem *)gpxItem; diff --git a/Sources/Helpers/OAGPXUIHelper.mm b/Sources/Helpers/OAGPXUIHelper.mm index f6a42a4a14..71b2a94611 100644 --- a/Sources/Helpers/OAGPXUIHelper.mm +++ b/Sources/Helpers/OAGPXUIHelper.mm @@ -246,36 +246,20 @@ + (double) getSegmentDistance:(OASTrkSegment *)segment return distance; } -+ (NSArray *) getSortedGPXFilesInfo:(NSString *)dir selectedGpxList:(NSArray *)selectedGpxList absolutePath:(BOOL)absolutePath ++ (NSArray *)sortedGPXDataItems { - NSMutableArray *list = [NSMutableArray new]; - [self readGpxDirectory:dir list:list parent:@"" absolutePath:absolutePath]; - if (selectedGpxList) - { - for (OAGpxFileInfo *info in list) - { - for (NSString *fileName in selectedGpxList) - { - if ([fileName hasSuffix:info.fileName]) - { - info.selected = YES; - break; - } - } - } - } + NSMutableArray *list = [[OAGPXDatabase.sharedDb getDataItems] mutableCopy]; - [list sortUsingComparator:^NSComparisonResult(OAGpxFileInfo *i1, OAGpxFileInfo *i2) { - NSComparisonResult res = (NSComparisonResult) (i1.selected == i2.selected ? 0 : i1.selected ? -1 : 1); - if (res != NSOrderedSame) - return res; - - NSString *name1 = i1.fileName; - NSString *name2 = i2.fileName; + [list sortUsingComparator:^NSComparisonResult(OASGpxDataItem *i1, OASGpxDataItem *i2) { + NSString *name1 = i1.gpxFileName; + NSString *name2 = i2.gpxFileName; NSInteger d1 = [self depth:name1]; NSInteger d2 = [self depth:name2]; - if (d1 != d2) - return d1 - d2 > 0 ? NSOrderedDescending : NSOrderedAscending; + + if (d1 < d2) + return NSOrderedAscending; + if (d1 > d2) + return NSOrderedDescending; NSInteger lastSame = 0; for (NSInteger i = 0; i < name1.length && i < name2.length; i++) @@ -289,7 +273,7 @@ + (double) getSegmentDistance:(OASTrkSegment *)segment BOOL isDigitStarts1 = [self isLastSameStartsWithDigit:name1 lastSame:lastSame]; BOOL isDigitStarts2 = [self isLastSameStartsWithDigit:name2 lastSame:lastSame]; - res = (NSComparisonResult) (isDigitStarts1 == isDigitStarts2 ? 0 : isDigitStarts1 ? -1 : 1); + NSComparisonResult res = (NSComparisonResult) (isDigitStarts1 == isDigitStarts2 ? 0 : isDigitStarts1 ? -1 : 1); if (res != NSOrderedSame) return res; From 48ffd3bbb3cd75c4e104a5410cbd87244975998e Mon Sep 17 00:00:00 2001 From: jonnysemon Date: Sun, 21 Jun 2026 12:07:47 +0200 Subject: [PATCH 28/47] Translated using Weblate (Arabic) Currently translated at 97.6% (3933 of 4028 strings) --- Resources/Localizations/ar.lproj/Localizable.strings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/Localizations/ar.lproj/Localizable.strings b/Resources/Localizations/ar.lproj/Localizable.strings index c1b66006b7..9d8f946275 100644 --- a/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Resources/Localizations/ar.lproj/Localizable.strings @@ -1714,7 +1714,7 @@ "lenght_limit_description" = "أدل بوزن مركبتك ،قد يتم تطبيق بعض قيود الطرقات على المركبات الثقيلة."; "height_limit_description" = "أدل بارتفاع مركبتك ،قد يتم تطبيق بعض القيود على المركبات المرتفعة."; "weight_limit_description" = "أدل بوزن مركبتك ،قد يتم تطبيق بعض القيود على المركبات الثقيلة."; -"export_profile" = "تصدير الوضع"; +"export_profile" = "صدِّر ملف التعريف"; "map_look_descr" = "مظهر الخريطة"; "export_profile_descr" = "لاستيراد ملف تعريف ، انقر فوق الملف المطلوب (* .osf) في أي تطبيق وحدد نسخ إلى أوسماند."; "profile_sett_descr" = "تؤثر على ملف التعريف المحدد"; From 622824df1a9ecbc93170e4cc560595a9a3d6d925 Mon Sep 17 00:00:00 2001 From: vitaliy-sova-ios <38758123+vitaliy-sova-ios@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:38:56 +0300 Subject: [PATCH 29/47] Fixed Smart Folders sync and export/import (#5441) * Fixed Smart Folders sync and exported * Code review fixes * Code review fix * Use executeOnMainThread for NSNotificationQueue Replace direct dispatch_async(dispatch_get_main_queue(), ^{ ... }) calls with executeOnMainThread(...) OAProfileSettingsItem OAAppSettings * Notify changed preference keys; add silent updates * Propagate changed preference keys in notifications * Update OAProfileSettingsItem.mm * Code review fixes * Update OAMapHudViewController.mm * Batch preference change notifications during settings import * Update OASharedUtil.m --------- Co-authored-by: alex-dev --- .../SettingsItems/OAGlobalSettingsItem.mm | 8 +- .../SettingsItems/OAProfileSettingsItem.mm | 8 - .../SettingsItems/OASettingsItem.mm | 21 +- .../OACarPlayDashboardInterfaceController.mm | 3 +- .../Map/Layers/OADestinationsLayer.mm | 18 +- .../Map/Layers/OATerrainMapLayer.mm | 64 +++-- .../Controllers/Map/OAMapHudViewController.mm | 271 +++++++----------- .../Controllers/Map/OAMapViewController.mm | 19 +- .../Map/OAMapViewTrackingUtilities.mm | 16 +- .../OAChangePositionViewController.mm | 7 +- .../TrackMenu/OAAddWaypointViewController.mm | 7 +- Sources/Helpers/OAAppSettings.h | 3 + Sources/Helpers/OAAppSettings.m | 107 ++++++- Sources/Helpers/OAOsmAndContextImpl.mm | 22 +- Sources/Helpers/ScreenOrientationHelper.swift | 3 +- .../Helpers/SharedLibHelpers/OASharedUtil.m | 1 + Sources/Plugins/SRTMPlugin/OASRTMPlugin.mm | 3 +- Sources/Plugins/SRTMPlugin/TerrainMode.swift | 8 + 18 files changed, 327 insertions(+), 262 deletions(-) diff --git a/Sources/Backup/LocalBackup/SettingsItems/OAGlobalSettingsItem.mm b/Sources/Backup/LocalBackup/SettingsItems/OAGlobalSettingsItem.mm index 164cf4a3bb..a03f855b1b 100644 --- a/Sources/Backup/LocalBackup/SettingsItems/OAGlobalSettingsItem.mm +++ b/Sources/Backup/LocalBackup/SettingsItems/OAGlobalSettingsItem.mm @@ -232,9 +232,11 @@ - (BOOL) readFromFile:(NSString *)filePath error:(NSError * _Nullable *)error } NSDictionary *settings = (NSDictionary *) json; - - [settings enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSString * _Nonnull obj, BOOL * _Nonnull stop) { - [self.item readPreferenceFromJson:key value:obj]; + [OAAppSettings performBatchedPreferenceNotifications:^{ + [settings enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSString * _Nonnull obj, BOOL * _Nonnull stop) { + [self.item readPreferenceFromJson:key value:obj]; + [OAAppSettings notifyPreferenceKeysChanged:[NSSet setWithObject:key]]; + }]; }]; self.item.read = YES; diff --git a/Sources/Backup/LocalBackup/SettingsItems/OAProfileSettingsItem.mm b/Sources/Backup/LocalBackup/SettingsItems/OAProfileSettingsItem.mm index ce96231861..eca5aebb5e 100644 --- a/Sources/Backup/LocalBackup/SettingsItems/OAProfileSettingsItem.mm +++ b/Sources/Backup/LocalBackup/SettingsItems/OAProfileSettingsItem.mm @@ -303,14 +303,6 @@ - (void)configureStringValue:(NSString *)strValue requiringSecureCoding:NO error:nil]; [defaults setObject:data forKey:modeKey]; - - [[NSNotificationQueue defaultQueue] enqueueNotification: - [NSNotification notificationWithName:kNotificationSetProfileSetting - object:self - userInfo:nil] - postingStyle:NSPostASAP - coalesceMask:(NSNotificationCoalescingOnName | NSNotificationCoalescingOnSender) - forModes:nil]; } else { diff --git a/Sources/Backup/LocalBackup/SettingsItems/OASettingsItem.mm b/Sources/Backup/LocalBackup/SettingsItems/OASettingsItem.mm index 453ae68c9a..faeac2a9da 100644 --- a/Sources/Backup/LocalBackup/SettingsItems/OASettingsItem.mm +++ b/Sources/Backup/LocalBackup/SettingsItems/OASettingsItem.mm @@ -328,16 +328,21 @@ - (BOOL) readFromFile:(NSString *)filePath error:(NSError * _Nullable *)error NSDictionary *settings; settings = [[OAMigrationManager shared] changeJsonMigrationToV2:json]; - + NSMutableDictionary *rendererSettings = [NSMutableDictionary new]; NSMutableDictionary *routingSettings = [NSMutableDictionary new]; - [settings enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSString * _Nonnull obj, BOOL * _Nonnull stop) { - if ([key hasPrefix:kRendererPreferencePrefix] || [key isEqualToString:@"displayed_transport_settings"]) - [rendererSettings setObject:obj forKey:key]; - else if ([key hasPrefix:kRoutingPreferencePrefix]) - [routingSettings setObject:obj forKey:key]; - else - [self.item readPreferenceFromJson:key value:obj]; + [OAAppSettings performBatchedPreferenceNotifications:^{ + [settings enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSString * _Nonnull obj, BOOL * _Nonnull stop) { + if ([key hasPrefix:kRendererPreferencePrefix] || [key isEqualToString:@"displayed_transport_settings"]) + [rendererSettings setObject:obj forKey:key]; + else if ([key hasPrefix:kRoutingPreferencePrefix]) + [routingSettings setObject:obj forKey:key]; + else + { + [self.item readPreferenceFromJson:key value:obj]; + [OAAppSettings notifyPreferenceKeysChanged:[NSSet setWithObject:key]]; + } + }]; }]; [self.item applyRendererPreferences:rendererSettings]; [self.item applyRoutingPreferences:routingSettings]; diff --git a/Sources/CarPlay/OACarPlayDashboardInterfaceController.mm b/Sources/CarPlay/OACarPlayDashboardInterfaceController.mm index cd3411df2e..852dabb279 100644 --- a/Sources/CarPlay/OACarPlayDashboardInterfaceController.mm +++ b/Sources/CarPlay/OACarPlayDashboardInterfaceController.mm @@ -273,7 +273,8 @@ - (void)onMap3dModeUpdated - (void)onProfileSettingSet:(NSNotification *)notification { - if (notification.object == [[OAMapButtonsHelper sharedInstance] getMap3DButtonState].visibilityPref) + NSSet *preferenceKeys = notification.userInfo[kPreferenceKeysUserInfoKey]; + if (preferenceKeys && [preferenceKeys containsObject:[[OAMapButtonsHelper sharedInstance] getMap3DButtonState].visibilityPref.key]) [self onMap3dModeUpdated]; } diff --git a/Sources/Controllers/Map/Layers/OADestinationsLayer.mm b/Sources/Controllers/Map/Layers/OADestinationsLayer.mm index a223775f75..7b21baa5c9 100644 --- a/Sources/Controllers/Map/Layers/OADestinationsLayer.mm +++ b/Sources/Controllers/Map/Layers/OADestinationsLayer.mm @@ -181,19 +181,17 @@ - (void) deinitLayer - (void) onProfileSettingSet:(NSNotification *)notification { - OACommonPreference *obj = notification.object; OAAppSettings *settings = [OAAppSettings sharedManager]; OACommonActiveMarkerConstant *activeMarkers = settings.activeMarkers; OACommonBoolean *directionLines = settings.directionLines; - if (obj) - { - if (obj == activeMarkers || obj == directionLines) - { - dispatch_async(dispatch_get_main_queue(), ^{ - [self drawDestinationLines]; - }); - } - } + + NSSet *keysToCheck = [NSSet setWithArray:@[activeMarkers.key, directionLines.key]]; + NSSet *preferenceKeys = notification.userInfo[kPreferenceKeysUserInfoKey]; + + if (preferenceKeys && [preferenceKeys intersectsSet:keysToCheck]) + dispatch_async(dispatch_get_main_queue(), ^{ + [self drawDestinationLines]; + }); } - (BOOL) updateLayer diff --git a/Sources/Controllers/Map/Layers/OATerrainMapLayer.mm b/Sources/Controllers/Map/Layers/OATerrainMapLayer.mm index 3accd868f2..b7d0fec61e 100644 --- a/Sources/Controllers/Map/Layers/OATerrainMapLayer.mm +++ b/Sources/Controllers/Map/Layers/OATerrainMapLayer.mm @@ -136,35 +136,53 @@ - (void)updateTerrainLayer - (void)onProfileSettingSet:(NSNotification *)notification { + NSSet *preferenceKeys = notification.userInfo[kPreferenceKeysUserInfoKey]; + + if (!preferenceKeys) + return; + + BOOL terrainEnabledChanged = NO; + BOOL terrainModeChanged = NO; + BOOL transparencyChanged = NO; + BOOL zoomChanged = NO; + + for (NSString *key in preferenceKeys) + { + if ([key isEqualToString:_plugin.terrainEnabledPref.key]) + terrainEnabledChanged = YES; + else if ([key isEqualToString:_plugin.terrainModeTypePref.key]) + terrainModeChanged = YES; + else if ([_terrainMode isTransparencySettingKey:key]) + transparencyChanged = YES; + else if ([_terrainMode isZoomSettingKey:key]) + zoomChanged = YES; + } + + if (!terrainEnabledChanged && !terrainModeChanged && !transparencyChanged && !zoomChanged) + return; + dispatch_async(dispatch_get_main_queue(), ^{ - if (notification.object == _plugin.terrainEnabledPref || notification.object == _plugin.terrainModeTypePref) + if (terrainEnabledChanged || terrainModeChanged || zoomChanged) { [self updateTerrainLayer]; - if (_plugin.terrainModeTypePref) + if (_plugin.terrainModeTypePref && (terrainEnabledChanged || terrainModeChanged)) [self onVerticalExaggerationScaleChanged]; } - else if ([notification.object isKindOfClass:OACommonInteger.class]) + else if (transparencyChanged) { - if ([_terrainMode isTransparencySetting:notification.object]) - { - [self.mapViewController runWithRenderSync:^{ - if ([_terrainMode isTerrainShadows]) - { - [self.mapViewController updateElevationConfiguration]; - [self onVerticalExaggerationScaleChanged]; - } - else - { - OsmAnd::MapLayerConfiguration config; - config.setOpacityFactor([_terrainMode getTransparency] * 0.01); - [self.mapView setMapLayerConfiguration:self.layerIndex configuration:config forcedUpdate:NO]; - } - }]; - } - else if ([_terrainMode isZoomSetting:notification.object]) - { - [self updateTerrainLayer]; - } + [self.mapViewController runWithRenderSync:^{ + if ([_terrainMode isTerrainShadows]) + { + [self.mapViewController updateElevationConfiguration]; + [self onVerticalExaggerationScaleChanged]; + } + else + { + OsmAnd::MapLayerConfiguration config; + config.setOpacityFactor([_terrainMode getTransparency] * 0.01); + [self.mapView setMapLayerConfiguration:self.layerIndex configuration:config forcedUpdate:NO]; + } + }]; } }); } diff --git a/Sources/Controllers/Map/OAMapHudViewController.mm b/Sources/Controllers/Map/OAMapHudViewController.mm index 98cdd3c9b7..f7ad597833 100644 --- a/Sources/Controllers/Map/OAMapHudViewController.mm +++ b/Sources/Controllers/Map/OAMapHudViewController.mm @@ -949,203 +949,144 @@ - (void) onApplicationModeChanged:(OAApplicationMode *)prevMode - (void) onProfileSettingSet:(NSNotification *)notification { - OACommonPreference *obj = notification.object; - if (obj) - { - OAMapButtonsHelper *mapButtonsHelper = [OAMapButtonsHelper sharedInstance]; - CompassButtonState *compassButtonState = [mapButtonsHelper getCompassButtonState]; - Map3DButtonState *map3DButtonState = [mapButtonsHelper getMap3DButtonState]; - MapSettingsButtonState *configureMapButtonState = [mapButtonsHelper getConfigureMapButtonState]; - SearchButtonState *searchButtonState = [mapButtonsHelper getSearchButtonState]; - OptionsMenuButtonState *menuButtonState = [mapButtonsHelper getMenuButtonState]; - DriveModeButtonState *navigationButtonState = [mapButtonsHelper getNavigationModeButtonState]; - MyLocationButtonState *myLocationButtonState = [mapButtonsHelper getMyLocationButtonState]; - ZoomInButtonState *zoomInButtonState = [mapButtonsHelper getZoomInButtonState]; - ZoomOutButtonState *zoomOutButtonState = [mapButtonsHelper getZoomOutButtonState]; - - BOOL isQuickAction = NO; - for (QuickActionButtonState *buttonState in [mapButtonsHelper getButtonsStates]) - { - BOOL isQuickActionProperty = [[NSSet setWithArray:@[ - buttonState.statePref, - [buttonState storedCornerRadiusPref], - [buttonState storedOpacityPref], - [buttonState storedSizePref], - [buttonState storedIconPref], - buttonState.quickActionsPref - ]] containsObject:obj]; - - if (isQuickActionProperty) - { - isQuickAction = YES; - break; - } - } - - if ([self isCompassProperty:obj buttonState:compassButtonState]) - { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateCompassButton]; - [_mapHudLayout updateButtons]; - }); - } - else if (obj == _settings.transparentMapTheme - || obj == _settings.profileIconColor - || obj == _settings.profileCustomIconColor) - { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateColors]; - }); - } - else if ([self isMap3DProperty:obj buttonState:map3DButtonState] || isQuickAction) + NSSet *preferenceKeys = notification.userInfo[kPreferenceKeysUserInfoKey]; + if (preferenceKeys.count == 0) + return; + + OAMapButtonsHelper *mapButtonsHelper = [OAMapButtonsHelper sharedInstance]; + CompassButtonState *compassButtonState = [mapButtonsHelper getCompassButtonState]; + Map3DButtonState *map3DButtonState = [mapButtonsHelper getMap3DButtonState]; + MapSettingsButtonState *configureMapButtonState = [mapButtonsHelper getConfigureMapButtonState]; + SearchButtonState *searchButtonState = [mapButtonsHelper getSearchButtonState]; + OptionsMenuButtonState *menuButtonState = [mapButtonsHelper getMenuButtonState]; + DriveModeButtonState *navigationButtonState = [mapButtonsHelper getNavigationModeButtonState]; + MyLocationButtonState *myLocationButtonState = [mapButtonsHelper getMyLocationButtonState]; + ZoomInButtonState *zoomInButtonState = [mapButtonsHelper getZoomInButtonState]; + ZoomOutButtonState *zoomOutButtonState = [mapButtonsHelper getZoomOutButtonState]; + + BOOL compassChanged = [preferenceKeys intersectsSet:[self compassPropertyKeysForButtonState:compassButtonState]]; + BOOL colorsChanged = [preferenceKeys intersectsSet:[self keysFromPreferences:@[ + _settings.transparentMapTheme, + _settings.profileIconColor, + _settings.profileCustomIconColor + ]]]; + + BOOL quickActionChanged = [preferenceKeys intersectsSet:[self quickActionPropertyKeys:mapButtonsHelper]]; + BOOL map3DChanged = [preferenceKeys intersectsSet:[self buttonStateAppearanceKeysForVisibilityPref:map3DButtonState.visibilityPref buttonState:map3DButtonState]]; + BOOL configureMapChanged = [preferenceKeys intersectsSet:[self buttonStateAppearanceKeysForVisibilityPref:configureMapButtonState.visibilityPref buttonState:configureMapButtonState]]; + BOOL searchChanged = [preferenceKeys intersectsSet:[self buttonStateAppearanceKeysForVisibilityPref:searchButtonState.visibilityPref buttonState:searchButtonState]]; + BOOL menuChanged = [preferenceKeys intersectsSet:[self buttonStateAppearanceKeysForVisibilityPref:menuButtonState.visibilityPref buttonState:menuButtonState]]; + BOOL navigationChanged = [preferenceKeys intersectsSet:[self buttonStateAppearanceKeysForVisibilityPref:navigationButtonState.visibilityPref buttonState:navigationButtonState]]; + BOOL myLocationChanged = [preferenceKeys intersectsSet:[self buttonStateAppearanceKeysForVisibilityPref:myLocationButtonState.visibilityPref buttonState:myLocationButtonState]]; + BOOL zoomInChanged = [preferenceKeys intersectsSet:[self buttonStateAppearanceKeysForVisibilityPref:zoomInButtonState.visibilityPref buttonState:zoomInButtonState]]; + BOOL zoomOutChanged = [preferenceKeys intersectsSet:[self buttonStateAppearanceKeysForVisibilityPref:zoomOutButtonState.visibilityPref buttonState:zoomOutButtonState]]; + + if (!compassChanged && !colorsChanged && !map3DChanged && !quickActionChanged + && !configureMapChanged && !searchChanged && !menuChanged && !navigationChanged + && !myLocationChanged && !zoomInChanged && !zoomOutChanged) + return; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (compassChanged) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateDependentButtons]; - }); + [self updateCompassButton]; + [_mapHudLayout updateButtons]; } - else if ([self isConfigureMapProperty:obj buttonState:configureMapButtonState]) + if (colorsChanged) + [self updateColors]; + if (map3DChanged || quickActionChanged) + [self updateDependentButtons]; + if (configureMapChanged) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateMapButton:_mapSettingsButton showButton:[self shouldShowConfigureMap] appearanceParams:[[mapButtonsHelper getConfigureMapButtonState] createAppearanceParams]]; - }); + [self updateMapButton:_mapSettingsButton + showButton:[self shouldShowConfigureMap] + appearanceParams:[[mapButtonsHelper getConfigureMapButtonState] createAppearanceParams]]; } - else if ([self isSearchProperty:obj buttonState:searchButtonState]) + if (searchChanged) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateMapButton:_searchButton showButton:[self shouldShowSearch] appearanceParams:[[mapButtonsHelper getSearchButtonState] createAppearanceParams]]; - }); + [self updateMapButton:_searchButton + showButton:[self shouldShowSearch] + appearanceParams:[[mapButtonsHelper getSearchButtonState] createAppearanceParams]]; } - else if ([self isMenuProperty:obj buttonState:menuButtonState]) + if (menuChanged) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateMapButton:_optionsMenuButton showButton:[self shouldShowMenu] appearanceParams:[[mapButtonsHelper getMenuButtonState] createAppearanceParams]]; - }); + [self updateMapButton:_optionsMenuButton + showButton:[self shouldShowMenu] + appearanceParams:[[mapButtonsHelper getMenuButtonState] createAppearanceParams]]; } - else if ([self isNavigationProperty:obj buttonState:navigationButtonState]) + if (navigationChanged) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateMapButton:_driveModeButton showButton:[self shouldShowNavigation] appearanceParams:[[mapButtonsHelper getNavigationModeButtonState] createAppearanceParams]]; - }); + [self updateMapButton:_driveModeButton + showButton:[self shouldShowNavigation] + appearanceParams:[[mapButtonsHelper getNavigationModeButtonState] createAppearanceParams]]; } - else if ([self isMyLocationProperty:obj buttonState:myLocationButtonState]) + if (myLocationChanged) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateMapButton:_mapModeButton showButton:[self shouldShowMyLocation] appearanceParams:[[mapButtonsHelper getMyLocationButtonState] createAppearanceParams]]; - }); + [self updateMapButton:_mapModeButton + showButton:[self shouldShowMyLocation] + appearanceParams:[[mapButtonsHelper getMyLocationButtonState] createAppearanceParams]]; } - else if ([self isZoomInProperty:obj buttonState:zoomInButtonState]) + if (zoomInChanged) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateMapButton:_zoomInButton showButton:[self shouldShowZoomIn] appearanceParams:[[mapButtonsHelper getZoomInButtonState] createAppearanceParams]]; - }); + [self updateMapButton:_zoomInButton + showButton:[self shouldShowZoomIn] + appearanceParams:[[mapButtonsHelper getZoomInButtonState] createAppearanceParams]]; } - else if ([self isZoomOutProperty:obj buttonState:zoomOutButtonState]) + if (zoomOutChanged) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateMapButton:_zoomOutButton showButton:[self shouldShowZoomOut] appearanceParams:[[mapButtonsHelper getZoomOutButtonState] createAppearanceParams]]; - }); + [self updateMapButton:_zoomOutButton + showButton:[self shouldShowZoomOut] + appearanceParams:[[mapButtonsHelper getZoomOutButtonState] createAppearanceParams]]; } - } -} - -- (BOOL)isCompassProperty:(OACommonPreference *)obj buttonState:(CompassButtonState *)buttonState -{ - return [[NSSet setWithArray:@[ - _settings.rotateMap, - buttonState.visibilityPref, - [buttonState storedCornerRadiusPref], - [buttonState storedOpacityPref], - [buttonState storedSizePref], - [buttonState storedIconPref] - ]] containsObject:obj]; -} - -- (BOOL)isMap3DProperty:(OACommonPreference *)obj buttonState:(Map3DButtonState *)buttonState -{ - return [[NSSet setWithArray:@[ - buttonState.visibilityPref, - [buttonState storedCornerRadiusPref], - [buttonState storedOpacityPref], - [buttonState storedSizePref], - [buttonState storedIconPref] - ]] containsObject:obj]; -} - -- (BOOL)isConfigureMapProperty:(OACommonPreference *)obj buttonState:(MapSettingsButtonState *)buttonState -{ - return [[NSSet setWithArray:@[ - buttonState.visibilityPref, - [buttonState storedCornerRadiusPref], - [buttonState storedOpacityPref], - [buttonState storedSizePref], - [buttonState storedIconPref] - ]] containsObject:obj]; -} - -- (BOOL)isSearchProperty:(OACommonPreference *)obj buttonState:(SearchButtonState *)buttonState -{ - return [[NSSet setWithArray:@[ - buttonState.visibilityPref, - [buttonState storedCornerRadiusPref], - [buttonState storedOpacityPref], - [buttonState storedSizePref], - [buttonState storedIconPref] - ]] containsObject:obj]; -} - -- (BOOL)isMenuProperty:(OACommonPreference *)obj buttonState:(OptionsMenuButtonState *)buttonState -{ - return [[NSSet setWithArray:@[ - buttonState.visibilityPref, - [buttonState storedCornerRadiusPref], - [buttonState storedOpacityPref], - [buttonState storedSizePref], - [buttonState storedIconPref] - ]] containsObject:obj]; + }); } -- (BOOL)isNavigationProperty:(OACommonPreference *)obj buttonState:(DriveModeButtonState *)buttonState +- (NSSet *)keysFromPreferences:(NSArray *)preferences { - return [[NSSet setWithArray:@[ - buttonState.visibilityPref, - [buttonState storedCornerRadiusPref], - [buttonState storedOpacityPref], - [buttonState storedSizePref], - [buttonState storedIconPref] - ]] containsObject:obj]; + NSMutableSet *keys = [NSMutableSet setWithCapacity:preferences.count]; + for (OACommonPreference *pref in preferences) + { + if (pref.key.length > 0) + [keys addObject:pref.key]; + } + return [keys copy]; } -- (BOOL)isMyLocationProperty:(OACommonPreference *)obj buttonState:(MyLocationButtonState *)buttonState +- (NSSet *)buttonStateAppearanceKeysForVisibilityPref:(OACommonPreference *)visibilityPref + buttonState:(MapButtonState *)buttonState { - return [[NSSet setWithArray:@[ - buttonState.visibilityPref, + return [self keysFromPreferences:@[ + visibilityPref, [buttonState storedCornerRadiusPref], [buttonState storedOpacityPref], [buttonState storedSizePref], [buttonState storedIconPref] - ]] containsObject:obj]; + ]]; } -- (BOOL)isZoomInProperty:(OACommonPreference *)obj buttonState:(ZoomInButtonState *)buttonState +- (NSSet *)compassPropertyKeysForButtonState:(CompassButtonState *)buttonState { - return [[NSSet setWithArray:@[ - buttonState.visibilityPref, - [buttonState storedCornerRadiusPref], - [buttonState storedOpacityPref], - [buttonState storedSizePref], - [buttonState storedIconPref] - ]] containsObject:obj]; + NSMutableSet *keys = [NSMutableSet setWithSet:[self buttonStateAppearanceKeysForVisibilityPref:buttonState.visibilityPref buttonState:buttonState]]; + if (_settings.rotateMap.key.length > 0) + [keys addObject:_settings.rotateMap.key]; + return [keys copy]; } -- (BOOL)isZoomOutProperty:(OACommonPreference *)obj buttonState:(ZoomOutButtonState *)buttonState +- (NSSet *)quickActionPropertyKeys:(OAMapButtonsHelper *)helper { - return [[NSSet setWithArray:@[ - buttonState.visibilityPref, - [buttonState storedCornerRadiusPref], - [buttonState storedOpacityPref], - [buttonState storedSizePref], - [buttonState storedIconPref] - ]] containsObject:obj]; + NSMutableSet *keys = [NSMutableSet set]; + for (QuickActionButtonState *buttonState in [helper getButtonsStates]) + { + [keys unionSet:[self keysFromPreferences:@[ + buttonState.statePref, + [buttonState storedCornerRadiusPref], + [buttonState storedOpacityPref], + [buttonState storedSizePref], + [buttonState storedIconPref], + buttonState.quickActionsPref + ]]]; + } + return [keys copy]; } - (void)updateMapButton:(OAHudButton *)button showButton:(BOOL)showButton appearanceParams:(ButtonAppearanceParams *)appearanceParams diff --git a/Sources/Controllers/Map/OAMapViewController.mm b/Sources/Controllers/Map/OAMapViewController.mm index 083b221f1f..2c9beb803f 100644 --- a/Sources/Controllers/Map/OAMapViewController.mm +++ b/Sources/Controllers/Map/OAMapViewController.mm @@ -2380,19 +2380,12 @@ - (void) onLocalResourcesChanged:(const QList< QString >&)ids - (void) onProfileSettingSet:(NSNotification *)notification { - OACommonPreference *obj = notification.object; - OAAppSettings *settings = [OAAppSettings sharedManager]; - OACommonBoolean *keepMapLabelsVisible = settings.keepMapLabelsVisible; - if (obj) - { - if (obj == keepMapLabelsVisible) - { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateSymbolsLayerProviderAlpha]; - [self updateRasterLayerProviderAlpha]; - }); - } - } + NSSet *preferenceKeys = notification.userInfo[kPreferenceKeysUserInfoKey]; + if (preferenceKeys && [preferenceKeys containsObject:[OAAppSettings sharedManager].keepMapLabelsVisible.key]) + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateSymbolsLayerProviderAlpha]; + [self updateRasterLayerProviderAlpha]; + }); } - (void) refreshMap diff --git a/Sources/Controllers/Map/OAMapViewTrackingUtilities.mm b/Sources/Controllers/Map/OAMapViewTrackingUtilities.mm index 9f7cb26b5c..e20ec2f014 100644 --- a/Sources/Controllers/Map/OAMapViewTrackingUtilities.mm +++ b/Sources/Controllers/Map/OAMapViewTrackingUtilities.mm @@ -1144,17 +1144,11 @@ - (CGRect) calculateVisibleMapRect - (void) onProfileSettingSet:(NSNotification *)notification { - OACommonPreference *obj = notification.object; - OACommonInteger *centerPositionOnMap = [OAAppSettings sharedManager].positionPlacementOnMap; - if (obj) - { - if (obj == centerPositionOnMap) - { - dispatch_async(dispatch_get_main_queue(), ^{ - [self updateSettings]; - }); - } - } + NSSet *preferenceKeys = notification.userInfo[kPreferenceKeysUserInfoKey]; + if (preferenceKeys && [preferenceKeys containsObject:[OAAppSettings sharedManager].positionPlacementOnMap.key]) + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateSettings]; + }); } @end diff --git a/Sources/Controllers/TargetMenu/ChangePosition/OAChangePositionViewController.mm b/Sources/Controllers/TargetMenu/ChangePosition/OAChangePositionViewController.mm index 1ac6d62f9c..b1a531d467 100644 --- a/Sources/Controllers/TargetMenu/ChangePosition/OAChangePositionViewController.mm +++ b/Sources/Controllers/TargetMenu/ChangePosition/OAChangePositionViewController.mm @@ -236,10 +236,9 @@ - (void)onMenuShown - (void)onProfileSettingSet:(NSNotification *)notification { // Keep the movable pin centered by ignoring map position changes from rotation mode updates. - if (notification.object != [OAAppSettings sharedManager].rotateMap) - return; - - [self adjustViewport]; + NSSet *preferenceKeys = notification.userInfo[kPreferenceKeysUserInfoKey]; + if (preferenceKeys && [preferenceKeys containsObject:[OAAppSettings sharedManager].rotateMap.key]) + [self adjustViewport]; } - (void) applyLocalization diff --git a/Sources/Controllers/TargetMenu/GPX/TrackMenu/OAAddWaypointViewController.mm b/Sources/Controllers/TargetMenu/GPX/TrackMenu/OAAddWaypointViewController.mm index d01c43e493..ccc28d70ec 100644 --- a/Sources/Controllers/TargetMenu/GPX/TrackMenu/OAAddWaypointViewController.mm +++ b/Sources/Controllers/TargetMenu/GPX/TrackMenu/OAAddWaypointViewController.mm @@ -168,10 +168,9 @@ - (void)onMenuDismissed - (void)onProfileSettingSet:(NSNotification *)notification { // Keep the movable pin centered by ignoring map position changes from rotation mode updates. - if (notification.object != [OAAppSettings sharedManager].rotateMap) - return; - - [self adjustViewport]; + NSSet *preferenceKeys = notification.userInfo[kPreferenceKeysUserInfoKey]; + if (preferenceKeys && [preferenceKeys containsObject:[OAAppSettings sharedManager].rotateMap.key]) + [self adjustViewport]; } - (NSString *)getTypeStr diff --git a/Sources/Helpers/OAAppSettings.h b/Sources/Helpers/OAAppSettings.h index 4c21a69772..e08fdc4cb8 100644 --- a/Sources/Helpers/OAAppSettings.h +++ b/Sources/Helpers/OAAppSettings.h @@ -13,6 +13,7 @@ NS_ASSUME_NONNULL_BEGIN @class OAApplicationMode, OAColoringType, OADownloadMode, OAAvoidRoadInfo, OAMapSource, OAMapLayersConfiguration, OASubscriptionState, OASGradientPaletteCategory; static NSString * const kNotificationSetProfileSetting = @"kNotificationSetProfileSetting"; +static NSString * const kPreferenceKeysUserInfoKey = @"kPreferenceKeysUserInfoKey"; static NSString * const VOICE_PROVIDER_NOT_USE = @"VOICE_PROVIDER_NOT_USE"; static NSString * const appearanceProfileThemeKey = @"appearanceProfileThemeKey"; @@ -1341,6 +1342,8 @@ typedef NS_ENUM(NSInteger, EOAWikiDataSourceType) - (void)setGlobalPreference:(NSString *)value key:(NSString *)key; - (OACommonPreference *)getProfilePreference:(NSString *)key; - (void)setProfilePreference:(NSString *)value key:(NSString *)key; ++ (void)performBatchedPreferenceNotifications:(void (^)(void))changes; ++ (void)notifyPreferenceKeysChanged:(NSSet *)keys; - (NSMapTable *)getRegisteredPreferences; - (NSMapTable *)getGlobalPreferences; diff --git a/Sources/Helpers/OAAppSettings.m b/Sources/Helpers/OAAppSettings.m index 2da3cc5181..118a714ceb 100644 --- a/Sources/Helpers/OAAppSettings.m +++ b/Sources/Helpers/OAAppSettings.m @@ -25,6 +25,16 @@ #import "OsmAnd_Maps-Swift.h" #import "OsmAndSharedWrapper.h" +static NSString * const kPreferenceNotificationBatchDepthThreadKey = @"OAPreferenceNotificationBatchDepth"; +static NSString * const kPreferenceNotificationBatchKeysThreadKey = @"OAPreferenceNotificationBatchKeys"; + +@interface OAAppSettings () + ++ (void)notifyPreferenceChanged:(OACommonPreference *)preference; ++ (void)postPreferenceNotificationWithObject:(id)object keys:(NSSet *)keys enqueue:(BOOL)enqueue; + +@end + static NSString * const settingAppModeKey = @"settingAppModeKey"; static NSString * const settingShowMapRuletKey = @"settingShowMapRuletKey"; static NSString * const metricSystemKey = @"settingMetricSystemKey"; @@ -1690,8 +1700,8 @@ - (void)setValue:(NSObject *)value mode:(OAApplicationMode *)mode [self setLastModifiedTime:NSDate.date.timeIntervalSince1970]; [[NSUserDefaults standardUserDefaults] setObject:value forKey:[self getKey:mode]]; - NSNotification *notif = [NSNotification notificationWithName:kNotificationSetProfileSetting object:self userInfo:nil]; - [[NSNotificationQueue defaultQueue] enqueueNotification:notif postingStyle:NSPostASAP coalesceMask:(NSNotificationCoalescingOnName | NSNotificationCoalescingOnSender) forModes:nil]; + + [OAAppSettings notifyPreferenceChanged:self]; } - (BOOL)isSetForMode:(OAApplicationMode *)mode @@ -1878,8 +1888,8 @@ - (void)setValue:(NSObject *)value mode:(OAApplicationMode *)mode [self.cachedValues setObject:appMode forKey:mode]; [[NSUserDefaults standardUserDefaults] setObject:appMode.stringKey forKey:[self getKey:mode]]; - NSNotification *notif = [NSNotification notificationWithName:kNotificationSetProfileSetting object:self userInfo:nil]; - [[NSNotificationQueue defaultQueue] enqueueNotification:notif postingStyle:NSPostASAP coalesceMask:(NSNotificationCoalescingOnName | NSNotificationCoalescingOnSender) forModes:nil]; + + [OAAppSettings notifyPreferenceChanged:self]; } - (void)resetToDefault @@ -4276,8 +4286,8 @@ - (void) setValue:(NSObject *)value mode:(OAApplicationMode *)mode NSData *data = [NSKeyedArchiver archivedDataWithRootObject:unit requiringSecureCoding:NO error:nil]; [[NSUserDefaults standardUserDefaults] setObject:data forKey:[self getKey:mode]]; - NSNotification *notif = [NSNotification notificationWithName:kNotificationSetProfileSetting object:self userInfo:nil]; - [[NSNotificationQueue defaultQueue] enqueueNotification:notif postingStyle:NSPostASAP coalesceMask:(NSNotificationCoalescingOnName | NSNotificationCoalescingOnSender) forModes:nil]; + + [OAAppSettings notifyPreferenceChanged:self]; } - (NSObject *)getProfileDefaultValue:(OAApplicationMode *)mode @@ -5700,6 +5710,91 @@ + (OAAppSettings*) sharedManager return _sharedManager; } ++ (void)performBatchedPreferenceNotifications:(void (^)(void))changes +{ + if (!changes) + return; + + NSMutableDictionary *threadDictionary = NSThread.currentThread.threadDictionary; + NSInteger previousDepth = [threadDictionary[kPreferenceNotificationBatchDepthThreadKey] integerValue]; + if (previousDepth == 0) + threadDictionary[kPreferenceNotificationBatchKeysThreadKey] = [NSMutableSet set]; + + threadDictionary[kPreferenceNotificationBatchDepthThreadKey] = @(previousDepth + 1); + @try + { + changes(); + } + @finally + { + NSInteger currentDepth = [threadDictionary[kPreferenceNotificationBatchDepthThreadKey] integerValue] - 1; + if (currentDepth > 0) + { + threadDictionary[kPreferenceNotificationBatchDepthThreadKey] = @(currentDepth); + } + else + { + NSSet *changedKeys = [threadDictionary[kPreferenceNotificationBatchKeysThreadKey] copy]; + [threadDictionary removeObjectForKey:kPreferenceNotificationBatchDepthThreadKey]; + [threadDictionary removeObjectForKey:kPreferenceNotificationBatchKeysThreadKey]; + if (changedKeys.count > 0) + [self postPreferenceNotificationWithObject:nil keys:changedKeys enqueue:NO]; + } + } +} + ++ (void)notifyPreferenceKeysChanged:(NSSet *)keys +{ + if (keys.count == 0) + return; + + NSMutableDictionary *threadDictionary = NSThread.currentThread.threadDictionary; + NSInteger batchDepth = [threadDictionary[kPreferenceNotificationBatchDepthThreadKey] integerValue]; + if (batchDepth > 0) + { + NSMutableSet *batchedKeys = threadDictionary[kPreferenceNotificationBatchKeysThreadKey]; + if (!batchedKeys) + { + batchedKeys = [NSMutableSet set]; + threadDictionary[kPreferenceNotificationBatchKeysThreadKey] = batchedKeys; + } + [batchedKeys unionSet:keys]; + } + else + { + [self postPreferenceNotificationWithObject:nil keys:keys enqueue:NO]; + } +} + ++ (void)notifyPreferenceChanged:(OACommonPreference *)preference +{ + if (preference.key.length == 0) + return; + + NSSet *keys = [NSSet setWithObject:preference.key]; + NSMutableDictionary *threadDictionary = NSThread.currentThread.threadDictionary; + NSInteger batchDepth = [threadDictionary[kPreferenceNotificationBatchDepthThreadKey] integerValue]; + if (batchDepth > 0) + [self notifyPreferenceKeysChanged:keys]; + else + [self postPreferenceNotificationWithObject:preference keys:keys enqueue:YES]; +} + ++ (void)postPreferenceNotificationWithObject:(id)object keys:(NSSet *)keys enqueue:(BOOL)enqueue +{ + if (keys.count == 0) + return; + + NSSet *keysCopy = [keys copy]; + executeOnMainThread(^{ + NSNotification *notif = [NSNotification notificationWithName:kNotificationSetProfileSetting object:object userInfo:@{kPreferenceKeysUserInfoKey:keysCopy}]; + if (enqueue) + [[NSNotificationQueue defaultQueue] enqueueNotification:notif postingStyle:NSPostASAP coalesceMask:(NSNotificationCoalescingOnName | NSNotificationCoalescingOnSender) forModes:nil]; + else + [[NSNotificationCenter defaultCenter] postNotification:notif]; + }); +} + - (instancetype) init { self = [super init]; diff --git a/Sources/Helpers/OAOsmAndContextImpl.mm b/Sources/Helpers/OAOsmAndContextImpl.mm index 603565933c..6f7fe3c35c 100644 --- a/Sources/Helpers/OAOsmAndContextImpl.mm +++ b/Sources/Helpers/OAOsmAndContextImpl.mm @@ -43,10 +43,22 @@ - (void)dealloc - (void)onPreferenceSet:(NSNotification *)notification { - OACommonPreference *pref = (OACommonPreference *) notification.object; - id listener = _prefListeners[pref.key]; - if (listener) - [listener stateChangedChange:[pref getPrefValue]]; + NSSet *preferenceKeys = notification.userInfo[kPreferenceKeysUserInfoKey]; + if (preferenceKeys && preferenceKeys.count > 0) + { + for (NSString *listenerKey in _prefListeners.allKeys) + { + if ([preferenceKeys containsObject:listenerKey]) + { + OACommonPreference *pref = [[OAAppSettings.sharedManager getRegisteredPreferences] objectForKey:listenerKey]; + if (!pref) + continue; + id listener = _prefListeners[pref.key]; + if (listener) + [listener stateChangedChange:[pref getPrefValue]]; + } + } + } } - (void)registerPreferenceName:(NSString *)name defValue:(NSString *)defValue global:(BOOL)global shared_:(BOOL)shared @@ -56,6 +68,8 @@ - (void)registerPreferenceName:(NSString *)name defValue:(NSString *)defValue gl [pref makeGlobal]; if (shared) [pref makeShared]; + if (global) + [[OAAppSettings.sharedManager getPreferences:YES] setObject:pref forKey:name]; } - (void)addStringPreferenceListenerName:(nonnull NSString *)name listener:(nonnull id)listener diff --git a/Sources/Helpers/ScreenOrientationHelper.swift b/Sources/Helpers/ScreenOrientationHelper.swift index 6e33819447..f391976e0d 100644 --- a/Sources/Helpers/ScreenOrientationHelper.swift +++ b/Sources/Helpers/ScreenOrientationHelper.swift @@ -123,7 +123,8 @@ class ScreenOrientationHelper: NSObject { } @objc private func onProfileSettingDidChange(notification: Notification) { - if let obj = notification.object as? OACommonPreference, obj == settings.mapScreenOrientation { + let preferenceKeys = notification.userInfo?[kPreferenceKeysUserInfoKey] as? Set + if let preferenceKeys, preferenceKeys.contains(settings.mapScreenOrientation.key) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.updateCachedUserInterfaceOrientationMask() diff --git a/Sources/Helpers/SharedLibHelpers/OASharedUtil.m b/Sources/Helpers/SharedLibHelpers/OASharedUtil.m index 0a6fe9e383..03da1f55b0 100644 --- a/Sources/Helpers/SharedLibHelpers/OASharedUtil.m +++ b/Sources/Helpers/SharedLibHelpers/OASharedUtil.m @@ -19,6 +19,7 @@ + (void)initSharedLib:(NSString *)documentsPath gpxPath:(NSString *)gpxPath { [OASPlatformUtil.shared initializeOsmAndContext:[[OAOsmAndContextImpl alloc] init] xmlFactoryApi:[[OAXmlFactory alloc] init]]; + (void)[SharedLibSmartFolderHelper shared]; } // Temporary test code diff --git a/Sources/Plugins/SRTMPlugin/OASRTMPlugin.mm b/Sources/Plugins/SRTMPlugin/OASRTMPlugin.mm index 85aad2afff..9d52adb63f 100644 --- a/Sources/Plugins/SRTMPlugin/OASRTMPlugin.mm +++ b/Sources/Plugins/SRTMPlugin/OASRTMPlugin.mm @@ -260,7 +260,8 @@ - (int)terrainShadowsOpacity - (void)onProfileSettingSet:(NSNotification *)notification { - if (notification.object == _enable3dMapsPref) + NSSet *preferenceKeys = notification.userInfo[kPreferenceKeysUserInfoKey]; + if (preferenceKeys && [preferenceKeys containsObject:_enable3dMapsPref.key]) { dispatch_async(dispatch_get_main_queue(), ^{ [OARootViewController.instance.mapPanel.mapViewController recreateHeightmapProvider]; diff --git a/Sources/Plugins/SRTMPlugin/TerrainMode.swift b/Sources/Plugins/SRTMPlugin/TerrainMode.swift index 1ac41fe66a..94cf4a9612 100644 --- a/Sources/Plugins/SRTMPlugin/TerrainMode.swift +++ b/Sources/Plugins/SRTMPlugin/TerrainMode.swift @@ -302,8 +302,16 @@ final class TerrainMode: NSObject { func isTransparencySetting(_ setting: OACommonInteger) -> Bool { setting == transparencyPref } + + func isTransparencySettingKey(_ key: String) -> Bool { + key == transparencyPref.key + } func isZoomSetting(_ setting: OACommonInteger) -> Bool { setting == minZoomPref || setting == maxZoomPref } + + func isZoomSettingKey(_ key: String) -> Bool { + key == minZoomPref.key || key == maxZoomPref.key + } } From 55a7135bb19dcacf731c900b8a80cf141c021c87 Mon Sep 17 00:00:00 2001 From: DmitrySvetlichny <111898301+DmitrySvetlichny@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:28:03 +0300 Subject: [PATCH 30/47] Change default appearance track folders (#5478) * Add insetGrouped to TracksChangeAppearanceViewController * Add onFolderDefaultAppearanceButtonClicked * Add Title Default appearance * Add getTableHeaderDescription * Add loc strings * Add default appearance for Track folders * init fix * Fix in applyFolderDefaultAppearance * Fix configureWidth * Code Fix --- .../en.lproj/Localizable.strings | 5 + .../Controllers/MyPlaces/AppearanceData.swift | 24 +- .../MyPlaces/ChangeTracksAppearanceTask.swift | 18 +- ...TracksChangeAppearanceViewController.swift | 475 ++++++++++++++---- .../MyPlaces/TracksViewController.swift | 23 +- .../RoutePlanning/OASaveGpxRouteAsyncTask.mm | 1 + Sources/GPX/GpxAppearanceInfo.swift | 16 +- Sources/Helpers/OAGPXImportUIHelper.mm | 11 +- Sources/Helpers/OAGPXUIHelper.mm | 1 + .../GpxDataItemExtension.swift | 35 +- 10 files changed, 474 insertions(+), 135 deletions(-) diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 5b9d7c69e3..fe1fc0c213 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -4109,8 +4109,13 @@ "change_appearance" = "Change appearance"; "default_appearance" = "Default appearance"; +"default_appearance_description" = "These settings apply to all new tracks added to this folder."; +"change_default_tracks_appearance_confirmation" = "Apply changes to existing tracks in the folder or only to new ones?"; +"apply_to_existing_tracks_description" = "This will overwrite the individual appearance settings of tracks currently inside this folder."; +"settings_applied" = "Settings applied"; "default_appearance_desc" = "Default color, icon and shape will apply to the added favorite points into the group."; "apply_to_existing" = "Apply to existing"; +"apply_only_to_new" = "Apply only to new"; "apply_only_to_new_points" = "Apply only to new points"; "apply_to_all_points" = "Apply to all points"; "save_favorite_default_appearance" = "Apply changes to all existing points in the folder, or only to newly added ones?"; diff --git a/Sources/Controllers/MyPlaces/AppearanceData.swift b/Sources/Controllers/MyPlaces/AppearanceData.swift index 69ae415b33..72af63ec23 100644 --- a/Sources/Controllers/MyPlaces/AppearanceData.swift +++ b/Sources/Controllers/MyPlaces/AppearanceData.swift @@ -33,19 +33,14 @@ final class AppearanceData: NSObject { } } - private func notifyAppearanceModified() { - delegate?.onAppearanceChanged() - } - - private func isValidValue(parameter: GpxParameter, value: Any?) -> Bool { - guard parameter.isAppearanceParameter() else { return false } - return true - } - func getParameter(for parameter: GpxParameter) -> T? { guard let tuple = map[parameter] else { return nil } return tuple.1 as? T } + + func rawParameter(for parameter: GpxParameter) -> Any? { + map[parameter]?.1 + } func setParameter(_ parameter: GpxParameter, value: Any?) { guard isValidValue(parameter: parameter, value: value) else { return } @@ -71,9 +66,18 @@ final class AppearanceData: NSObject { return true } } - + return false } + + private func notifyAppearanceModified() { + delegate?.onAppearanceChanged() + } + + private func isValidValue(parameter: GpxParameter, value: Any?) -> Bool { + guard parameter.isAppearanceParameter() else { return false } + return true + } } extension AppearanceData { diff --git a/Sources/Controllers/MyPlaces/ChangeTracksAppearanceTask.swift b/Sources/Controllers/MyPlaces/ChangeTracksAppearanceTask.swift index 4632827d44..a24e84504c 100644 --- a/Sources/Controllers/MyPlaces/ChangeTracksAppearanceTask.swift +++ b/Sources/Controllers/MyPlaces/ChangeTracksAppearanceTask.swift @@ -28,14 +28,13 @@ final class ChangeTracksAppearanceTask: NSObject { updateCurrentTrackAppearance() } else if let file = track.getFile() { let gpxFile = resetAnything ? getGpxFile(for: file) : nil - updateTrackAppearance(file: file, gpxFile: gpxFile) + updateTrackAppearance(track: track, file: file, gpxFile: gpxFile) } } } - private func updateTrackAppearance(file: KFile, gpxFile: GpxFile?) { - let callback = getGpxDataItemCallback(gpxFile: gpxFile) - if let dataItem = gpxDbHelper?.getItem(file: file, callback: callback) { + private func updateTrackAppearance(track: TrackItem, file: KFile, gpxFile: GpxFile?) { + if let dataItem = track.dataItem ?? gpxDbHelper?.getItem(file: file, readIfNeeded: false) { updateTrackAppearance(item: dataItem, gpxFile: gpxFile) } } @@ -74,20 +73,11 @@ final class ChangeTracksAppearanceTask: NSObject { } } - private func getGpxDataItemCallback(gpxFile: GpxFile?) -> GpxDbHelperGpxDataItemCallback { - let handler = GpxDataItemHandler() - handler.onGpxDataItemReady = { [weak self] item in - self?.updateTrackAppearance(item: item, gpxFile: gpxFile) - } - - return handler - } - private func updateTrackAppearance(item: GpxDataItem, gpxFile: GpxFile?) { for parameter in GpxParameter.companion.getAppearanceParameters() { if data.shouldResetParameter(parameter), let gpxFile = gpxFile { item.readGpxAppearanceParameter(gpxFile: gpxFile, parameter: parameter) - } else if let value: Any = data.getParameter(for: parameter) { + } else if let value = data.rawParameter(for: parameter) { item.setParameter(parameter: parameter, value: value) } } diff --git a/Sources/Controllers/MyPlaces/TracksChangeAppearanceViewController.swift b/Sources/Controllers/MyPlaces/TracksChangeAppearanceViewController.swift index af3738c75a..de09878584 100644 --- a/Sources/Controllers/MyPlaces/TracksChangeAppearanceViewController.swift +++ b/Sources/Controllers/MyPlaces/TracksChangeAppearanceViewController.swift @@ -8,6 +8,11 @@ import UIKit +enum InitMode { + case tracks(Set) + case folder(TrackFolder) +} + private enum WidthKeys: String { case thin, medium, bold } @@ -29,6 +34,7 @@ private enum RowKey: String { case splitIntervalRowKey case splitIntervalDescrRowKey case splitIntervalNoneDescrRowKey + case applyExistingTracksRowKey } final class TracksChangeAppearanceViewController: OABaseNavbarViewController { @@ -36,11 +42,15 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { private static let widthArrayValue = "widthArrayValue" private static let hasTopLabels = "hasTopLabels" private static let hasBottomLabels = "hasBottomLabels" + private static let isEnabledValue = "isEnabled" + + private let folder: TrackFolder? + private let dirItem: GpxDirItem? + private let appearanceCollection: OAGPXAppearanceCollection = OAGPXAppearanceCollection.sharedInstance() private var tracks: Set private var initialData: AppearanceData private var data: AppearanceData - private let appearanceCollection: OAGPXAppearanceCollection = OAGPXAppearanceCollection.sharedInstance() private var sortedColorItems: [PaletteItemSolid] = [] private var sortedPaletteColorItems = OAConcurrentArray() private var selectedShowArrows: Bool? @@ -64,9 +74,22 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { private var isSplitIntervalSelected = false private var isSplitIntervalNoneSelected = false - init(tracks: Set) { - self.tracks = tracks - self.initialData = Self.buildAppearanceData() + init(mode: InitMode) { + switch mode { + case .tracks(let tracks): + self.folder = nil + self.dirItem = nil + self.tracks = tracks + self.initialData = Self.buildAppearanceData() + case .folder(let folder): + let dirItem = folder.dirItem ?? GpxDbHelper.shared.getGpxDirItem(file: folder.getDirFile()) + folder.dirItem = dirItem + self.folder = folder + self.dirItem = dirItem + self.tracks = Set(folder.getTrackItems()) + self.initialData = Self.buildAppearanceData(from: dirItem) + } + self.data = AppearanceData(data: self.initialData) super.init(nibName: "OABaseNavbarViewController", bundle: nil) initTableData() @@ -105,10 +128,11 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { addCell(SegmentImagesTableViewCell.reuseIdentifier) addCell(SegmentTextTableViewCell.reuseIdentifier) addCell(OASegmentSliderTableViewCell.reuseIdentifier) + addCell(OASearchMoreCell.reuseIdentifier) } override func getTitle() -> String? { - localizedString("change_appearance") + localizedString(folder == nil ? "change_appearance" : "default_appearance") } override func getLeftNavbarButtonTitle() -> String? { @@ -123,6 +147,14 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { false } + override func tableStyle() -> UITableView.Style { + .insetGrouped + } + + override func getTableHeaderDescription() -> String? { + folder != nil ? localizedString("default_appearance_description") : nil + } + override func generateData() { tableData.clearAllData() colorsCollectionIndexPath = nil @@ -224,6 +256,16 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { splitIntervalDescrRow.key = RowKey.splitIntervalDescrRowKey.rawValue splitIntervalDescrRow.title = localizedString("unchanged_parameter_summary") } + + if folder != nil { + let applySection = tableData.createNewSection() + applySection.footerText = localizedString("apply_to_existing_tracks_description") + let applyRow = applySection.createNewRow() + applyRow.cellType = OASearchMoreCell.reuseIdentifier + applyRow.key = RowKey.applyExistingTracksRowKey.rawValue + applyRow.title = applyExistingTracksTitle() + applyRow.setObj(hasAppearanceChanges() && !tracks.isEmpty, forKey: Self.isEnabledValue) + } } override func getRow(_ indexPath: IndexPath?) -> UITableViewCell? { @@ -283,6 +325,7 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { cell.selectionStyle = .none cell.heightConstraint.constant = 60 cell.chartView.extraBottomOffset = 24 + cell.backgroundColor = .groupBg GpxUIHelper.setupGradientChart(chart: cell.chartView, useGesturesAndScale: false, xAxisGridColor: .chartAxisGridLine, labelsColor: .textColorSecondary) if let paletteItem = selectedPaletteColorItem { let fileType = paletteItem.properties.fileType @@ -368,6 +411,15 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { cell.sliderView.removeTarget(self, action: nil, for: [.touchUpInside, .touchUpOutside]) cell.sliderView.addTarget(self, action: #selector(sliderChanged(sender:)), for: [.touchUpInside, .touchUpOutside]) return cell + } else if item.cellType == OASearchMoreCell.reuseIdentifier { + let cell = tableView.dequeueReusableCell(withIdentifier: OASearchMoreCell.reuseIdentifier, for: indexPath) as! OASearchMoreCell + let isEnabled = item.bool(forKey: Self.isEnabledValue) + cell.selectionStyle = isEnabled ? .default : .none + cell.textView.text = item.title + cell.textView.textAlignment = .center + cell.textView.font = UIFont.preferredFont(forTextStyle: .body) + cell.textView.textColor = isEnabled ? .textColorActive : .textColorSecondary + return cell } return nil @@ -379,13 +431,11 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { if isSolidColorSelected { let items = appearanceCollection.getAvailableColorsSortingByLastUsed() ?? [] let selectedItem: Any = selectedColorItem ?? NSNull() - let colorCollectionVC = ItemsCollectionViewController(collectionType: .colorItems, items: items, selectedItem: selectedItem) colorCollectionVC.delegate = self if let colorsCollectionIndexPath, let colorCell = tableView.cellForRow(at: colorsCollectionIndexPath) as? OACollectionSingleLineTableViewCell, let colorHandler = colorCell.getCollectionHandler() as? OAColorCollectionHandler { colorCollectionVC.hostColorHandler = colorHandler } - navigationController?.pushViewController(colorCollectionVC, animated: true) } else if isGradientColorSelected { if let paletteColorItem = selectedPaletteColorItem { @@ -395,23 +445,28 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { navigationController?.pushViewController(colorCollectionVC, animated: true) } } + } else if item.key == RowKey.applyExistingTracksRowKey.rawValue { + guard hasAppearanceChanges(), !tracks.isEmpty else { return } + saveFolderDefaultAppearance(updateExisting: true, dismissOnFinish: false) } } override func onLeftNavbarButtonPressed() { - if data != initialData { + if hasAppearanceChanges() { let alertController = UIAlertController(title: localizedString("unsaved_changes"), message: localizedString("unsaved_changes_will_be_lost"), preferredStyle: .actionSheet) - let discardAction = UIAlertAction(title: "Discard", style: .destructive) { [weak self] _ in + let discardAction = UIAlertAction(title: localizedString("shared_string_discard_changes"), style: .destructive) { [weak self] _ in guard let self else { return } self.dismiss(animated: true, completion: nil) } - let applyAction = UIAlertAction(title: localizedString("shared_string_apply"), style: .default) { [weak self] _ in - guard let self else { return } - self.onRightNavbarButtonPressed() + alertController.addAction(discardAction) + if folder == nil { + let applyAction = UIAlertAction(title: localizedString("shared_string_apply"), style: .default) { [weak self] _ in + guard let self else { return } + self.onRightNavbarButtonPressed() + } + alertController.addAction(applyAction) } let cancelAction = UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel, handler: nil) - alertController.addAction(discardAction) - alertController.addAction(applyAction) alertController.addAction(cancelAction) let popPresenter = alertController.popoverPresentationController popPresenter?.barButtonItem = navigationItem.leftBarButtonItem @@ -423,52 +478,149 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { } override func onRightNavbarButtonPressed() { + if folder == nil { + markSelectedAppearanceAsUsed() + let task = ChangeTracksAppearanceTask(data: data, items: tracks) { [weak self] in + self?.dismissAndRefreshMap() + } + task.execute() + } else { + showFolderDefaultAppearanceConfirmation() + } + } + + private static func buildAppearanceData(from item: GpxDirItem? = nil) -> AppearanceData { + let data = AppearanceData() + for parameter in GpxParameter.companion.getAppearanceParameters() { + let value: Any? = item?.getParameter(parameter: parameter) + data.setParameter(parameter, value: value) + } + + return data + } + + private func markSelectedAppearanceAsUsed() { if isSolidColorSelected, let selectedColorItem { appearanceCollection.selectColor(selectedColorItem) } else if isGradientColorSelected, let selectedPaletteColorItem { GradientPaletteHelper.shared.markPaletteItemAsUsed(selectedPaletteColorItem) } - let task = ChangeTracksAppearanceTask(data: data, items: tracks) { [weak self] in - guard let self else { return } - self.dismiss(animated: true) { - OsmAndApp.swiftInstance().updateGpxTracksOnMapObservable.notifyEvent() - } + } + + private func showFolderDefaultAppearanceConfirmation() { + guard hasAppearanceChanges() else { + dismissAndRefreshMap() + return } - task.execute() + let alertController = UIAlertController(title: localizedString("shared_string_save"), message: localizedString("change_default_tracks_appearance_confirmation"), preferredStyle: .actionSheet) + alertController.addAction(UIAlertAction(title: applyExistingTracksTitle(), style: .default) { [weak self] _ in + self?.saveFolderDefaultAppearance(updateExisting: true) + }) + alertController.addAction(UIAlertAction(title: localizedString("apply_only_to_new"), style: .default) { [weak self] _ in + self?.saveFolderDefaultAppearance(updateExisting: false) + }) + alertController.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + let popPresenter = alertController.popoverPresentationController + popPresenter?.barButtonItem = navigationItem.rightBarButtonItems?.first + popPresenter?.permittedArrowDirections = UIPopoverArrowDirection.any + present(alertController, animated: true, completion: nil) } - private static func buildAppearanceData() -> AppearanceData { - let data = AppearanceData() + private func saveFolderDefaultAppearance(updateExisting: Bool, dismissOnFinish: Bool = true) { + guard let dirItem else { return } + markSelectedAppearanceAsUsed() for parameter in GpxParameter.companion.getAppearanceParameters() { - data.setParameter(parameter, value: nil) + if data.shouldResetParameter(parameter) { + dirItem.setParameter(parameter: parameter, value: nil) + } else { + dirItem.setParameter(parameter: parameter, value: folderDefaultValue(for: parameter)) + } } - return data + GpxDbHelper.shared.updateDataItem(item: dirItem) + if updateExisting { + let task = ChangeTracksAppearanceTask(data: data, items: tracks) { [weak self] in + guard let self else { return } + if dismissOnFinish { + self.dismissAndRefreshMap() + } else { + self.onExistingTracksAppearanceApplied() + } + } + task.execute() + } else { + if dismissOnFinish { + dismissAndRefreshMap() + } else { + onExistingTracksAppearanceApplied() + } + } + } + + private func onExistingTracksAppearanceApplied() { + initialData = Self.buildAppearanceData(from: dirItem) + data = AppearanceData(data: initialData) + updateData() + OsmAndApp.swiftInstance().updateGpxTracksOnMapObservable.notifyEvent() + OAUtilities.showToast(localizedString("settings_applied"), details: nil, duration: 4, verticalOffset: 50, in: view) + } + + private func applyExistingTracksTitle() -> String { + String(format: localizedString("ltr_or_rtl_combine_via_space"), localizedString("apply_to_existing"), "(\(tracks.count))") + } + + private func dismissAndRefreshMap() { + dismiss(animated: true) { + OsmAndApp.swiftInstance().updateGpxTracksOnMapObservable.notifyEvent() + } } private func configureShowArrows() { + if folder != nil { + selectedShowArrows = booleanNumber(for: .showArrows)?.boolValue + return + } + selectedShowArrows = preselectParameter(in: tracks) { $0.showArrows } initialData.setParameter(.showArrows, value: selectedShowArrows) data.setParameter(.showArrows, value: selectedShowArrows) } private func configureShowStartFinish() { + if folder != nil { + selectedShowStartFinish = booleanNumber(for: .showStartFinish)?.boolValue + return + } + selectedShowStartFinish = preselectParameter(in: tracks) { $0.showStartFinish } initialData.setParameter(.showStartFinish, value: selectedShowStartFinish) data.setParameter(.showStartFinish, value: selectedShowStartFinish) } private func configureColorType() { - guard let typeStr = preselectParameter(in: tracks, extractor: { $0.coloringType }) else { return } - let normalizedTypeStr = ColoringType.routeStatisticsAttributesStrings.contains(typeStr) ? typeStr.replacingOccurrences(of: "routeInfo", with: "route_info") : typeStr + let typeStr: String? + if folder != nil { + typeStr = data.getParameter(for: .coloringType) + } else { + typeStr = preselectParameter(in: tracks, extractor: { $0.coloringType }) + } + guard let typeStr else { return } + let normalizedTypeStr = normalizedColoringType(typeStr) selectedColorType = ColoringType.companion.valueOf(purpose: .track, name: normalizedTypeStr, defaultValue: .trackSolid) - initialData.setParameter(.coloringType, value: selectedColorType?.id) - data.setParameter(.coloringType, value: selectedColorType?.id) + if folder == nil { + initialData.setParameter(.coloringType, value: selectedColorType?.id) + data.setParameter(.coloringType, value: selectedColorType?.id) + } guard let type = selectedColorType else { return } switch type { case .trackSolid: - if preselectParameter(in: tracks, extractor: { $0.color }) != nil { + if folder != nil { + let color = intValue(for: .color) + configureLineColors(color: color, updateAppearanceData: false) + isColorSelected = true + isSolidColorSelected = true + } else if preselectParameter(in: tracks, extractor: { $0.color }) != nil { configureLineColors() isColorSelected = true isSolidColorSelected = true @@ -482,7 +634,12 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { isGradientColorSelected = false isRouteAttributeTypeSelected = false case .speed, .altitude, .slope: - if preselectParameter(in: tracks, extractor: { $0.gradientPaletteName }) != nil { + if folder != nil { + let paletteName: String? = data.getParameter(for: .colorPalette) + configureGradientColors(paletteName: paletteName, updateAppearanceData: false) + isColorSelected = true + isGradientColorSelected = true + } else if preselectParameter(in: tracks, extractor: { $0.gradientPaletteName }) != nil { configureGradientColors() isColorSelected = true isGradientColorSelected = true @@ -506,8 +663,10 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { } } - private func configureLineColors() { - if let trackColor = tracks.first?.color { + private func configureLineColors(color: Int? = nil, updateAppearanceData: Bool = true) { + if let color { + selectedColorItem = appearanceCollection.getColorItem(withValue: Int32(color)) + } else if folder == nil, let trackColor = tracks.first?.color { selectedColorItem = appearanceCollection.getColorItem(withValue: Int32(trackColor)) } else { selectedColorItem = appearanceCollection.defaultLineColorItem() @@ -515,25 +674,37 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { sortedColorItems = Array(appearanceCollection.getAvailableColorsSortingByLastUsed() ?? []) let selectedColor = Int(selectedColorItem?.colorInt ?? 0) - initialData.setParameter(.color, value: KotlinInt(integerLiteral: selectedColor)) - data.setParameter(.color, value: KotlinInt(integerLiteral: selectedColor)) - initialData.setParameter(.colorPalette, value: PaletteConstants.shared.DEFAULT_NAME) - data.setParameter(.colorPalette, value: PaletteConstants.shared.DEFAULT_NAME) + if updateAppearanceData { + initialData.setParameter(.color, value: KotlinInt(integerLiteral: selectedColor)) + data.setParameter(.color, value: KotlinInt(integerLiteral: selectedColor)) + initialData.setParameter(.colorPalette, value: PaletteConstants.shared.DEFAULT_NAME) + data.setParameter(.colorPalette, value: PaletteConstants.shared.DEFAULT_NAME) + } } - private func configureGradientColors() { + private func configureGradientColors(paletteName: String? = nil, updateAppearanceData: Bool = true) { let gradientScaleType = selectedColorType?.toGradientScaleType() let paletteItems = GradientPaletteHelper.shared.paletteItems(gradientScaleType: gradientScaleType, sortMode: .lastUsedTime) sortedPaletteColorItems.replaceAll(withObjectsSync: paletteItems) - selectedPaletteColorItem = GradientPaletteHelper.shared.paletteItemOrDefault(gradientScaleType: gradientScaleType, name: tracks.first?.gradientPaletteName) - if let selectedPaletteColorItem { + let selectedPaletteName = paletteName ?? (folder == nil ? tracks.first?.gradientPaletteName : nil) + selectedPaletteColorItem = GradientPaletteHelper.shared.paletteItemOrDefault(gradientScaleType: gradientScaleType, name: selectedPaletteName) + if (updateAppearanceData || paletteName?.isEmpty == false), let selectedPaletteColorItem { initialData.setParameter(.colorPalette, value: selectedPaletteColorItem.id) data.setParameter(.colorPalette, value: selectedPaletteColorItem.id) } } private func configureWidth() { - selectedWidth = preselectParameter(in: tracks) { appearanceCollection.getWidthForValue($0.width) } + if folder != nil { + let width: String? = data.getParameter(for: .width) + selectedWidth = width.flatMap { appearanceCollection.getWidthForValue($0) } + } else { + selectedWidth = preselectParameter(in: tracks) { + guard let width = $0.dataItem?.getParameter(parameter: .width) as? String, !width.isEmpty else { return nil } + return appearanceCollection.getWidthForValue(width) + } + } + let minValue = OAGPXTrackWidth.getCustomTrackWidthMin() let maxValue = OAGPXTrackWidth.getCustomTrackWidthMax() customWidthValues = (minValue...maxValue).map { "\($0)" } @@ -541,27 +712,32 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { isWidthSelected = true isCustomWidthSelected = false let widthString = width.isCustom() ? width.customValue : width.key - initialData.setParameter(.width, value: widthString) - data.setParameter(.width, value: widthString) - switch width.key { - case WidthKeys.thin.rawValue: - selectedWidthIndex = 0 - case WidthKeys.medium.rawValue: - selectedWidthIndex = 1 - case WidthKeys.bold.rawValue: - selectedWidthIndex = 2 - default: - isCustomWidthSelected = true - selectedWidthIndex = 3 + if folder == nil { + initialData.setParameter(.width, value: widthString) + data.setParameter(.width, value: widthString) } + + selectedWidthIndex = widthSegmentIndex(for: width) + isCustomWidthSelected = width.isCustom() } private func configureSplitInterval() { - selectedSplit = preselectParameter(in: tracks) { appearanceCollection.getSplitInterval(for: $0.splitType) } - if let firstTrack = tracks.first { + let splitInterval: Double? + if folder != nil { + guard let splitTypeValue = intValue(for: .splitType), let splitType = EOAGpxSplitType(rawValue: splitTypeValue) else { return } + selectedSplit = appearanceCollection.getSplitInterval(for: splitType) + splitInterval = doubleValue(for: .splitInterval) + } else { + selectedSplit = preselectParameter(in: tracks) { appearanceCollection.getSplitInterval(for: $0.splitType) } + splitInterval = tracks.first?.splitInterval + } + + if folder == nil, let firstTrack = tracks.first { if firstTrack.splitInterval > 0 && firstTrack.splitType != EOAGpxSplitType.none { selectedSplit?.customValue = selectedSplit?.titles[(selectedSplit?.values.firstIndex { ($0).doubleValue == Double(firstTrack.splitInterval) }) ?? 0] } + } else if let splitInterval, splitInterval > 0, selectedSplit?.type != EOAGpxSplitType.none { + selectedSplit?.customValue = selectedSplit?.titles[(selectedSplit?.values.firstIndex { ($0).doubleValue == splitInterval }) ?? 0] } if let selectedSplit { @@ -581,24 +757,101 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { } let splitTypeValue = Int32(selectedSplit.type.rawValue) - initialData.setParameter(.splitType, value: splitTypeValue) - data.setParameter(.splitType, value: splitTypeValue) + if folder == nil { + initialData.setParameter(.splitType, value: splitTypeValue) + data.setParameter(.splitType, value: splitTypeValue) + } if selectedSplit.isCustom(), let customValue = selectedSplit.customValue, let customIndex = selectedSplit.titles.firstIndex(of: customValue) { let intervalValue = selectedSplit.values[customIndex].doubleValue - initialData.setParameter(.splitInterval, value: intervalValue) - data.setParameter(.splitInterval, value: intervalValue) + if folder == nil { + initialData.setParameter(.splitInterval, value: intervalValue) + data.setParameter(.splitInterval, value: intervalValue) + } } else { - initialData.setParameter(.splitInterval, value: 0) - data.setParameter(.splitInterval, value: 0) + if folder == nil { + initialData.setParameter(.splitInterval, value: 0) + data.setParameter(.splitInterval, value: 0) + } } } } + private func widthSegmentIndex(for width: OAGPXTrackWidth) -> Int { + switch width.key { + case WidthKeys.thin.rawValue: + return 0 + case WidthKeys.medium.rawValue: + return 1 + case WidthKeys.bold.rawValue: + return 2 + default: + return 3 + } + } + + private func intValue(for parameter: GpxParameter) -> Int? { + switch data.rawParameter(for: parameter) { + case let value as Int: return value + case let value as Int32: return Int(value) + case let value as NSNumber: return value.intValue + default: return nil + } + } + + private func doubleValue(for parameter: GpxParameter) -> Double? { + switch data.rawParameter(for: parameter) { + case let value as Double: return value + case let value as NSNumber: return value.doubleValue + default: return nil + } + } + + private func booleanNumber(for parameter: GpxParameter) -> NSNumber? { + switch data.rawParameter(for: parameter) { + case let value as Bool: return NSNumber(value: value) + case let value as NSNumber: return value + default: return nil + } + } + + private func folderDefaultValue(for parameter: GpxParameter) -> Any? { + switch parameter { + case .color, .splitType: + return intValue(for: parameter).map { KotlinInt(integerLiteral: $0) } + case .showArrows, .showStartFinish: + return booleanNumber(for: parameter).map { KotlinBoolean(bool: $0.boolValue) } + case .splitInterval: + return doubleValue(for: parameter) + default: + return data.rawParameter(for: parameter) + } + } + + private func hasAppearanceChanges() -> Bool { + guard folder != nil else { return data != initialData } + for parameter in GpxParameter.companion.getAppearanceParameters() { + switch (initialData.rawParameter(for: parameter), data.rawParameter(for: parameter)) { + case (nil, nil): + continue + case let (lhs as NSObject, rhs as NSObject) where lhs.isEqual(rhs): + continue + default: + return true + } + } + + return false + } + private func preselectParameter(in tracks: Set, extractor: (TrackItem) -> T?) -> T? { guard let firstTrack = tracks.first, let firstValue = extractor(firstTrack) else { return nil } return tracks.allSatisfy { extractor($0) == firstValue } ? firstValue : nil } + private func normalizedColoringType(_ type: String) -> String { + ColoringType.routeStatisticsAttributesStrings.contains(type) ? type.replacingOccurrences(of: "routeInfo", with: "route_info") : type + } + private func createStateSelectionMenu(for key: String) -> UIMenu { switch key { case RowKey.directionArrowsRowKey.rawValue: @@ -623,23 +876,44 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { private func updateSection(containingRowKey key: RowKey) { generateData() - guard let matchingSection = (0..= 0, index < widths.count else { return } - let width = widths[index] + let width: OAGPXTrackWidth? + switch index { + case 0: + width = appearanceCollection.getWidthForValue(WidthKeys.thin.rawValue) + case 1: + width = appearanceCollection.getWidthForValue(WidthKeys.medium.rawValue) + case 2: + width = appearanceCollection.getWidthForValue(WidthKeys.bold.rawValue) + default: + let customValue = selectedWidth?.isCustom() == true ? selectedWidth?.customValue : customWidthValues.first + width = customValue.flatMap { appearanceCollection.getWidthForValue($0) } + } + + guard let width else { return } selectedWidth = width let isCustom = width.isCustom() let widthString = isCustom ? width.customValue : width.key data.setParameter(.width, value: widthString) isWidthSelected = true isCustomWidthSelected = isCustom - selectedWidthIndex = index + selectedWidthIndex = widthSegmentIndex(for: width) updateSection(containingRowKey: .widthRowKey) } @@ -692,6 +966,7 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { let item = tableData.item(for: indexPath) guard let cell = tableView.cellForRow(at: indexPath) as? OASegmentSliderTableViewCell else { return } let selectedIndex = Int(cell.sliderView.selectedMark) + let rowKey: RowKey if item.key == RowKey.customWidthModesRowKey.rawValue { guard let customWidthValues = item.obj(forKey: Self.widthArrayValue) as? [String], selectedIndex >= 0, selectedIndex < customWidthValues.count else { return } let selectedValue = customWidthValues[selectedIndex] @@ -699,6 +974,7 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { w.customValue = selectedValue } data.setParameter(.width, value: selectedValue) + rowKey = .widthRowKey } else if item.key == RowKey.customSplitIntervalRowKey.rawValue { guard let splitTitles = item.obj(forKey: Self.widthArrayValue) as? [String], selectedIndex >= 0, selectedIndex < splitTitles.count else { return } let selectedValue = splitTitles[selectedIndex] @@ -708,10 +984,12 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { data.setParameter(.splitInterval, value: split.values[customIndex].doubleValue) } } + rowKey = .splitIntervalRowKey + } else { + return } - generateData() - tableView.reloadRows(at: [indexPath], with: .none) + updateSection(containingRowKey: rowKey) } @objc private func productPurchased(_: Notification) { @@ -733,39 +1011,45 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { extension TracksChangeAppearanceViewController { private func createArrowsMenu() -> UIMenu { - return createBooleanSelectionMenu(currentValue: selectedShowArrows, parameter: .showArrows) { [weak self] newValue in + return createBooleanSelectionMenu(currentValue: selectedShowArrows, parameter: .showArrows, rowKey: .directionArrowsRowKey) { [weak self] newValue in self?.selectedShowArrows = newValue } } private func createStartFinishMenu() -> UIMenu { - return createBooleanSelectionMenu(currentValue: selectedShowStartFinish, parameter: .showStartFinish) { [weak self] newValue in + return createBooleanSelectionMenu(currentValue: selectedShowStartFinish, parameter: .showStartFinish, rowKey: .startFinishIconsRowKey) { [weak self] newValue in self?.selectedShowStartFinish = newValue } } - private func createBooleanSelectionMenu(currentValue: Bool?, parameter: GpxParameter, update: @escaping (Bool?) -> Void) -> UIMenu { + private func createBooleanSelectionMenu(currentValue: Bool?, parameter: GpxParameter, rowKey: RowKey, update: @escaping (Bool?) -> Void) -> UIMenu { let isReset = data.shouldResetParameter(parameter) + let isOriginal = isReset || (folder != nil && currentValue == nil) let unchangedAction = UIAction(title: localizedString("shared_string_unchanged"), state: !isReset && currentValue == nil ? .on : .off) { [weak self] _ in guard let self else { return } self.data.setParameter(parameter, value: nil) update(nil) + self.updateSection(containingRowKey: rowKey) } - let originalAction = UIAction(title: localizedString("simulate_location_movement_speed_original"), state: isReset ? .on : .off) { [weak self] _ in + let originalAction = UIAction(title: localizedString("simulate_location_movement_speed_original"), state: isOriginal ? .on : .off) { [weak self] _ in guard let self else { return } self.data.resetParameter(parameter) + update(nil) + self.updateSection(containingRowKey: rowKey) } - let unchangedOriginalMenu = inlineMenu(withActions: [unchangedAction, originalAction]) + let unchangedOriginalMenu = inlineMenu(withActions: folder == nil ? [unchangedAction, originalAction] : [originalAction]) let onAction = UIAction(title: localizedString("shared_string_on"), state: !isReset && currentValue == true ? .on : .off) { [weak self] _ in guard let self else { return } self.data.setParameter(parameter, value: true) update(true) + self.updateSection(containingRowKey: rowKey) } let offAction = UIAction(title: localizedString("shared_string_off"), state: !isReset && currentValue == false ? .on : .off) { [weak self] _ in guard let self else { return } self.data.setParameter(parameter, value: false) update(false) + self.updateSection(containingRowKey: rowKey) } let onOffMenu = inlineMenu(withActions: [onAction, offAction]) @@ -775,30 +1059,34 @@ extension TracksChangeAppearanceViewController { private func createColoringMenu() -> UIMenu { let isReset = data.shouldResetParameter(.coloringType) let isRouteInfoAttribute = selectedColorType?.isRouteInfoAttribute() ?? false + let isOriginal = isReset || (folder != nil && selectedColorType == nil) let unchangedAction = UIAction(title: localizedString("shared_string_unchanged"), state: !isReset && selectedColorType == nil ? .on : .off) { [weak self] _ in guard let self else { return } self.data.setParameter(.coloringType, value: nil) + self.data.setParameter(.color, value: nil) + self.data.setParameter(.colorPalette, value: nil) self.selectedColorType = nil self.isRouteAttributeTypeSelected = false self.resetColorSelectionFlags() self.updateSection(containingRowKey: .coloringRowKey) } - let originalAction = UIAction(title: localizedString("simulate_location_movement_speed_original"), state: isReset ? .on : .off) { [weak self] _ in + let originalAction = UIAction(title: localizedString("simulate_location_movement_speed_original"), state: isOriginal ? .on : .off) { [weak self] _ in guard let self else { return } self.data.resetParameter(.coloringType) self.data.resetParameter(.color) + self.data.resetParameter(.colorPalette) self.selectedColorType = nil self.isRouteAttributeTypeSelected = false self.resetColorSelectionFlags() self.updateSection(containingRowKey: .coloringRowKey) } - let unchangedOriginalMenu = inlineMenu(withActions: [unchangedAction, originalAction]) + let unchangedOriginalMenu = inlineMenu(withActions: folder == nil ? [unchangedAction, originalAction] : [originalAction]) let solidColorAction = UIAction(title: localizedString("track_coloring_solid"), state: !isReset && selectedColorType == .trackSolid ? .on : .off) { [weak self] _ in guard let self else { return } self.data.setParameter(.coloringType, value: ColoringType.trackSolid.id) self.selectedColorType = .trackSolid - self.configureLineColors() + self.configureLineColors(updateAppearanceData: self.folder == nil) self.resetColorSelectionFlags() self.isColorSelectionEnabled(true, solid: true) self.updateSection(containingRowKey: .coloringRowKey) @@ -809,7 +1097,7 @@ extension TracksChangeAppearanceViewController { guard let self else { return } self.data.setParameter(.coloringType, value: ColoringType.altitude.id) self.selectedColorType = .altitude - self.configureGradientColors() + self.configureGradientColors(updateAppearanceData: self.folder == nil) self.resetColorSelectionFlags() self.isColorSelectionEnabled(true, solid: false) self.updateSection(containingRowKey: .coloringRowKey) @@ -818,7 +1106,7 @@ extension TracksChangeAppearanceViewController { guard let self else { return } self.data.setParameter(.coloringType, value: ColoringType.speed.id) self.selectedColorType = .speed - self.configureGradientColors() + self.configureGradientColors(updateAppearanceData: self.folder == nil) self.resetColorSelectionFlags() self.isColorSelectionEnabled(true, solid: false) self.updateSection(containingRowKey: .coloringRowKey) @@ -827,7 +1115,7 @@ extension TracksChangeAppearanceViewController { guard let self else { return } self.data.setParameter(.coloringType, value: ColoringType.slope.id) self.selectedColorType = .slope - self.configureGradientColors() + self.configureGradientColors(updateAppearanceData: self.folder == nil) self.resetColorSelectionFlags() self.isColorSelectionEnabled(true, solid: false) self.updateSection(containingRowKey: .coloringRowKey) @@ -876,6 +1164,7 @@ extension TracksChangeAppearanceViewController { private func createWidthMenu() -> UIMenu { let paramValue: String? = selectedWidth?.isCustom() == true ? selectedWidth?.customValue : selectedWidth?.key let isReset = data.shouldResetParameter(.width) + let isOriginal = isReset || (folder != nil && paramValue == nil) let fixedWidths: [(titleKey: String, widthKey: String, index: Int)] = [("rendering_value_thin_name", "thin", 0), ("rendering_value_medium_w_name", "medium", 1), ("rendering_value_bold_name", "bold", 2)] let unchangedAction = UIAction(title: localizedString("shared_string_unchanged"), state: (!isReset && paramValue == nil) ? .on : .off) { [weak self] _ in guard let self else { return } @@ -885,14 +1174,15 @@ extension TracksChangeAppearanceViewController { self.isCustomWidthSelected = false self.updateSection(containingRowKey: .widthRowKey) } - let originalAction = UIAction(title: localizedString("simulate_location_movement_speed_original"), state: isReset ? .on : .off) { [weak self] _ in + let originalAction = UIAction(title: localizedString("simulate_location_movement_speed_original"), state: isOriginal ? .on : .off) { [weak self] _ in guard let self else { return } self.data.resetParameter(.width) + self.selectedWidth = nil self.isWidthSelected = false self.isCustomWidthSelected = false self.updateSection(containingRowKey: .widthRowKey) } - let unchangedOriginalMenu = inlineMenu(withActions: [unchangedAction, originalAction]) + let unchangedOriginalMenu = inlineMenu(withActions: folder == nil ? [unchangedAction, originalAction] : [originalAction]) let fixedWidthActions = fixedWidths.map { item in return UIAction(title: localizedString(item.titleKey), state: (!isReset && paramValue == item.widthKey) ? .on : .off) { [weak self] _ in @@ -902,7 +1192,8 @@ extension TracksChangeAppearanceViewController { } let widthMenu = inlineMenu(withActions: fixedWidthActions) - let customAction = UIAction(title: localizedString("shared_string_custom"), state: (!isReset && paramValue == selectedWidth?.customValue) ? .on : .off) { [weak self] _ in + let isCustomSelected = !isReset && paramValue != nil && selectedWidth?.isCustom() == true && paramValue == selectedWidth?.customValue + let customAction = UIAction(title: localizedString("shared_string_custom"), state: isCustomSelected ? .on : .off) { [weak self] _ in guard let self else { return } self.handleWidthSelection(index: 3) } @@ -912,11 +1203,11 @@ extension TracksChangeAppearanceViewController { } private func createSplitIntervalMenu() -> UIMenu { - let paramSplitType: Int32? = selectedSplit.map { Int32($0.type.rawValue) } ?? data.getParameter(for: .splitType) + let paramSplitType: Int32? = selectedSplit.map { Int32($0.type.rawValue) } ?? (folder == nil ? data.getParameter(for: .splitType) : nil) let isResetSplitType = data.shouldResetParameter(.splitType) let isResetSplitInterval = data.shouldResetParameter(.splitInterval) let isUnchanged = paramSplitType == nil && !isResetSplitType && !isResetSplitInterval - let isOriginalSelected = isResetSplitType && isResetSplitInterval + let isOriginalSelected = (isResetSplitType && isResetSplitInterval) || (folder != nil && paramSplitType == nil) let currentType = paramSplitType ?? 0 let isNoSplit = paramSplitType != nil && currentType == GpxSplitType.noSplit.type let isTime = currentType == GpxSplitType.time.type @@ -935,11 +1226,12 @@ extension TracksChangeAppearanceViewController { guard let self else { return } self.data.resetParameter(.splitType) self.data.resetParameter(.splitInterval) + self.selectedSplit = nil self.isSplitIntervalSelected = false self.isSplitIntervalNoneSelected = false self.updateSection(containingRowKey: .splitIntervalRowKey) } - let unchangedOriginalMenu = inlineMenu(withActions: [unchangedAction, originalAction]) + let unchangedOriginalMenu = inlineMenu(withActions: folder == nil ? [unchangedAction, originalAction] : [originalAction]) let noSplitAction = UIAction(title: localizedString("shared_string_none"), state: isNoSplit ? .on : .off) { [weak self] _ in guard let self else { return } @@ -981,6 +1273,9 @@ extension TracksChangeAppearanceViewController: OACollectionCellDelegate { selectedPaletteColorItem = picked data.setParameter(.colorPalette, value: picked.id) } + if collectionView != nil { + updateSection(containingRowKey: .coloringRowKey) + } } func reloadCollectionData() { @@ -990,7 +1285,11 @@ extension TracksChangeAppearanceViewController: OACollectionCellDelegate { selectedItem = selectedColor } if selectedItem == nil { - if let trackColor = tracks.first?.color { + if folder != nil, let color = intValue(for: .color) { + selectedItem = appearanceCollection.getColorItem(withValue: Int32(color)) + } else if folder != nil { + selectedItem = appearanceCollection.defaultLineColorItem() + } else if let trackColor = tracks.first?.color { selectedItem = appearanceCollection.getColorItem(withValue: Int32(trackColor)) } else { selectedItem = appearanceCollection.defaultLineColorItem() diff --git a/Sources/Controllers/MyPlaces/TracksViewController.swift b/Sources/Controllers/MyPlaces/TracksViewController.swift index f368e4312e..91e34e7c00 100644 --- a/Sources/Controllers/MyPlaces/TracksViewController.swift +++ b/Sources/Controllers/MyPlaces/TracksViewController.swift @@ -1252,7 +1252,7 @@ final class TracksViewController: UITableViewController, OATrackSavingHelperUpda let trackItems = Set(allTracks.toTrackItems()) guard !trackItems.isEmpty else { return } - let vc = TracksChangeAppearanceViewController(tracks: trackItems) + let vc = TracksChangeAppearanceViewController(mode: .tracks(trackItems)) let navigationController = UINavigationController(rootViewController: vc) navigationController.modalPresentationStyle = .custom present(navigationController, animated: true) { [weak self] in @@ -1458,6 +1458,14 @@ final class TracksViewController: UITableViewController, OATrackSavingHelperUpda present(alert, animated: true) } + private func onFolderDefaultAppearanceButtonClicked(_ selectedFolderName: String) { + guard let selectedFolder = currentFolder.getSubFolders().first(where: { $0.getDirName(includingSubdirs: false) == selectedFolderName }) else { return } + let vc = TracksChangeAppearanceViewController(mode: .folder(selectedFolder)) + let navigationController = UINavigationController(rootViewController: vc) + navigationController.modalPresentationStyle = .custom + present(navigationController, animated: true) + } + private func onFolderExportButtonClicked(_ selectedFolderName: String) { if let selectedFolder = currentFolder.getSubFolders().first(where: { $0.getDirName(includingSubdirs: false) == selectedFolderName }) { let exportFilePaths = selectedFolder.getTrackItems().compactMap({ $0.path }) @@ -2382,9 +2390,8 @@ final class TracksViewController: UITableViewController, OATrackSavingHelperUpda override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { let item = tableData.item(for: indexPath) if item.key == tracksFolderKey || item.key == tracksSmartFolderKey { - + let isTracksFolder = item.key == tracksFolderKey let selectedFolderName = item.title ?? "" - let menuProvider: UIContextMenuActionProvider = { [weak self] _ in // TODO: implement Folder Details in next task https://github.com/osmandapp/OsmAnd-Issues/issues/2348 @@ -2396,7 +2403,15 @@ final class TracksViewController: UITableViewController, OATrackSavingHelperUpda let renameAction = UIAction(title: localizedString("shared_string_rename"), image: .icCustomEdit) { _ in self?.onFolderRenameButtonClicked(selectedFolderName) } - let secondButtonsSection = UIMenu(title: "", options: .displayInline, children: [renameAction]) + let secondButtonsSection: UIMenu + if isTracksFolder { + let defaultAppearanceAction = UIAction(title: localizedString("default_appearance"), image: .icCustomAppearanceOutlined) { _ in + self?.onFolderDefaultAppearanceButtonClicked(selectedFolderName) + } + secondButtonsSection = UIMenu(title: "", options: .displayInline, children: [renameAction, defaultAppearanceAction]) + } else { + secondButtonsSection = UIMenu(title: "", options: .displayInline, children: [renameAction]) + } let exportAction = UIAction(title: localizedString("shared_string_export"), image: .icCustomExportOutlined) { _ in self?.onFolderExportButtonClicked(selectedFolderName) diff --git a/Sources/Controllers/RoutePlanning/OASaveGpxRouteAsyncTask.mm b/Sources/Controllers/RoutePlanning/OASaveGpxRouteAsyncTask.mm index 674094cc77..eb9ae32ef3 100644 --- a/Sources/Controllers/RoutePlanning/OASaveGpxRouteAsyncTask.mm +++ b/Sources/Controllers/RoutePlanning/OASaveGpxRouteAsyncTask.mm @@ -159,6 +159,7 @@ - (void)saveGpxToDatabase gpx.splitInterval = oldGpx.splitInterval; gpx.creationDate = oldGpx.creationDate; } + [gpxDb updateDataItem:gpx]; } diff --git a/Sources/GPX/GpxAppearanceInfo.swift b/Sources/GPX/GpxAppearanceInfo.swift index c1096f5b00..7bf55d1b24 100644 --- a/Sources/GPX/GpxAppearanceInfo.swift +++ b/Sources/GPX/GpxAppearanceInfo.swift @@ -91,14 +91,14 @@ final class GpxAppearanceInfo: NSObject { convenience init(dataItem: GpxDataItem) { self.init() - self.color = dataItem.color - self.width = dataItem.width - self.showArrows = dataItem.showArrows - self.showStartFinish = dataItem.showStartFinish - self.splitType = dataItem.splitType - self.splitInterval = dataItem.splitInterval - self.coloringType = dataItem.coloringType - self.gradientPaletteName = dataItem.gradientPaletteName + self.color = dataItem.getParameter(parameter: .color) as? Int ?? 0 + self.width = dataItem.getParameter(parameter: .width) as? String + self.showArrows = dataItem.getParameter(parameter: .showArrows) as? Bool ?? false + self.showStartFinish = dataItem.getParameter(parameter: .showStartFinish) as? Bool ?? false + self.splitType = EOAGpxSplitType(rawValue: dataItem.getParameter(parameter: .splitType) as? Int ?? -1) ?? .none + self.splitInterval = dataItem.getParameter(parameter: .splitInterval) as? Double ?? 0 + self.coloringType = dataItem.getParameter(parameter: .coloringType) as? String + self.gradientPaletteName = dataItem.getParameter(parameter: .colorPalette) as? String self.trackVisualizationType = dataItem.visualization3dByType self.trackWallColorType = dataItem.visualization3dWallColorType diff --git a/Sources/Helpers/OAGPXImportUIHelper.mm b/Sources/Helpers/OAGPXImportUIHelper.mm index 27e27fd263..6634ab7728 100644 --- a/Sources/Helpers/OAGPXImportUIHelper.mm +++ b/Sources/Helpers/OAGPXImportUIHelper.mm @@ -372,11 +372,18 @@ - (OASGpxDataItem *)doImport return nil; } - OASGpxDataItem *item = [[OAGPXDatabase sharedDb] addGPXFileToDBIfNeeded:gpxPath]; + OASKFile *file = [[OASKFile alloc] initWithFilePath:gpxPath]; + OASGpxDataItem *item = [[OASGpxDataItem alloc] initWithFile:file]; [item updateAppearance]; + OASGpxDbHelper *gpxDbHelper = [OASGpxDbHelper shared]; + if ([gpxDbHelper hasGpxDataItemFile:file]) + [gpxDbHelper updateDataItemItem:item]; + else + [gpxDbHelper addItem:item]; + if (item.color != 0) - [[OAGPXAppearanceCollection sharedInstance] getColorItemWithValue:item.color]; + [[OAGPXAppearanceCollection sharedInstance] getColorItemWithValue:(int)item.color]; [OAUtilities denyAccessToFile:_importUrl.path removeFromInbox:YES]; diff --git a/Sources/Helpers/OAGPXUIHelper.mm b/Sources/Helpers/OAGPXUIHelper.mm index 71b2a94611..f0577aff48 100644 --- a/Sources/Helpers/OAGPXUIHelper.mm +++ b/Sources/Helpers/OAGPXUIHelper.mm @@ -596,6 +596,7 @@ - (void)copyGPXToNewFolder:(NSString *)newFolderName } } [OASelectedGPXHelper renameVisibleTrack:oldPath newPath:newStoringPath]; + [OsmAndApp.instance.updateGpxTracksOnMapObservable notifyEvent]; } else { diff --git a/Sources/Helpers/SharedLibHelpers/GpxDataItemExtension.swift b/Sources/Helpers/SharedLibHelpers/GpxDataItemExtension.swift index f7898dcaec..eb0bb7bb33 100644 --- a/Sources/Helpers/SharedLibHelpers/GpxDataItemExtension.swift +++ b/Sources/Helpers/SharedLibHelpers/GpxDataItemExtension.swift @@ -170,7 +170,7 @@ extension GpxDataItem { var showArrows: Bool { get { - getParameter(parameter: .showArrows) as? Bool ?? false + appearanceParameter(.showArrows) as? Bool ?? GpxParameter.showArrows.defaultValue as? Bool ?? false } set { setParameter(parameter: .showArrows, value: newValue) @@ -179,7 +179,7 @@ extension GpxDataItem { var showStartFinish: Bool { get { - getParameter(parameter: .showStartFinish) as? Bool ?? false + appearanceParameter(.showStartFinish) as? Bool ?? GpxParameter.showStartFinish.defaultValue as? Bool ?? true } set { setParameter(parameter: .showStartFinish, value: newValue) @@ -239,7 +239,7 @@ extension GpxDataItem { var coloringType: String { get { - getParameter(parameter: .coloringType) as? String ?? "" + appearanceParameter(.coloringType) as? String ?? "" } set { setParameter(parameter: .coloringType, value: newValue) @@ -248,7 +248,7 @@ extension GpxDataItem { var width: String { get { - getParameter(parameter: .width) as? String ?? "" + appearanceParameter(.width) as? String ?? "" } set { setParameter(parameter: .width, value: newValue) @@ -257,7 +257,7 @@ extension GpxDataItem { var color: Int { get { - return getParameter(parameter: .color) as? Int ?? 0 + appearanceParameter(.color) as? Int ?? 0 } set { setParameter(parameter: .color, value: KotlinInt(integerLiteral: newValue)) @@ -266,7 +266,7 @@ extension GpxDataItem { var splitType: EOAGpxSplitType { get { - let value = getParameter(parameter: .splitType) as? Int ?? -1 + let value = appearanceParameter(.splitType) as? Int ?? -1 return EOAGpxSplitType(rawValue: value) ?? .none } set { @@ -276,7 +276,7 @@ extension GpxDataItem { var splitInterval: Double { get { - getParameter(parameter: .splitInterval) as? Double ?? 0.0 + appearanceParameter(.splitInterval) as? Double ?? 0.0 } set { setParameter(parameter: .splitInterval, value: newValue) @@ -294,13 +294,30 @@ extension GpxDataItem { var gradientPaletteName: String? { get { - getParameter(parameter: .colorPalette) as? String ?? "" + appearanceParameter(.colorPalette) as? String ?? "" } set { setParameter(parameter: .colorPalette, value: newValue) } } + @nonobjc private func appearanceParameter(_ parameter: GpxParameter) -> Any? { + let value: Any? + if let trackValue = getParameter(parameter: parameter) { + value = trackValue + } else if let parentFile = file.getParentFile() { + value = GpxDbHelper.shared.getGpxDirItem(file: parentFile).getParameter(parameter: parameter) + } else { + value = nil + } + + if parameter == .splitType, let splitType = value as? Int32 { + return Int(splitType) + } + + return value + } + private func convertTimestamp(_ timestamp: TimeInterval) -> Date { // Check if the timestamp is greater than 10 billion if timestamp > 10_000_000_000 { @@ -313,7 +330,7 @@ extension GpxDataItem { } } -@objc(OASGpxDataItem) +@objc(OASGpxDataItemAppearance) extension GpxDataItem { var gpxFileNameWithoutExtension: String { From 0d886ab1f8de099e9e89c215425a51d92d678305 Mon Sep 17 00:00:00 2001 From: vitaliy-sova-ios <38758123+vitaliy-sova-ios@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:25:18 +0300 Subject: [PATCH 31/47] Add support for GPX files with multiple tracks (#5464) * Add GPX multi-track import UI and task * ImportTracks: add folder selection & UI refactor * Add AttributedString description * Replace OAFolderCardsCell ObjC/xib with Swift cells Migrate folder cards UI from Objective-C + XIBs to native Swift components: add FolderCardCollectionViewCell.swift and FolderCardsCell.swift * Add track preview & stats to import flow * Add GPX import saving and waypoint selector * Refactor track preview renderer and import UI * Folder cards, and layout fixes * Improved code structure and readability * Show nearest footer conditionally; animate icon * Improve accessibility for folder, import and waypoints cells * Update project.pbxproj * Code review fixes * Swiftlint fix * Background GPX analysis and folder sizes * Code review fixes * Code review fixes * Code review fixes * Code review fixes * Code review fixes * Remove sd_dir_not_accessible --- OsmAnd.xcodeproj/project.pbxproj | 106 +- .../en.lproj/Localizable.strings | 12 + Sources/AppNavigation/MyPlacesNavigator.swift | 20 + .../Cells/FolderCardCollectionViewCell.swift | 70 ++ .../Controllers/Cells/FolderCardsCell.swift | 366 ++++++ .../Cells/OAFolderCardCollectionViewCell.h | 17 - .../Cells/OAFolderCardCollectionViewCell.m | 19 - Sources/Controllers/Cells/OAFolderCardsCell.h | 33 - Sources/Controllers/Cells/OAFolderCardsCell.m | 236 ---- .../Cells/TrackStatsTableCell.swift | 100 ++ .../Xibs/OAFolderCardCollectionViewCell.xib | 74 -- .../Cells/Xibs/OAFolderCardsCell.xib | 58 - .../Xibs/OAPointWithRegionTableViewCell.xib | 6 +- .../Import/ImportTracksViewController.swift | 1000 +++++++++++++++++ .../OATrackPreviewMapRenderer.h | 31 + .../OATrackPreviewMapRenderer.mm | 368 ++++++ .../TrackBitmapDrawer.swift | 121 ++ .../TrackPreviewColorHelper.swift | 47 + .../TrackPreviewManager.swift | 63 ++ .../TrackStubPreviewRenderer.swift | 154 +++ .../Import/SelectPointsViewController.swift | 636 +++++++++++ .../Import/Tasks/CollectTracksTask.swift | 174 +++ .../Import/Tasks/SaveGpxAsyncTask.swift | 197 ++++ .../Import/Tasks/SaveImportedGpxHelper.swift | 73 ++ .../Import/Tasks/SaveTracksTask.swift | 112 ++ .../MyPlacesContainerViewController.swift | 8 + .../MyPlaces/TracksViewController.swift | 45 + .../OAAddTrackFolderViewController.h | 2 + .../OAAddTrackFolderViewController.m | 7 +- .../OASaveTrackViewController.mm | 18 +- .../OASelectTrackFolderViewController.h | 2 + .../OASelectTrackFolderViewController.m | 1 + .../TargetMenu/OAEditPointViewController.mm | 20 +- Sources/Helpers/OAGPXImportUIHelper.mm | 37 +- Sources/OsmAnd Maps-Bridging-Header.h | 7 +- Sources/SwiftExtensions/Array+Extension.swift | 15 + 36 files changed, 3760 insertions(+), 495 deletions(-) create mode 100644 Sources/Controllers/Cells/FolderCardCollectionViewCell.swift create mode 100644 Sources/Controllers/Cells/FolderCardsCell.swift delete mode 100644 Sources/Controllers/Cells/OAFolderCardCollectionViewCell.h delete mode 100644 Sources/Controllers/Cells/OAFolderCardCollectionViewCell.m delete mode 100644 Sources/Controllers/Cells/OAFolderCardsCell.h delete mode 100644 Sources/Controllers/Cells/OAFolderCardsCell.m create mode 100644 Sources/Controllers/Cells/TrackStatsTableCell.swift delete mode 100644 Sources/Controllers/Cells/Xibs/OAFolderCardCollectionViewCell.xib delete mode 100644 Sources/Controllers/Cells/Xibs/OAFolderCardsCell.xib create mode 100644 Sources/Controllers/MyPlaces/Import/ImportTracksViewController.swift create mode 100644 Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/OATrackPreviewMapRenderer.h create mode 100644 Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/OATrackPreviewMapRenderer.mm create mode 100644 Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackBitmapDrawer.swift create mode 100644 Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackPreviewColorHelper.swift create mode 100644 Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackPreviewManager.swift create mode 100644 Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackStubPreviewRenderer.swift create mode 100644 Sources/Controllers/MyPlaces/Import/SelectPointsViewController.swift create mode 100644 Sources/Controllers/MyPlaces/Import/Tasks/CollectTracksTask.swift create mode 100644 Sources/Controllers/MyPlaces/Import/Tasks/SaveGpxAsyncTask.swift create mode 100644 Sources/Controllers/MyPlaces/Import/Tasks/SaveImportedGpxHelper.swift create mode 100644 Sources/Controllers/MyPlaces/Import/Tasks/SaveTracksTask.swift create mode 100644 Sources/SwiftExtensions/Array+Extension.swift diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index ebe3caf12f..a11d08288f 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -1627,7 +1627,22 @@ CE4075A32FC48BD9004224AA /* BasePoiIconCollectionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4075A22FC48BD6004224AA /* BasePoiIconCollectionHandler.swift */; }; CE4075A52FC48C40004224AA /* ProfileIconCollectionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4075A42FC48C2F004224AA /* ProfileIconCollectionHandler.swift */; }; CE7818262FC07944005CCF47 /* WikipediaContextMenuCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7818252FC07944005CCF47 /* WikipediaContextMenuCell.swift */; }; + CE7AC1A82FE02A2600495411 /* OATrackPreviewMapRenderer.mm in Sources */ = {isa = PBXBuildFile; fileRef = CE7AC19A2FE02A2600495411 /* OATrackPreviewMapRenderer.mm */; }; + CE7AC1A92FE02A2600495411 /* TrackBitmapDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AC19B2FE02A2600495411 /* TrackBitmapDrawer.swift */; }; + CE7AC1AA2FE02A2600495411 /* TrackPreviewColorHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AC19C2FE02A2600495411 /* TrackPreviewColorHelper.swift */; }; + CE7AC1AB2FE02A2600495411 /* TrackPreviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AC19D2FE02A2600495411 /* TrackPreviewManager.swift */; }; + CE7AC1AC2FE02A2600495411 /* TrackStubPreviewRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AC19E2FE02A2600495411 /* TrackStubPreviewRenderer.swift */; }; + CE7AC1AD2FE02A2600495411 /* CollectTracksTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AC1A02FE02A2600495411 /* CollectTracksTask.swift */; }; + CE7AC1AE2FE02A2600495411 /* SaveGpxAsyncTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AC1A12FE02A2600495411 /* SaveGpxAsyncTask.swift */; }; + CE7AC1AF2FE02A2600495411 /* SaveImportedGpxHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AC1A22FE02A2600495411 /* SaveImportedGpxHelper.swift */; }; + CE7AC1B02FE02A2600495411 /* SaveTracksTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AC1A32FE02A2600495411 /* SaveTracksTask.swift */; }; + CE7AC1B12FE02A2600495411 /* ImportTracksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AC1A52FE02A2600495411 /* ImportTracksViewController.swift */; }; + CE7AC1B22FE02A2600495411 /* SelectPointsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AC1A62FE02A2600495411 /* SelectPointsViewController.swift */; }; CE8A82A92FCFE11F00EADFD8 /* MapVariantReplacementManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8A82A82FCFE11F00EADFD8 /* MapVariantReplacementManager.swift */; }; + CEAF08182FD91C4A0048A74A /* FolderCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEAF08162FD91C4A0048A74A /* FolderCardCollectionViewCell.swift */; }; + CEAF08192FD91C4A0048A74A /* FolderCardsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEAF08172FD91C4A0048A74A /* FolderCardsCell.swift */; }; + CEAF081B2FD9226C0048A74A /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEAF081A2FD9226C0048A74A /* Array+Extension.swift */; }; + CEDB71E52FD993C3009DAA0D /* TrackStatsTableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDB71E42FD993C3009DAA0D /* TrackStatsTableCell.swift */; }; D1A0B0012F50000100A0B001 /* OpeningHoursParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0AFFE2F50000100A0B001 /* OpeningHoursParserTest.swift */; }; D1A0B0032F50000100A0B001 /* OpeningHoursParserTestSupport.mm in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0002F50000100A0B001 /* OpeningHoursParserTestSupport.mm */; }; D1A0B0112F50001100A0B001 /* URLExtractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */; }; @@ -2286,7 +2301,6 @@ DA5A827926C563A700F274C7 /* OARadiusCellEx.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BCF26C563A100F274C7 /* OARadiusCellEx.xib */; }; DA5A827A26C563A700F274C7 /* OAHeaderRoundCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BD026C563A100F274C7 /* OAHeaderRoundCell.xib */; }; DA5A827B26C563A700F274C7 /* OATargetInfoCollapsableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BD126C563A100F274C7 /* OATargetInfoCollapsableViewCell.xib */; }; - DA5A827C26C563A700F274C7 /* OAFolderCardCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BD226C563A100F274C7 /* OAFolderCardCollectionViewCell.xib */; }; DA5A827D26C563A700F274C7 /* OAMultilineTextViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BD326C563A100F274C7 /* OAMultilineTextViewCell.xib */; }; DA5A827E26C563A700F274C7 /* OASearchMoreCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BD426C563A100F274C7 /* OASearchMoreCell.xib */; }; DA5A828026C563A700F274C7 /* OATargetInfoViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BD626C563A100F274C7 /* OATargetInfoViewCell.xib */; }; @@ -2301,7 +2315,6 @@ DA5A828D26C563A700F274C7 /* OABottomSheetHeaderIconCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BE326C563A100F274C7 /* OABottomSheetHeaderIconCell.xib */; }; DA5A828E26C563A700F274C7 /* OATextLineViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BE426C563A100F274C7 /* OATextLineViewCell.xib */; }; DA5A828F26C563A700F274C7 /* OAInAppCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BE526C563A100F274C7 /* OAInAppCell.xib */; }; - DA5A829126C563A700F274C7 /* OAFolderCardsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BE726C563A100F274C7 /* OAFolderCardsCell.xib */; }; DA5A829226C563A700F274C7 /* OAWaypointHeaderCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BE826C563A100F274C7 /* OAWaypointHeaderCell.xib */; }; DA5A829326C563A700F274C7 /* OADestinationCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BE926C563A100F274C7 /* OADestinationCollectionViewCell.xib */; }; DA5A829426C563A700F274C7 /* OAImageDescTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DA5A7BEA26C563A100F274C7 /* OAImageDescTableViewCell.xib */; }; @@ -2390,7 +2403,6 @@ DA5A830E26C563A800F274C7 /* OATitleIconRoundCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7C9C26C563A200F274C7 /* OATitleIconRoundCell.m */; }; DA5A831026C563A800F274C7 /* OARouteProgressBarCell.mm in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7C9E26C563A200F274C7 /* OARouteProgressBarCell.mm */; }; DA5A831126C563A800F274C7 /* OARouteInfoCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7C9F26C563A200F274C7 /* OARouteInfoCell.m */; }; - DA5A831226C563A800F274C7 /* OAFolderCardsCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7CA126C563A200F274C7 /* OAFolderCardsCell.m */; }; DA5A831426C563A800F274C7 /* OARouteInfoLegendCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7CA726C563A200F274C7 /* OARouteInfoLegendCell.m */; }; DA5A831726C563A800F274C7 /* OAHeaderRoundCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7CAC26C563A200F274C7 /* OAHeaderRoundCell.m */; }; DA5A831826C563A800F274C7 /* OATargetInfoViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7CAE26C563A200F274C7 /* OATargetInfoViewCell.m */; }; @@ -2420,7 +2432,6 @@ DA5A833A26C563A800F274C7 /* OAHomeWorkCell.mm in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7CE326C563A200F274C7 /* OAHomeWorkCell.mm */; }; DA5A833D26C563A800F274C7 /* OAPublicTransportPointCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7CE726C563A200F274C7 /* OAPublicTransportPointCell.m */; }; DA5A833E26C563A800F274C7 /* OAWaypointCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7CE926C563A200F274C7 /* OAWaypointCell.m */; }; - DA5A834026C563A800F274C7 /* OAFolderCardCollectionViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7CEF26C563A200F274C7 /* OAFolderCardCollectionViewCell.m */; }; DA5A834426C563A800F274C7 /* OATitleDescriptionIconRoundCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7CF726C563A200F274C7 /* OATitleDescriptionIconRoundCell.m */; }; DA5A834526C563A800F274C7 /* OAIconTextDescButtonCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7CF826C563A200F274C7 /* OAIconTextDescButtonCell.m */; }; DA5A834626C563A800F274C7 /* OADateTimePickerTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = DA5A7CFC26C563A300F274C7 /* OADateTimePickerTableViewCell.m */; }; @@ -5569,7 +5580,23 @@ CE4075A22FC48BD6004224AA /* BasePoiIconCollectionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasePoiIconCollectionHandler.swift; sourceTree = ""; }; CE4075A42FC48C2F004224AA /* ProfileIconCollectionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileIconCollectionHandler.swift; sourceTree = ""; }; CE7818252FC07944005CCF47 /* WikipediaContextMenuCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WikipediaContextMenuCell.swift; sourceTree = ""; }; + CE7AC1992FE02A2600495411 /* OATrackPreviewMapRenderer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OATrackPreviewMapRenderer.h; sourceTree = ""; }; + CE7AC19A2FE02A2600495411 /* OATrackPreviewMapRenderer.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OATrackPreviewMapRenderer.mm; sourceTree = ""; }; + CE7AC19B2FE02A2600495411 /* TrackBitmapDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackBitmapDrawer.swift; sourceTree = ""; }; + CE7AC19C2FE02A2600495411 /* TrackPreviewColorHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackPreviewColorHelper.swift; sourceTree = ""; }; + CE7AC19D2FE02A2600495411 /* TrackPreviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackPreviewManager.swift; sourceTree = ""; }; + CE7AC19E2FE02A2600495411 /* TrackStubPreviewRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackStubPreviewRenderer.swift; sourceTree = ""; }; + CE7AC1A02FE02A2600495411 /* CollectTracksTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectTracksTask.swift; sourceTree = ""; }; + CE7AC1A12FE02A2600495411 /* SaveGpxAsyncTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveGpxAsyncTask.swift; sourceTree = ""; }; + CE7AC1A22FE02A2600495411 /* SaveImportedGpxHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveImportedGpxHelper.swift; sourceTree = ""; }; + CE7AC1A32FE02A2600495411 /* SaveTracksTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveTracksTask.swift; sourceTree = ""; }; + CE7AC1A52FE02A2600495411 /* ImportTracksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportTracksViewController.swift; sourceTree = ""; }; + CE7AC1A62FE02A2600495411 /* SelectPointsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectPointsViewController.swift; sourceTree = ""; }; CE8A82A82FCFE11F00EADFD8 /* MapVariantReplacementManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapVariantReplacementManager.swift; sourceTree = ""; }; + CEAF08162FD91C4A0048A74A /* FolderCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderCardCollectionViewCell.swift; sourceTree = ""; }; + CEAF08172FD91C4A0048A74A /* FolderCardsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderCardsCell.swift; sourceTree = ""; }; + CEAF081A2FD9226C0048A74A /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; + CEDB71E42FD993C3009DAA0D /* TrackStatsTableCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackStatsTableCell.swift; sourceTree = ""; }; D1A0AFFE2F50000100A0B001 /* OpeningHoursParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpeningHoursParserTest.swift; sourceTree = ""; }; D1A0AFFF2F50000100A0B001 /* OpeningHoursParserTestSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OpeningHoursParserTestSupport.h; sourceTree = ""; }; D1A0B0002F50000100A0B001 /* OpeningHoursParserTestSupport.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OpeningHoursParserTestSupport.mm; sourceTree = ""; }; @@ -6557,7 +6584,6 @@ DA5A7BCF26C563A100F274C7 /* OARadiusCellEx.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OARadiusCellEx.xib; sourceTree = ""; }; DA5A7BD026C563A100F274C7 /* OAHeaderRoundCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OAHeaderRoundCell.xib; sourceTree = ""; }; DA5A7BD126C563A100F274C7 /* OATargetInfoCollapsableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OATargetInfoCollapsableViewCell.xib; sourceTree = ""; }; - DA5A7BD226C563A100F274C7 /* OAFolderCardCollectionViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OAFolderCardCollectionViewCell.xib; sourceTree = ""; }; DA5A7BD326C563A100F274C7 /* OAMultilineTextViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OAMultilineTextViewCell.xib; sourceTree = ""; }; DA5A7BD426C563A100F274C7 /* OASearchMoreCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OASearchMoreCell.xib; sourceTree = ""; }; DA5A7BD626C563A100F274C7 /* OATargetInfoViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OATargetInfoViewCell.xib; sourceTree = ""; }; @@ -6572,7 +6598,6 @@ DA5A7BE326C563A100F274C7 /* OABottomSheetHeaderIconCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OABottomSheetHeaderIconCell.xib; sourceTree = ""; }; DA5A7BE426C563A100F274C7 /* OATextLineViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OATextLineViewCell.xib; sourceTree = ""; }; DA5A7BE526C563A100F274C7 /* OAInAppCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OAInAppCell.xib; sourceTree = ""; }; - DA5A7BE726C563A100F274C7 /* OAFolderCardsCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OAFolderCardsCell.xib; sourceTree = ""; }; DA5A7BE826C563A100F274C7 /* OAWaypointHeaderCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OAWaypointHeaderCell.xib; sourceTree = ""; }; DA5A7BE926C563A100F274C7 /* OADestinationCollectionViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OADestinationCollectionViewCell.xib; sourceTree = ""; }; DA5A7BEA26C563A100F274C7 /* OAImageDescTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OAImageDescTableViewCell.xib; sourceTree = ""; }; @@ -6649,7 +6674,6 @@ DA5A7C5426C563A200F274C7 /* OATitleDescriptionIconRoundCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OATitleDescriptionIconRoundCell.h; sourceTree = ""; }; DA5A7C5726C563A200F274C7 /* OACustomPickerTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OACustomPickerTableViewCell.m; sourceTree = ""; }; DA5A7C5A26C563A200F274C7 /* OARadiusCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OARadiusCell.m; sourceTree = ""; }; - DA5A7C5B26C563A200F274C7 /* OAFolderCardCollectionViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OAFolderCardCollectionViewCell.h; sourceTree = ""; }; DA5A7C5C26C563A200F274C7 /* OALocationIconsTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OALocationIconsTableViewCell.m; sourceTree = ""; }; DA5A7C5D26C563A200F274C7 /* OATextInputFloatingCellWithIcon.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OATextInputFloatingCellWithIcon.h; sourceTree = ""; }; DA5A7C5E26C563A200F274C7 /* OASegmentTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OASegmentTableViewCell.m; sourceTree = ""; }; @@ -6698,7 +6722,6 @@ DA5A7C9E26C563A200F274C7 /* OARouteProgressBarCell.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = OARouteProgressBarCell.mm; sourceTree = ""; }; DA5A7C9F26C563A200F274C7 /* OARouteInfoCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OARouteInfoCell.m; sourceTree = ""; }; DA5A7CA026C563A200F274C7 /* OATitleDescriptionCollapsableCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OATitleDescriptionCollapsableCell.h; sourceTree = ""; }; - DA5A7CA126C563A200F274C7 /* OAFolderCardsCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OAFolderCardsCell.m; sourceTree = ""; }; DA5A7CA226C563A200F274C7 /* OAGPXTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OAGPXTableViewCell.h; sourceTree = ""; }; DA5A7CA326C563A200F274C7 /* OASliderWithValuesCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OASliderWithValuesCell.h; sourceTree = ""; }; DA5A7CA726C563A200F274C7 /* OARouteInfoLegendCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OARouteInfoLegendCell.m; sourceTree = ""; }; @@ -6752,7 +6775,6 @@ DA5A7CEC26C563A200F274C7 /* OASegmentTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OASegmentTableViewCell.h; sourceTree = ""; }; DA5A7CED26C563A200F274C7 /* OAQuickActionCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OAQuickActionCell.h; sourceTree = ""; }; DA5A7CEE26C563A200F274C7 /* OAProgressBarCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OAProgressBarCell.h; sourceTree = ""; }; - DA5A7CEF26C563A200F274C7 /* OAFolderCardCollectionViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OAFolderCardCollectionViewCell.m; sourceTree = ""; }; DA5A7CF126C563A200F274C7 /* OARadiusCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OARadiusCell.h; sourceTree = ""; }; DA5A7CF226C563A200F274C7 /* OACustomPickerTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OACustomPickerTableViewCell.h; sourceTree = ""; }; DA5A7CF626C563A200F274C7 /* OAPointTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OAPointTableViewCell.h; sourceTree = ""; }; @@ -6797,7 +6819,6 @@ DA5A7D3226C563A300F274C7 /* OASliderWithValuesCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OASliderWithValuesCell.m; sourceTree = ""; }; DA5A7D3326C563A300F274C7 /* OATitleDescriptionCollapsableCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OATitleDescriptionCollapsableCell.m; sourceTree = ""; }; DA5A7D3426C563A300F274C7 /* OARouteInfoCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OARouteInfoCell.h; sourceTree = ""; }; - DA5A7D3526C563A300F274C7 /* OAFolderCardsCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OAFolderCardsCell.h; sourceTree = ""; }; DA5A7D3626C563A300F274C7 /* OAHomeWorkCollectionViewCell.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = OAHomeWorkCollectionViewCell.mm; sourceTree = ""; }; DA5A7D3726C563A300F274C7 /* OATitleIconRoundCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OATitleIconRoundCell.h; sourceTree = ""; }; DA5A7D3826C563A300F274C7 /* OACompoundViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OACompoundViewController.m; sourceTree = ""; }; @@ -10616,6 +10637,41 @@ name = Resources; sourceTree = ""; }; + CE7AC19F2FE02A2600495411 /* MapPreviewRenderer */ = { + isa = PBXGroup; + children = ( + CE7AC1992FE02A2600495411 /* OATrackPreviewMapRenderer.h */, + CE7AC19A2FE02A2600495411 /* OATrackPreviewMapRenderer.mm */, + CE7AC19B2FE02A2600495411 /* TrackBitmapDrawer.swift */, + CE7AC19C2FE02A2600495411 /* TrackPreviewColorHelper.swift */, + CE7AC19D2FE02A2600495411 /* TrackPreviewManager.swift */, + CE7AC19E2FE02A2600495411 /* TrackStubPreviewRenderer.swift */, + ); + path = MapPreviewRenderer; + sourceTree = ""; + }; + CE7AC1A42FE02A2600495411 /* Tasks */ = { + isa = PBXGroup; + children = ( + CE7AC1A02FE02A2600495411 /* CollectTracksTask.swift */, + CE7AC1A12FE02A2600495411 /* SaveGpxAsyncTask.swift */, + CE7AC1A22FE02A2600495411 /* SaveImportedGpxHelper.swift */, + CE7AC1A32FE02A2600495411 /* SaveTracksTask.swift */, + ); + path = Tasks; + sourceTree = ""; + }; + CE7AC1A72FE02A2600495411 /* Import */ = { + isa = PBXGroup; + children = ( + CE7AC19F2FE02A2600495411 /* MapPreviewRenderer */, + CE7AC1A42FE02A2600495411 /* Tasks */, + CE7AC1A52FE02A2600495411 /* ImportTracksViewController.swift */, + CE7AC1A62FE02A2600495411 /* SelectPointsViewController.swift */, + ); + path = Import; + sourceTree = ""; + }; DA0133122A20E1E900920C14 /* Helpers */ = { isa = PBXGroup; children = ( @@ -12157,10 +12213,6 @@ DA5A7CB226C563A200F274C7 /* OAFilledButtonCell.h */, DA5A7BAD26C563A100F274C7 /* OAFilledButtonCell.mm */, C57E26F12D3D663F00A8A3D5 /* AttachRoadsBannerCell.swift */, - DA5A7C5B26C563A200F274C7 /* OAFolderCardCollectionViewCell.h */, - DA5A7CEF26C563A200F274C7 /* OAFolderCardCollectionViewCell.m */, - DA5A7D3526C563A300F274C7 /* OAFolderCardsCell.h */, - DA5A7CA126C563A200F274C7 /* OAFolderCardsCell.m */, DA5A7C6A26C563A200F274C7 /* OAFoldersCell.h */, DA5A7CE226C563A200F274C7 /* OAFoldersCell.m */, DA5A7CD526C563A200F274C7 /* OAFoldersCollectionViewCell.h */, @@ -12348,6 +12400,9 @@ 27C6D5AE2E7AE36600695D5B /* KeyTableViewCell.swift */, 27291B5F2EE81169005D0B0A /* PreviewImageViewTableViewCell.swift */, CE7818252FC07944005CCF47 /* WikipediaContextMenuCell.swift */, + CEAF08162FD91C4A0048A74A /* FolderCardCollectionViewCell.swift */, + CEAF08172FD91C4A0048A74A /* FolderCardsCell.swift */, + CEDB71E42FD993C3009DAA0D /* TrackStatsTableCell.swift */, 271A5C432FCDE1D700C27411 /* SortButtonCollectionViewCell.swift */, DA5A7BB626C563A100F274C7 /* Xibs */, 468CD3822C809E3400CC3436 /* ElevationChartCell.swift */, @@ -12390,8 +12445,6 @@ DA5A7C1026C563A200F274C7 /* OADownloadProgressBarCell.xib */, DA5A7C0526C563A200F274C7 /* OAEmptySearchCell.xib */, DA5A7BC826C563A100F274C7 /* OAFilledButtonCell.xib */, - DA5A7BD226C563A100F274C7 /* OAFolderCardCollectionViewCell.xib */, - DA5A7BE726C563A100F274C7 /* OAFolderCardsCell.xib */, DA5A7C0126C563A200F274C7 /* OAFoldersCell.xib */, DA5A7BE126C563A100F274C7 /* OAFoldersCollectionViewCell.xib */, DA5A7BFD26C563A200F274C7 /* OAGPXRecTableViewCell.xib */, @@ -12673,6 +12726,7 @@ DA5A7DC226C563A300F274C7 /* MyPlaces */ = { isa = PBXGroup; children = ( + CE7AC1A72FE02A2600495411 /* Import */, D7B76D0C2FD16070004EE3E9 /* OrganizeByStepSizeViewController.swift */, D7BF04772FD2DB4400BABB31 /* TracksViewController.swift */, D71B9A8B2FC95D8500FBB0F3 /* OrganizeTracksByViewController.swift */, @@ -14250,6 +14304,7 @@ FA069C6A2B9F65750007E4DE /* UILabel+Extension.swift */, 4609FAA12C5158F100B57F06 /* UIImageView+Extension.swift */, 32BC95722CC021CE00FEEAB8 /* UIFont+Extension.swift */, + CEAF081A2FD9226C0048A74A /* Array+Extension.swift */, ); path = SwiftExtensions; sourceTree = ""; @@ -15255,7 +15310,6 @@ DA5A851326C563A900F274C7 /* LaunchScreen.xib in Resources */, 843E5D111AB341E000BE14BE /* left_menu_icon_map@3x.png in Resources */, DA03184422DF0E50008FC564 /* ic_custom_boat@3x.png in Resources */, - DA5A827C26C563A700F274C7 /* OAFolderCardCollectionViewCell.xib in Resources */, DA5F77BE28743E53000A2BFF /* ic_custom_cloud_alert@3x.png in Resources */, DA2EC69029FBBE0300ECEB37 /* widget_marker_triangle_pin_1@2x.png in Resources */, 1571CE2524ADEE2000083F92 /* ic_custom_contrast@2x.png in Resources */, @@ -16371,7 +16425,6 @@ 323BD8E72628850400D255ED /* ic_bg_point_comment_top@2x.png in Resources */, DA25FA5A23FFFC90009CDEAD /* ic_custom_change_object_position@3x.png in Resources */, 27291B622EE81169005D0B0A /* PreviewImageViewTableViewCell.xib in Resources */, - DA5A829126C563A700F274C7 /* OAFolderCardsCell.xib in Resources */, DA84E84D21D77EE0006954D0 /* ic_action_osmand_logo_banner@3x.png in Resources */, 0A0EAC0126E619210044E82C /* ic_custom_magnifier@2x.png in Resources */, DA5A828326C563A700F274C7 /* OAPublicTransportShieldCell.xib in Resources */, @@ -17246,7 +17299,6 @@ DA5A849726C563A900F274C7 /* OAGpxData.mm in Sources */, 464BF4A92C0517E7000D6908 /* Map3DModeVisibility.swift in Sources */, DAA61CCE2A03D9C000AFF65D /* OAWidgetsVisibilityHelper.mm in Sources */, - DA5A831226C563A800F274C7 /* OAFolderCardsCell.m in Sources */, FA54CE0F2EBB938E007F1F68 /* Locale+Extension.swift in Sources */, DA4ABF1C2876DA6800B996EF /* OAMapSourcesSettingsItem.mm in Sources */, DA5A84CE26C563A900F274C7 /* OAQuickActionSelectionBottomSheetViewController.mm in Sources */, @@ -17263,6 +17315,17 @@ 2758DD592E5F05F000051096 /* KeyAssignment.swift in Sources */, DA5A840F26C563A800F274C7 /* OABasePointEditingHandler.m in Sources */, DA5A832B26C563A800F274C7 /* OADirectionTableViewCell.m in Sources */, + CE7AC1A82FE02A2600495411 /* OATrackPreviewMapRenderer.mm in Sources */, + CE7AC1A92FE02A2600495411 /* TrackBitmapDrawer.swift in Sources */, + CE7AC1AA2FE02A2600495411 /* TrackPreviewColorHelper.swift in Sources */, + CE7AC1AB2FE02A2600495411 /* TrackPreviewManager.swift in Sources */, + CE7AC1AC2FE02A2600495411 /* TrackStubPreviewRenderer.swift in Sources */, + CE7AC1AD2FE02A2600495411 /* CollectTracksTask.swift in Sources */, + CE7AC1AE2FE02A2600495411 /* SaveGpxAsyncTask.swift in Sources */, + CE7AC1AF2FE02A2600495411 /* SaveImportedGpxHelper.swift in Sources */, + CE7AC1B02FE02A2600495411 /* SaveTracksTask.swift in Sources */, + CE7AC1B12FE02A2600495411 /* ImportTracksViewController.swift in Sources */, + CE7AC1B22FE02A2600495411 /* SelectPointsViewController.swift in Sources */, DA5A817926C563A700F274C7 /* OAMapButtonsHelper.m in Sources */, 27E316872E716901007172DA /* BaseSwitchAppModeAction.swift in Sources */, 27E316882E716901007172DA /* PreviousAppProfileAction.swift in Sources */, @@ -17280,6 +17343,7 @@ C5B4AE5E2B1F51740019F357 /* MenuHelpDataService.swift in Sources */, DA5A845E26C563A900F274C7 /* OARoutePointsLayer.mm in Sources */, DA5A852C26C563A900F274C7 /* OAOcbfHelper.m in Sources */, + CEAF081B2FD9226C0048A74A /* Array+Extension.swift in Sources */, 327A19862A98D57800F4A027 /* TravelJsonParser.swift in Sources */, C5455E5E2F0E5421004FB3ED /* TripRecordingMovingTimeWidget.swift in Sources */, DA5A815426C563A700F274C7 /* OACarPlayMapViewController.mm in Sources */, @@ -17823,6 +17887,7 @@ DA5A815126C563A700F274C7 /* OACarPlayTracksListController.m in Sources */, DA5A813C26C563A700F274C7 /* OAEntity.m in Sources */, DA5A841026C563A800F274C7 /* OATargetAddressViewController.mm in Sources */, + CEDB71E52FD993C3009DAA0D /* TrackStatsTableCell.swift in Sources */, FA4713D22E0E937500DC518F /* URLSessionConfigProvider.swift in Sources */, FAC166262B21BEF900D63755 /* SBError.swift in Sources */, DA5A825226C563A700F274C7 /* OANavigationTypeViewController.mm in Sources */, @@ -18190,6 +18255,8 @@ DA5A812A26C563A700F274C7 /* OASQLiteTileSourceMapLayerProvider.mm in Sources */, DA5A84F726C563A900F274C7 /* OAAppData.m in Sources */, FA5FB6422DE0757700ABECCF /* OBDComputerWidgetExtension.swift in Sources */, + CEAF08182FD91C4A0048A74A /* FolderCardCollectionViewCell.swift in Sources */, + CEAF08192FD91C4A0048A74A /* FolderCardsCell.swift in Sources */, DA5A846326C563A900F274C7 /* OAMapLayer.mm in Sources */, DA5A849B26C563A900F274C7 /* OAClearPointsCommand.mm in Sources */, 323084D926E933FE00D6FC67 /* OAOsmAndFormatter.mm in Sources */, @@ -18316,7 +18383,6 @@ 4631377929AE6D8A000B0258 /* OACarPlayHistoryListController.mm in Sources */, 3284BEBC2EABA3300011A9BA /* GpxAppearanceInfo.swift in Sources */, C575798C2D64A0A30016957D /* TracksChangeAppearanceViewController.swift in Sources */, - DA5A834026C563A800F274C7 /* OAFolderCardCollectionViewCell.m in Sources */, DA5A849226C563A900F274C7 /* OAMeasurementEditingContext.mm in Sources */, DA5A856226C563A900F274C7 /* OAWaypointHelper.mm in Sources */, DA5A838726C563A800F274C7 /* OAManageResourcesViewController.mm in Sources */, diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index fe1fc0c213..4b62a989a4 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -4610,3 +4610,15 @@ "shared_string_url" = "URL"; +"import_tracks_cancel_title" = "Cancel import?"; +"import_tracks_cancel_descr" = "If you close now, your track selections will not be imported."; +"import_tracks_descr_one" = "The file %@ contains 1 separate track. Select the one you want to import."; +"import_tracks_descr_other" = "The file %@ contains %d separate tracks. Select the ones you want to import."; +"import_as_one_track" = "Import as one track"; +"import_tracks_folders_footer" = "Select a destination folder for the imported tracks."; +"error_reading_gpx" = "Could not read GPX data."; + +"selected_waypoints_descr" = "Selected waypoints will be added to the %@ track."; +"selected_waypoints_exit_descr" = "Your waypoint selections will not be saved if you close now"; +"auto_select_nearest_points" = "Auto-select nearest points"; +"auto_select_nearest_footer" = "Automatically find and select the waypoints closest to this track."; diff --git a/Sources/AppNavigation/MyPlacesNavigator.swift b/Sources/AppNavigation/MyPlacesNavigator.swift index f9753796c2..afe09cf930 100644 --- a/Sources/AppNavigation/MyPlacesNavigator.swift +++ b/Sources/AppNavigation/MyPlacesNavigator.swift @@ -42,6 +42,26 @@ final class MyPlacesNavigator: NSObject { let track = TrackItem(file: item.file) openTrack(track) } + + func openTracks(inFolder absoluteFolderPath: String?) { + guard let root, let nav = root.navigationController else { return } + + if let myPlaces = nav.viewControllers.first(where: { $0 is MyPlacesContainerViewController }) as? MyPlacesContainerViewController { + nav.popToViewController(myPlaces, animated: false) + myPlaces.switchToWithSegmentControl(tab: .tracks) + (myPlaces.viewController(for: .tracks) as? TracksViewController)?.navigateToSubfolder(absoluteFolderPath) + return + } + + nav.dismiss(animated: false) + nav.popToRootViewController(animated: false) + + let myPlaces = MyPlacesContainerViewController() + myPlaces.loadViewIfNeeded() + myPlaces.selectedTab = .tracks + myPlaces.tracksFolderPathToOpenOnLoad = absoluteFolderPath + nav.pushViewController(myPlaces, animated: true) + } private func openMyPlaces(tab: MyPlacesContainerViewController.Tab) { guard let root, diff --git a/Sources/Controllers/Cells/FolderCardCollectionViewCell.swift b/Sources/Controllers/Cells/FolderCardCollectionViewCell.swift new file mode 100644 index 0000000000..238913b18f --- /dev/null +++ b/Sources/Controllers/Cells/FolderCardCollectionViewCell.swift @@ -0,0 +1,70 @@ +// +// FolderCardCollectionViewCell.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 09.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class FolderCardCollectionViewCell: UICollectionViewCell { + + let imageView = UIImageView() + let titleLabel = UILabel() + let descLabel = UILabel() + + override var isHighlighted: Bool { + didSet { + backgroundColor = isHighlighted ? .iconColorDefault : .groupBg + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + private func setupUI() { + layer.cornerRadius = 12 + clipsToBounds = true + + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.font = UIFont.scaledSystemFont(ofSize: 15, weight: .semibold) + titleLabel.lineBreakMode = .byTruncatingTail + + descLabel.translatesAutoresizingMaskIntoConstraints = false + descLabel.font = UIFont.preferredFont(forTextStyle: .subheadline) + descLabel.textColor = UIColor(white: 0.67, alpha: 1) + descLabel.lineBreakMode = .byTruncatingTail + + contentView.addSubview(imageView) + contentView.addSubview(titleLabel) + contentView.addSubview(descLabel) + + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), + imageView.widthAnchor.constraint(equalToConstant: 30), + imageView.heightAnchor.constraint(equalToConstant: 30), + + descLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 14), + descLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 8), + descLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), + + titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 2), + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), + titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), + titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + titleLabel.heightAnchor.constraint(equalToConstant: 21) + ]) + } +} diff --git a/Sources/Controllers/Cells/FolderCardsCell.swift b/Sources/Controllers/Cells/FolderCardsCell.swift new file mode 100644 index 0000000000..9fda0c5ecc --- /dev/null +++ b/Sources/Controllers/Cells/FolderCardsCell.swift @@ -0,0 +1,366 @@ +// +// FolderCardsCell.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 09.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +@objc enum FolderCardsAddButtonPosition: Int { + case end + case beginning +} + +@objc enum FolderCardsConfig: Int { + case defaultConfig + case importTracks +} + +@objc protocol FolderCardsCellDelegate: AnyObject { + func onItemSelected(_ index: Int) + func onAddFolderButtonPressed() +} + +@objc final class FolderCardsCell: UITableViewCell { + + private enum Layout { + static let margin: CGFloat = 16 + static let cellWidth: CGFloat = 120 + static let cellHeight: CGFloat = 69 + static let rowHeight: CGFloat = 85 + } + + private struct Item { + enum Kind { case folder, add } + let title: String + let size: String + let color: UIColor + let imageName: String + let hidden: Bool + let kind: Kind + } + + @objc weak var delegate: FolderCardsCellDelegate? + @objc weak var state: OACollectionViewCellState? + @objc var cellIndex = IndexPath(row: 0, section: 0) + + var addButtonPosition: FolderCardsAddButtonPosition = .end + var iconDefaultColor: UIColor = .iconColorActive + var folderTitleDefaultColor: UIColor = .textColorActive + var folderTitleSelectedDefaultColor: UIColor = .textColorActive + + @objc let collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumInteritemSpacing = 0 + layout.minimumLineSpacing = Layout.margin + layout.sectionInset = UIEdgeInsets(top: 0, left: Layout.margin, bottom: Layout.margin, right: Layout.margin) + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.backgroundColor = .groupBg + collectionView.showsHorizontalScrollIndicator = false + collectionView.showsVerticalScrollIndicator = false + return collectionView + }() + + private var items: [Item] = [] + private var selectedFolderIndex = 0 + private var originalGroupFont: UIFont? + private var italicGroupFont: UIFont? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + override func layoutSubviews() { + super.layoutSubviews() + updateContentOffset() + } + + // MARK: - Config + + func configureCell(_ config: FolderCardsConfig) { + switch config { + case .defaultConfig: break + case .importTracks: + addButtonPosition = .beginning + iconDefaultColor = .iconColorSelected + folderTitleSelectedDefaultColor = .textColorPrimary + } + } + + // MARK: - Public API + + @objc func setValues(_ values: [String], + sizes: [NSNumber]?, + colors: [UIColor]?, + hidden: [NSNumber]?, + addButtonTitle: String, + withSelectedIndex index: Int32) { + setValues(values, + sizes: sizes, + colors: colors, + hidden: hidden, + addButtonTitle: addButtonTitle, + withSelectedIndex: index, + addButtonPosition: addButtonPosition) + } + + func setValues(_ values: [String], + sizes: [NSNumber]?, + colors: [UIColor]?, + hidden: [NSNumber]?, + addButtonTitle: String, + withSelectedIndex index: Int32, + addButtonPosition position: FolderCardsAddButtonPosition) { + self.addButtonPosition = position + selectedFolderIndex = Int(index) + + let folderItems: [Item] = values.enumerated().map { i, title in + let sizeNumber = sizes?[safe: i] + let sizeString = sizeNumber.map { "\($0.intValue)" } ?? "" + + var color = colors?[safe: i] ?? iconDefaultColor + + let isHidden = hidden?[safe: i]?.boolValue ?? false + let visible = !isHidden + let imageName = visible ? "ic_custom_folder" : "ic_custom_folder_hidden_outlined" + if !visible { + color = .iconColorSecondary + } + + return Item(title: title, + size: sizeString, + color: color, + imageName: imageName, + hidden: !visible, + kind: .folder) + } + + let addItem = Item(title: addButtonTitle, + size: "", + color: .iconColorActive, + imageName: "ic_custom_add", + hidden: false, + kind: .add) + + switch position { + case .end: + items = folderItems + [addItem] + case .beginning: + items = [addItem] + folderItems + } + + collectionView.reloadData() + } + + @objc func setSelectedIndex(_ selectedIndex: Int) { + let previous = selectedFolderIndex + selectedFolderIndex = selectedIndex + collectionView.reloadItems(at: [ + IndexPath(row: collectionIndex(forFolderIndex: previous), section: 0), + IndexPath(row: collectionIndex(forFolderIndex: selectedFolderIndex), section: 0) + ]) + } + + @objc func updateContentOffset() { + guard let state else { return } + + if !state.containsValue(forIndex: cellIndex) { + let initialOffset = calculateOffset(forFolderIndex: selectedFolderIndex) + state.setOffset(initialOffset, forIndex: cellIndex) + collectionView.contentOffset = initialOffset + } else { + var offsetForIndex = state.getOffsetForIndex(cellIndex) + if OAUtilities.getLeftMargin() > 0 { + offsetForIndex.x -= OAUtilities.getLeftMargin() - Layout.margin + } + collectionView.contentOffset = offsetForIndex + } + } + + @objc func scrollToFolder(at folderIndex: Int, animated: Bool) { + let indexPath = IndexPath(row: collectionIndex(forFolderIndex: folderIndex), section: 0) + guard indexPath.row < collectionView.numberOfItems(inSection: 0), + !collectionView.indexPathsForVisibleItems.contains(indexPath) else { return } + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: animated) + } + + // MARK: - Setup + + private func setupUI() { + selectionStyle = .none + backgroundColor = .groupBg + contentView.backgroundColor = .groupBg + + collectionView.delegate = self + collectionView.dataSource = self + collectionView.register(FolderCardCollectionViewCell.self, forCellWithReuseIdentifier: FolderCardCollectionViewCell.reuseIdentifier) + + contentView.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentView.heightAnchor.constraint(equalToConstant: Layout.rowHeight) + ]) + } + + // MARK: - Index mapping + + private func collectionIndex(forFolderIndex folderIndex: Int) -> Int { + switch addButtonPosition { + case .end: + return folderIndex + case .beginning: + return folderIndex + 1 + } + } + + private func folderIndex(fromCollectionIndex collectionIndex: Int) -> Int { + switch addButtonPosition { + case .end: + return collectionIndex + case .beginning: + return collectionIndex - 1 + } + } + + private func isAddButton(at collectionIndex: Int) -> Bool { + guard let item = items[safe: collectionIndex] else { return false } + return item.kind == .add + } + + // MARK: - Scroll offset + + private func saveOffset() { + guard let state else { return } + var offset = collectionView.contentOffset + if OAUtilities.getLeftMargin() > 0 { + offset.x += OAUtilities.getLeftMargin() - Layout.margin + } + state.setOffset(offset, forIndex: cellIndex) + } + + private func calculateOffset(forFolderIndex folderIndex: Int) -> CGPoint { + let index: Int + switch addButtonPosition { + case .end: + index = collectionIndex(forFolderIndex: folderIndex) + case .beginning: + index = folderIndex + } + var selectedOffset = CGFloat(index) * (Layout.cellWidth + Layout.margin) + let fullLength = CGFloat(items.count) * (Layout.cellWidth + Layout.margin) + let screenWidth = OAUtilities.calculateScreenWidth() + var maxOffset = fullLength - screenWidth + Layout.margin * 3 + if maxOffset < 0 { + maxOffset = 0 + } + if selectedOffset > maxOffset { + selectedOffset = maxOffset + } + return CGPoint(x: selectedOffset, y: 0) + } + + private func configureCardCell(_ cell: FolderCardCollectionViewCell, item: Item, selected: Bool) { + if originalGroupFont == nil { + originalGroupFont = cell.titleLabel.font + if let descriptor = cell.titleLabel.font.fontDescriptor.withSymbolicTraits(.traitItalic) { + italicGroupFont = UIFont(descriptor: descriptor, size: 0) + } + } + + let titleColor: UIColor + if item.hidden { + titleColor = .textColorSecondary + } else if selected { + titleColor = folderTitleSelectedDefaultColor + } else { + titleColor = folderTitleDefaultColor + } + + cell.layer.cornerRadius = 9 + cell.titleLabel.text = item.title + cell.descLabel.text = item.size + cell.imageView.tintColor = item.color + cell.imageView.image = .templateImageNamed(item.imageName) + cell.backgroundColor = .groupBg + cell.titleLabel.textColor = titleColor + cell.titleLabel.font = item.hidden ? italicGroupFont : originalGroupFont + + if selected { + cell.layer.borderWidth = 2 + cell.layer.borderColor = UIColor.iconColorActive.cgColor + } else { + cell.layer.borderWidth = 1 + cell.layer.borderColor = UIColor.buttonBgColorSecondary.cgColor + } + + cell.isAccessibilityElement = true + cell.accessibilityTraits = .button + if item.kind == .add { + cell.accessibilityLabel = item.title + } else { + cell.accessibilityLabel = item.title + cell.accessibilityValue = selected ? localizedString("shared_string_selected") : item.size + if selected { + cell.accessibilityTraits.insert(.selected) + } + } + cell.imageView.isAccessibilityElement = false + } +} + +// MARK: - UICollectionViewDataSource + +extension FolderCardsCell: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + items.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: FolderCardCollectionViewCell.reuseIdentifier, for: indexPath) as? FolderCardCollectionViewCell else { + return UICollectionViewCell() + } + + let item = items[indexPath.row] + let selected = !isAddButton(at: indexPath.row) && folderIndex(fromCollectionIndex: indexPath.row) == selectedFolderIndex + + configureCardCell(cell, item: item, selected: selected) + return cell + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension FolderCardsCell: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + CGSize(width: Layout.cellWidth, height: Layout.cellHeight) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + if isAddButton(at: indexPath.row) { + delegate?.onAddFolderButtonPressed() + } else { + let folderIndex = folderIndex(fromCollectionIndex: indexPath.row) + delegate?.onItemSelected(folderIndex) + setSelectedIndex(folderIndex) + } + collectionView.deselectItem(at: indexPath, animated: true) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + saveOffset() + } +} diff --git a/Sources/Controllers/Cells/OAFolderCardCollectionViewCell.h b/Sources/Controllers/Cells/OAFolderCardCollectionViewCell.h deleted file mode 100644 index e3982559ec..0000000000 --- a/Sources/Controllers/Cells/OAFolderCardCollectionViewCell.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// OAFolderCardCollectionViewCell.h -// OsmAnd -// -// Created by nnngrach on 08.02.2021. -// Copyright © 2021 OsmAnd. All rights reserved. -// - -#import - -@interface OAFolderCardCollectionViewCell : UICollectionViewCell - -@property (weak, nonatomic) IBOutlet UIImageView *imageView; -@property (weak, nonatomic) IBOutlet UILabel *titleLabel; -@property (weak, nonatomic) IBOutlet UILabel *descLabel; - -@end diff --git a/Sources/Controllers/Cells/OAFolderCardCollectionViewCell.m b/Sources/Controllers/Cells/OAFolderCardCollectionViewCell.m deleted file mode 100644 index b25d498cb7..0000000000 --- a/Sources/Controllers/Cells/OAFolderCardCollectionViewCell.m +++ /dev/null @@ -1,19 +0,0 @@ -// -// OAFolderCardCollectionViewCell.m -// OsmAnd -// -// Created by nnngrach on 08.02.2021. -// Copyright © 2021 OsmAnd. All rights reserved. -// - -#import "OAFolderCardCollectionViewCell.h" - -@implementation OAFolderCardCollectionViewCell - -- (void) awakeFromNib -{ - [super awakeFromNib]; - self.titleLabel.font = [UIFont scaledSystemFontOfSize:15. weight:UIFontWeightSemibold]; -} - -@end diff --git a/Sources/Controllers/Cells/OAFolderCardsCell.h b/Sources/Controllers/Cells/OAFolderCardsCell.h deleted file mode 100644 index 8fa71bcd6b..0000000000 --- a/Sources/Controllers/Cells/OAFolderCardsCell.h +++ /dev/null @@ -1,33 +0,0 @@ -// -// OAHorizontalCollectionViewIconCell.h -// OsmAnd -// -// Created by nnngrach on 08.02.2021. -// Copyright © 2021 OsmAnd. All rights reserved. -// - -#import -#import "OACollectionViewCellState.h" - -@protocol OAFolderCardsCellDelegate - -@required - -- (void) onItemSelected:(NSInteger)index; -- (void) onAddFolderButtonPressed; - -@end - -@interface OAFolderCardsCell : UITableViewCell - -@property (weak, nonatomic) IBOutlet UICollectionView *collectionView; - -@property (weak, nonatomic) id delegate; -@property (weak, nonatomic) OACollectionViewCellState *state; -@property (nonatomic) NSIndexPath *cellIndex; - -- (void) setValues:(NSArray *)values sizes:(NSArray *)sizes colors:(NSArray *)colors hidden:(NSArray *)hidden addButtonTitle:(NSString *)addButtonTitle withSelectedIndex:(int)index; -- (void) setSelectedIndex:(NSInteger)selectedIndex; -- (void) updateContentOffset; - -@end diff --git a/Sources/Controllers/Cells/OAFolderCardsCell.m b/Sources/Controllers/Cells/OAFolderCardsCell.m deleted file mode 100644 index 33ab0b1bab..0000000000 --- a/Sources/Controllers/Cells/OAFolderCardsCell.m +++ /dev/null @@ -1,236 +0,0 @@ -// -// OAHorizontalCollectionViewIconCell.m -// OsmAnd -// -// Created by nnngrach on 08.02.2021. -// Copyright © 2021 OsmAnd. All rights reserved. -// - -#import "OAFolderCardsCell.h" -#import "OAFolderCardCollectionViewCell.h" -#import "OsmAnd_Maps-Swift.h" -#import "OAUtilities.h" -#import "Localization.h" -#import "GeneratedAssetSymbols.h" - -#define kMargin 16 -#define kCellWidth 120 -#define kCellHeight 69 - -@interface OAFolderCardsCell() - -@end - -@implementation OAFolderCardsCell -{ - NSMutableArray *_data; - NSInteger _selectedItemIndex; - - UIFont *_originalGroupFont; - UIFont *_italicGroupFont; -} - -- (void) awakeFromNib -{ - [super awakeFromNib]; - _collectionView.delegate = self; - _collectionView.dataSource = self; - [_collectionView registerNib:[UINib nibWithNibName:[OAFolderCardCollectionViewCell getCellIdentifier] bundle:nil] forCellWithReuseIdentifier:[OAFolderCardCollectionViewCell getCellIdentifier]]; - UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init]; - layout.scrollDirection = UICollectionViewScrollDirectionHorizontal; - layout.minimumInteritemSpacing = 0; - layout.minimumLineSpacing = kMargin; - layout.sectionInset = UIEdgeInsetsMake(0, kMargin, kMargin, kMargin); - [_collectionView setCollectionViewLayout:layout]; - [_collectionView setShowsHorizontalScrollIndicator:NO]; - [_collectionView setShowsVerticalScrollIndicator:NO]; - _data = [NSMutableArray new]; -} - -- (void) setValues:(NSArray *)values sizes:(NSArray *)sizes colors:(NSArray *)colors hidden:(NSArray *)hidden addButtonTitle:(NSString *)addButtonTitle withSelectedIndex:(int)index -{ - _data = [NSMutableArray new]; - _selectedItemIndex = index; - - for (NSInteger i = 0; i < values.count; i++) - { - NSString *sizeString; - NSNumber *size = (i < sizes.count && sizes[i]) ? sizes[i] : nil; - sizeString = size ? [NSString stringWithFormat:@"%i", size.intValue] : @""; - UIColor *color = (i < colors.count && colors[i]) ? colors[i] : [UIColor colorNamed:ACColorNameIconColorActive]; - BOOL visible = (i < hidden.count && hidden[i]) ? !hidden[i].boolValue : YES; - NSString *img = visible ? @"ic_custom_folder" : @"ic_custom_folder_hidden_outlined"; - if (!visible) - color = [UIColor colorNamed:ACColorNameIconColorSecondary]; - - [_data addObject:@{ - @"title" : values[i], - @"size" : sizeString, - @"color" : color, - @"img" : img, - @"hidden" : @(!visible), - @"key" : @"home"}]; - } - - [_data addObject:@{ - @"title" : addButtonTitle, - @"size" : @"", - @"color" : [UIColor colorNamed:ACColorNameIconColorActive], - @"img" : @"ic_custom_add", - @"hidden" : @(NO), - @"key" : @"work"}]; -} - -- (void)setSelectedIndex:(NSInteger)selectedIndex -{ - NSInteger prevSelectedItemIndex = _selectedItemIndex; - _selectedItemIndex = selectedIndex; - [self.collectionView reloadItemsAtIndexPaths:@[[NSIndexPath indexPathForRow:prevSelectedItemIndex inSection:0], - [NSIndexPath indexPathForRow:_selectedItemIndex inSection:0]]]; -} - -#pragma mark - Scroll offset calculations - -- (void) updateContentOffset -{ - if (![_state containsValueForIndex:_cellIndex]) - { - CGPoint initialOffset = [self calculateOffset:_selectedItemIndex]; - [_state setOffset:initialOffset forIndex:_cellIndex]; - self.collectionView.contentOffset = initialOffset; - } - else - { - CGPoint loadedOffset = [_state getOffsetForIndex:_cellIndex]; - if ([OAUtilities getLeftMargin] > 0) - loadedOffset.x -= [OAUtilities getLeftMargin] - kMargin; - self.collectionView.contentOffset = loadedOffset; - } -} - -- (void) saveOffset -{ - CGPoint offset = self.collectionView.contentOffset; - if ([OAUtilities getLeftMargin] > 0) - offset.x += [OAUtilities getLeftMargin] - kMargin; - [_state setOffset:offset forIndex:_cellIndex]; -} - -- (CGPoint) calculateOffset:(NSInteger)index; -{ - CGFloat selectedOffset = index * (kCellWidth + kMargin); - CGFloat fullLength = _data.count * (kCellWidth + kMargin); - CGFloat maxOffset = fullLength - DeviceScreenWidth + kMargin * 3; - if (selectedOffset > maxOffset) - selectedOffset = maxOffset; - return CGPointMake(selectedOffset, 0); -} - -#pragma mark - UICollectionViewDataSource - -- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView -{ - return 1; -} - -- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section -{ - return _data.count; -} - -- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath -{ - return CGSizeMake(kCellWidth,kCellHeight); -} - -- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath -{ - NSDictionary *item = _data[indexPath.row]; - UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:[OAFolderCardCollectionViewCell getCellIdentifier] forIndexPath:indexPath]; - if (cell == nil) - { - NSArray *nib = [[NSBundle mainBundle] loadNibNamed:[OAFolderCardCollectionViewCell getCellIdentifier] owner:self options:nil]; - cell = [nib objectAtIndex:0]; - } - if (cell && [cell isKindOfClass:OAFolderCardCollectionViewCell.class]) - { - OAFolderCardCollectionViewCell *destCell = (OAFolderCardCollectionViewCell *) cell; - if (!_originalGroupFont) - { - _originalGroupFont = destCell.titleLabel.font; - UIFontDescriptor *italicDescriptor = [destCell.titleLabel.font.fontDescriptor fontDescriptorWithSymbolicTraits:UIFontDescriptorTraitItalic]; - _italicGroupFont = [UIFont fontWithDescriptor:italicDescriptor size:0]; - } - BOOL hidden = ((NSNumber *) item[@"hidden"]).boolValue; - destCell.layer.cornerRadius = 9; - destCell.titleLabel.text = item[@"title"]; - destCell.descLabel.text = item[@"size"]; - destCell.imageView.tintColor = item[@"color"]; - [destCell.imageView setImage:[UIImage templateImageNamed:item[@"img"]]]; - destCell.backgroundColor = [UIColor colorNamed:ACColorNameGroupBg]; - destCell.titleLabel.textColor = [UIColor colorNamed:hidden ? ACColorNameTextColorSecondary : ACColorNameTextColorActive]; - destCell.titleLabel.font = hidden ? _italicGroupFont : _originalGroupFont; - - if (indexPath.row == _selectedItemIndex) - { - destCell.layer.borderWidth = 2; - destCell.layer.borderColor = [UIColor colorNamed:ACColorNameIconColorActive].CGColor; - } - else - { - destCell.layer.borderWidth = 1; - destCell.layer.borderColor = [UIColor colorNamed:ACColorNameButtonBgColorSecondary].CGColor; - } - } - return cell; -} - -- (void)collectionView:(UICollectionView *)colView didHighlightItemAtIndexPath:(NSIndexPath *)indexPath { - UICollectionViewCell* cell = [colView cellForItemAtIndexPath:indexPath]; - [UIView animateWithDuration:0.2 - delay:0 - options:(UIViewAnimationOptionAllowUserInteraction) - animations:^{ - [cell setBackgroundColor:[UIColor colorNamed:ACColorNameIconColorDisabled]]; - } - completion:nil]; -} - -- (void)collectionView:(UICollectionView *)colView didUnhighlightItemAtIndexPath:(NSIndexPath *)indexPath { - UICollectionViewCell* cell = [colView cellForItemAtIndexPath:indexPath]; - [UIView animateWithDuration:0.2 - delay:0 - options:(UIViewAnimationOptionAllowUserInteraction) - animations:^{ - [cell setBackgroundColor:[UIColor colorNamed:ACColorNameGroupBg]]; - } - completion:nil]; -} - -#pragma mark - UICollectionViewDelegate - -- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath -{ - if (indexPath.row == _data.count - 1) - { - if (_delegate) - [_delegate onAddFolderButtonPressed]; - } - else - { - if (_delegate) - { - [_delegate onItemSelected:indexPath.row]; - [self setSelectedIndex:indexPath.row]; - } - } - - [collectionView deselectItemAtIndexPath:indexPath animated:YES]; -} - -- (void)scrollViewDidScroll:(UIScrollView *)scrollView -{ - [self saveOffset]; -} - -@end diff --git a/Sources/Controllers/Cells/TrackStatsTableCell.swift b/Sources/Controllers/Cells/TrackStatsTableCell.swift new file mode 100644 index 0000000000..7059b2fb1c --- /dev/null +++ b/Sources/Controllers/Cells/TrackStatsTableCell.swift @@ -0,0 +1,100 @@ +// +// TrackStatsTableCell.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 10.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit +import OsmAndShared + +final class TrackStatsTableCell: UITableViewCell { + private static let stringValueKey = "string_value" + + private let collectionView: UICollectionView + private var statisticsData: [OAGPXTableCellData] = [] + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 0 + layout.minimumInteritemSpacing = 12 + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + super.init(style: style, reuseIdentifier: reuseIdentifier) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + collectionView.contentOffset.x = -collectionView.contentInset.left + } + + func configure(statistics: [OAGPXTableCellData]) { + statisticsData = statistics + collectionView.reloadData() + } + + // MARK: - Setup UI + + private func setup() { + selectionStyle = .none + backgroundColor = .groupBg + contentView.backgroundColor = .groupBg + + collectionView.backgroundColor = .clear + collectionView.showsHorizontalScrollIndicator = false + collectionView.contentInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + collectionView.dataSource = self + collectionView.delegate = self + collectionView.register( + UINib(nibName: OAGpxStatBlockCollectionViewCell.reuseIdentifier, bundle: nil), + forCellWithReuseIdentifier: OAGpxStatBlockCollectionViewCell.reuseIdentifier + ) + + collectionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16), + collectionView.heightAnchor.constraint(equalToConstant: 40) + ]) + } +} + +// MARK: - UICollectionViewDataSource, UICollectionViewDelegateFlowLayout + +extension TrackStatsTableCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + statisticsData.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: OAGpxStatBlockCollectionViewCell.reuseIdentifier, + for: indexPath) as! OAGpxStatBlockCollectionViewCell + let cellData = statisticsData[indexPath.row] + + cell.valueView.text = cellData.values[Self.stringValueKey] as? String + cell.iconView.image = .templateImageNamed(cellData.rightIconName) + cell.iconView.tintColor = .iconColorDefault + cell.titleView.text = cellData.title + cell.separatorView.isHidden = cell.isDirectionRTL() ? indexPath.row == 0 : indexPath.row == statisticsData.count - 1 + if cell.needsUpdateConstraints() { + cell.updateConstraints() + } + return cell + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let cellData = statisticsData[indexPath.row] + let isLast = indexPath.row == statisticsData.count - 1 + let text = cellData.values[Self.stringValueKey] as? String + return OATrackMenuHeaderView.getSizeForItem(cellData.title, value: text, isLast: isLast) + } +} diff --git a/Sources/Controllers/Cells/Xibs/OAFolderCardCollectionViewCell.xib b/Sources/Controllers/Cells/Xibs/OAFolderCardCollectionViewCell.xib deleted file mode 100644 index 31145fbb39..0000000000 --- a/Sources/Controllers/Cells/Xibs/OAFolderCardCollectionViewCell.xib +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Sources/Controllers/Cells/Xibs/OAFolderCardsCell.xib b/Sources/Controllers/Cells/Xibs/OAFolderCardsCell.xib deleted file mode 100644 index b834e411cc..0000000000 --- a/Sources/Controllers/Cells/Xibs/OAFolderCardsCell.xib +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Sources/Controllers/Cells/Xibs/OAPointWithRegionTableViewCell.xib b/Sources/Controllers/Cells/Xibs/OAPointWithRegionTableViewCell.xib index d394b98f6b..9fc0509926 100644 --- a/Sources/Controllers/Cells/Xibs/OAPointWithRegionTableViewCell.xib +++ b/Sources/Controllers/Cells/Xibs/OAPointWithRegionTableViewCell.xib @@ -1,9 +1,9 @@ - + - + @@ -115,7 +115,7 @@ - + diff --git a/Sources/Controllers/MyPlaces/Import/ImportTracksViewController.swift b/Sources/Controllers/MyPlaces/Import/ImportTracksViewController.swift new file mode 100644 index 0000000000..7e59715dd0 --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/ImportTracksViewController.swift @@ -0,0 +1,1000 @@ +// +// ImportTracksViewController.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 08.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit +import OsmAndShared + +final class ImportTrackItem: Hashable { + let index: Int + let name: String + let selectedGpxFile: GpxFile + var analysis: GpxTrackAnalysis? + var statisticsCells: [OAGPXTableCellData] = [] + var selectedPoints: [WptPt] = [] + var suggestedPoints: [WptPt] = [] + var previewImage: UIImage? + var isPreviewLoading = false + var bitmapDrawer: TrackBitmapDrawer? + var savedPath: String? + + init(index: Int, name: String, gpxFile: GpxFile, selectedPoints: [WptPt], suggestedPoints: [WptPt]) { + self.index = index + self.name = name + self.selectedGpxFile = gpxFile + self.selectedPoints = selectedPoints + self.suggestedPoints = suggestedPoints + } + + static func == (lhs: ImportTrackItem, rhs: ImportTrackItem) -> Bool { + lhs.index == rhs.index + } + + func hash(into hasher: inout Hasher) { + hasher.combine(index) + } +} + +@objc protocol ImportTracksViewControllerDelegate: AnyObject { + @objc optional func importTracksViewControllerDidFinishImport(_ controller: ImportTracksViewController, success: Bool) + @objc optional func importTracksViewController(_ controller: ImportTracksViewController, didSaveTrack success: Bool, gpxFile: GpxFile) +} + +final class ImportTracksViewController: OABaseButtonsViewController { + + private enum RowKey: String { + case infoDescr + case importAsOne + case trackHeader + case trackPreview + case trackStats + case trackWaypoints + case selectGroups + case folderChips + } + + private enum RowObjKey: String { + case importTrackItem = "importTrackItem" + case attributedTitleKey = "attributedTitle" + case statisticsCells = "statisticsCells" + case foldersValues = "values" + case foldersSizes = "sizes" + case foldersSelectedValue = "selectedValue" + case foldersAddButtonTitle = "addButtonTitle" + } + + weak var delegate: ImportTracksViewControllerDelegate? + + private let gpxFile: GpxFile + private let fileName: String + private var selectedFolderPath: String + private let importURL: URL? + private var importCompletion: ((Bool) -> Void)? + + private var trackItems: [ImportTrackItem] = [] + private var selection = SelectionManager(allItems: []) + private var isCollectingTracks = false + private var isSavingTracks = false + private var lastSavedPath: String? + private var successfulSaveCount = 0 + private lazy var allPointsCount: Int = { + gpxFile.getPointsList().count + }() + + private var folderNames: [String] = [] + private var selectedFolderIndex = 0 + private let foldersScrollState = OACollectionViewCellState() + private var foldersSizes: [NSNumber] = [] + + private let trackPreviewManager = TrackPreviewManager() + private var collectTracksTask: CollectTracksTask? + private var saveAsOneTrackTask: SaveGpxAsyncTask? + private var saveTracksTask: SaveTracksTask? + + private let progressStackView = UIStackView() + private let progressIndicator = UIActivityIndicatorView(style: .medium) + private let progressLabel = UILabel() + + // MARK: - Init + + @objc init(gpxFile: GpxFile, fileName: String, selectedFolderPath: String?, importURL: URL?, completion: ((Bool) -> Void)?) { + self.gpxFile = gpxFile + self.fileName = fileName + self.importURL = importURL + self.importCompletion = completion + self.selectedFolderPath = Self.resolveInitialFolderPath(from: selectedFolderPath) + super.init() + } + + @available(*, unavailable) + override init() { + fatalError("Use init(gpxFile:fileName:selectedFolderPath:importURL:completion:)") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupProgressView() + collectTracks() + reloadFolderNames() + } + + // MARK: - Table + + override func tableStyle() -> UITableView.Style { + .insetGrouped + } + + override func registerCells() { + addCell(OASimpleTableViewCell.reuseIdentifier) + addCell(OAValueTableViewCell.reuseIdentifier) + addCell(OAImageDescTableViewCell.reuseIdentifier) + tableView.register(FolderCardsCell.self, forCellReuseIdentifier: FolderCardsCell.reuseIdentifier) + tableView.register(TrackStatsTableCell.self, forCellReuseIdentifier: TrackStatsTableCell.reuseIdentifier) + } + + override func generateData() { + tableData.clearAllData() + guard !trackItems.isEmpty else { return } + + appendInfoSection() + trackItems.forEach { appendTrackSection(for: $0) } + appendFolderSection() + } + + override func getRow(_ indexPath: IndexPath) -> UITableViewCell? { + let item = tableData.item(for: indexPath) + + switch item.cellType { + case OASimpleTableViewCell.reuseIdentifier: + return configuredSimpleCell(for: item, at: indexPath) + case OAValueTableViewCell.reuseIdentifier: + return configuredValueCell(for: item, at: indexPath) + case FolderCardsCell.reuseIdentifier: + return configuredFolderCardsCell(for: item, at: indexPath) + case TrackStatsTableCell.reuseIdentifier: + return configuredStatsCell(for: item, at: indexPath) + case OAImageDescTableViewCell.reuseIdentifier: + return configuredPreviewCell(for: item, at: indexPath) + default: + return UITableViewCell() + } + } + + override func onRowSelected(_ indexPath: IndexPath) { + switch tableData.item(for: indexPath).key { + case RowKey.importAsOne.rawValue: + onImportAsOneTrackClicked() + case RowKey.trackHeader.rawValue: + onTrackItemSelected(at: indexPath) + case RowKey.selectGroups.rawValue: + onFoldersListSelected() + case RowKey.trackWaypoints.rawValue: + guard let trackItem = trackItem(from: indexPath) else { return } + onTrackItemPointsSelected(track: trackItem) + default: + break + } + } + + override func hideFirstHeader() -> Bool { true } + + override func getCustomHeight(forHeader section: Int) -> CGFloat { + tableData.sectionData(for: UInt(section)).headerText.isEmpty ? 0 : UITableView.automaticDimension + } + + override func getCustomHeight(forFooter section: Int) -> CGFloat { + tableData.sectionData(for: UInt(section)).footerText.isEmpty ? 16 : UITableView.automaticDimension + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + (cell as? FolderCardsCell)?.updateContentOffset() + } + + // MARK: - NavBar + + override func getTitle() -> String? { + localizedString("import_tracks") + } + + override func getCustomIconForLeftNavbarButton() -> UIImage? { + guard let image = UIImage.templateImageNamed("ic_navbar_close") else { return nil } + return OAUtilities.resize(image, newSize: CGSize(width: 24, height: 24))?.withRenderingMode(.alwaysTemplate) + } + + override func getCustomAccessibilityForLeftNavbarButton() -> String? { + localizedString("shared_string_close") + } + + override func onLeftNavbarButtonPressed() { + showExitConfirmationAction() + } + + override func getRightNavbarButtons() -> [UIBarButtonItem]? { + guard !isCollectingTracks, !isSavingTracks else { return nil } + let title = localizedString(selection.areAllSelected ? "shared_string_deselect_all" : "shared_string_select_all") + let item = UIBarButtonItem(title: title, style: .plain, target: self, action: #selector(onSelectAllAction)) + item.tintColor = .label + item.accessibilityLabel = title + return [item] + } + + override func updateNavbar() { + super.updateNavbar() + (getLeftNavbarButton()?.customView as? UIButton)?.tintColor = .label + } + + // MARK: - Bottom buttons + + override func getTopButtonTitle() -> String? { + "\(localizedString("shared_string_import")) \(selection.selectedItems.count)/\(trackItems.count)" + } + + override func getTopButtonColorScheme() -> EOABaseButtonColorScheme { + selection.isEmpty ? .inactive : .purple + } + + override func isBottomSeparatorVisible() -> Bool { false } + + override func onTopButtonPressed() { + importSelectedTracksAction() + } + + // MARK: - Deinit + + deinit { + trackPreviewManager.cancelAll(trackItems) + } +} + +// MARK: - Table Data + +private extension ImportTracksViewController { + func appendInfoSection() { + let section = tableData.createNewSection() + + let descriptionRow = section.createNewRow() + descriptionRow.cellType = OASimpleTableViewCell.reuseIdentifier + descriptionRow.key = RowKey.infoDescr.rawValue + descriptionRow.setObj(makeImportTracksDescription(tracksCount: trackItems.count), forKey: RowObjKey.attributedTitleKey.rawValue) + + let importAsOneRow = section.createNewRow() + importAsOneRow.cellType = OASimpleTableViewCell.reuseIdentifier + importAsOneRow.key = RowKey.importAsOne.rawValue + importAsOneRow.title = localizedString("import_as_one_track") + } + + func appendTrackSection(for item: ImportTrackItem) { + let section = tableData.createNewSection() + + let headerRow = section.createNewRow() + headerRow.cellType = OASimpleTableViewCell.reuseIdentifier + headerRow.key = RowKey.trackHeader.rawValue + headerRow.title = item.name + let positionText = String(format: localizedString("ltr_or_rtl_combine_via_of"), item.index + 1, trackItems.count) + headerRow.descr = String( + format: localizedString("ltr_or_rtl_combine_via_space"), + localizedString("shared_string_gpx_track"), + positionText + ) + headerRow.setObj(item, forKey: RowObjKey.importTrackItem.rawValue) + + let previewRow = section.createNewRow() + previewRow.cellType = OAImageDescTableViewCell.reuseIdentifier + previewRow.key = RowKey.trackPreview.rawValue + previewRow.setObj(item, forKey: RowObjKey.importTrackItem.rawValue) + + if !item.statisticsCells.isEmpty { + let statsRow = section.createNewRow() + statsRow.cellType = TrackStatsTableCell.reuseIdentifier + statsRow.key = RowKey.trackStats.rawValue + statsRow.setObj(item.statisticsCells, forKey: RowObjKey.statisticsCells.rawValue) + statsRow.setObj(item, forKey: RowObjKey.importTrackItem.rawValue) + } + + if allPointsCount > 0 { + let waypointsRow = section.createNewRow() + waypointsRow.cellType = OAValueTableViewCell.reuseIdentifier + waypointsRow.key = RowKey.trackWaypoints.rawValue + waypointsRow.title = localizedString("shared_string_waypoints") + waypointsRow.icon = .icCustomFolder + waypointsRow.iconTintColor = .iconColorActive + waypointsRow.setObj(item, forKey: RowObjKey.importTrackItem.rawValue) + waypointsRow.accessibilityLabel = waypointsRow.title + waypointsRow.accessibilityValue = waypointsRow.descr + } + } + + func appendFolderSection() { + let section = tableData.createNewSection() + section.headerText = localizedString("plan_route_folder") + section.footerText = localizedString("import_tracks_folders_footer") + + let selectGroupsRow = section.createNewRow() + selectGroupsRow.cellType = OAValueTableViewCell.reuseIdentifier + selectGroupsRow.key = RowKey.selectGroups.rawValue + selectGroupsRow.title = localizedString("select_group") + selectGroupsRow.descr = folderNames[safe: selectedFolderIndex] + + let chipsRow = section.createNewRow() + chipsRow.cellType = FolderCardsCell.reuseIdentifier + chipsRow.key = RowKey.folderChips.rawValue + chipsRow.setObj(folderNames, forKey: RowObjKey.foldersValues.rawValue) + chipsRow.setObj(foldersSizes, forKey: RowObjKey.foldersSizes.rawValue) + chipsRow.setObj(selectedFolderIndex, forKey: RowObjKey.foldersSelectedValue.rawValue) + chipsRow.setObj(localizedString("shared_string_add"), forKey: RowObjKey.foldersAddButtonTitle.rawValue) + } +} + +// MARK: - Cell Configuration + +private extension ImportTracksViewController { + func configuredSimpleCell(for item: OATableRowData, at indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: OASimpleTableViewCell.reuseIdentifier, for: indexPath) as! OASimpleTableViewCell + cell.setCustomLeftSeparatorInset(true) + cell.textStackView.isHidden = false + + switch item.key { + case RowKey.infoDescr.rawValue: + let attributedString = item.obj(forKey: RowObjKey.attributedTitleKey.rawValue) as? NSAttributedString + let plainText = attributedString?.string + cell.descriptionLabel.attributedText = attributedString + cell.isAccessibilityElement = true + cell.accessibilityValue = nil + cell.accessibilityLabel = plainText + cell.accessibilityTraits = .staticText + cell.leftIconVisibility(false) + cell.descriptionVisibility(true) + cell.titleVisibility(false) + hideSeparator(for: cell, false) + cell.selectionStyle = .none + case RowKey.importAsOne.rawValue: + cell.isAccessibilityElement = true + cell.accessibilityValue = nil + cell.accessibilityLabel = item.title + cell.accessibilityTraits = .button + cell.titleLabel.text = item.title + cell.titleLabel.textColor = .textColorActive + cell.titleLabel.font = .preferredFont(forTextStyle: .body) + cell.leftIconVisibility(false) + cell.titleVisibility(true) + cell.descriptionVisibility(false) + hideSeparator(for: cell, true) + cell.selectionStyle = .default + case RowKey.trackHeader.rawValue: + guard let trackItem = item.obj(forKey: RowObjKey.importTrackItem.rawValue) as? ImportTrackItem else { break } + let label = [item.title, item.descr].compactMap { $0 }.joined(separator: ", ") + let isSelected = selection.selectedItems.contains(trackItem) + cell.titleLabel.text = item.title + cell.titleLabel.textColor = .textColorPrimary + cell.titleLabel.font = .preferredFont(forTextStyle: .headline) + cell.descriptionLabel.text = item.descr + cell.configureAccessibility(withTitle: label, selected: isSelected) + cell.leftIconView.isAccessibilityElement = false + cell.leftIconView.image = isSelected ? .icCustomDone : .icCustomCheckboxUnselected + cell.leftIconView.tintColor = isSelected ? .iconColorActive : .iconColorSecondary + cell.leftIconVisibility(true) + cell.titleVisibility(true) + cell.descriptionVisibility(true) + hideSeparator(for: cell, true) + cell.selectionStyle = .default + default: + break + } + + return cell + } + + func configuredValueCell(for item: OATableRowData, at indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: OAValueTableViewCell.reuseIdentifier, for: indexPath) as! OAValueTableViewCell + cell.titleLabel.text = item.title + cell.valueLabel.text = item.descr + cell.valueVisibility(true) + cell.descriptionVisibility(false) + cell.leftIconVisibility(item.key == RowKey.trackWaypoints.rawValue) + cell.accessoryType = .disclosureIndicator + cell.accessibilityLabel = item.accessibilityLabel + cell.accessibilityValue = item.accessibilityValue + cell.accessibilityTraits = .button + cell.setCustomLeftSeparatorInset(true) + hideSeparator(for: cell, true) + + if item.key == RowKey.trackWaypoints.rawValue, let trackItem = item.obj(forKey: RowObjKey.importTrackItem.rawValue) as? ImportTrackItem { + cell.valueLabel.text = "\(trackItem.selectedPoints.count)/\(allPointsCount)" + cell.leftIconView.image = item.icon + cell.leftIconView.tintColor = item.iconTintColor + } + return cell + } + + func configuredFolderCardsCell(for item: OATableRowData, at indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: FolderCardsCell.reuseIdentifier, for: indexPath) as! FolderCardsCell + cell.selectionStyle = .none + cell.delegate = self + cell.cellIndex = indexPath + cell.state = foldersScrollState + cell.configureCell(.importTracks) + cell.setValues( + item.obj(forKey: RowObjKey.foldersValues.rawValue) as? [String] ?? [], + sizes: item.obj(forKey: RowObjKey.foldersSizes.rawValue) as? [NSNumber], + colors: nil, + hidden: nil, + addButtonTitle: item.string(forKey: RowObjKey.foldersAddButtonTitle.rawValue) ?? localizedString("shared_string_add"), + withSelectedIndex: Int32(item.integer(forKey: RowObjKey.foldersSelectedValue.rawValue)) + ) + return cell + } + + func configuredStatsCell(for item: OATableRowData, at indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: TrackStatsTableCell.reuseIdentifier, for: indexPath) as! TrackStatsTableCell + cell.selectionStyle = .none + cell.backgroundColor = .groupBg + cell.isAccessibilityElement = false + cell.accessibilityElementsHidden = true + if let statisticsCells = item.obj(forKey: RowObjKey.statisticsCells.rawValue) as? [OAGPXTableCellData] { + cell.configure(statistics: statisticsCells) + } + hideSeparator(for: cell, false) + return cell + } + + func configuredPreviewCell(for item: OATableRowData, at indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: OAImageDescTableViewCell.reuseIdentifier, for: indexPath) as! OAImageDescTableViewCell + cell.selectionStyle = .none + cell.backgroundColor = .groupBg + cell.descView.isHidden = true + cell.imageBottomToLabelConstraint.priority = .defaultLow + cell.imageBottomConstraint.priority = .required + cell.imageBottomConstraint.constant = 0 + cell.imageTopConstraint.constant = 0 + cell.iconView.contentMode = .scaleAspectFill + cell.iconView.clipsToBounds = true + cell.iconView.layer.cornerRadius = 10 + cell.iconViewHeight.constant = 96 + cell.isAccessibilityElement = false + cell.accessibilityElementsHidden = true + hideSeparator(for: cell, true) + + guard let trackItem = item.obj(forKey: RowObjKey.importTrackItem.rawValue) as? ImportTrackItem else { + return cell + } + + if let image = trackItem.previewImage { + cell.activityIndicatorView.stopAnimating() + cell.activityIndicatorView.isHidden = true + cell.iconView.image = image + } else { + cell.iconView.image = nil + cell.activityIndicatorView.isHidden = false + cell.activityIndicatorView.startAnimating() + } + return cell + } +} + +// MARK: - Setup & State + +private extension ImportTracksViewController { + static func resolveInitialFolderPath(from selectedFolderPath: String?) -> String { + guard let selectedFolderPath, !selectedFolderPath.isEmpty else { + let gpxPath = OsmAndApp.swiftInstance()?.gpxPath as String? ?? "" + return gpxPath.appending("/import") + } + + if selectedFolderPath.lowercased().hasSuffix(".gpx") { + return (selectedFolderPath as NSString).deletingLastPathComponent + } + return selectedFolderPath + } + + func setupProgressView() { + progressStackView.translatesAutoresizingMaskIntoConstraints = false + progressStackView.axis = .vertical + progressStackView.alignment = .center + progressStackView.spacing = 16 + progressStackView.addArrangedSubview(progressIndicator) + progressStackView.addArrangedSubview(progressLabel) + view.addSubview(progressStackView) + + progressLabel.font = .preferredFont(forTextStyle: .subheadline) + progressLabel.textColor = .textColorSecondary + progressLabel.textAlignment = .center + progressLabel.numberOfLines = 0 + progressLabel.adjustsFontForContentSizeCategory = true + + NSLayoutConstraint.activate([ + progressStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + progressStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + progressStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + func updateProgress() { + let progressVisible = isCollectingTracks || isSavingTracks + progressLabel.text = isCollectingTracks + ? localizedString("reading_file") + localizedString("shared_string_ellipsis") + : String(format: localizedString("importing_from"), fileName) + + if progressVisible { + progressIndicator.startAnimating() + } else { + progressIndicator.stopAnimating() + } + progressStackView.isHidden = !progressVisible + tableView.isHidden = progressVisible + topButton.isHidden = progressVisible + separatorBottomView.isHidden = progressVisible + + progressStackView.isAccessibilityElement = true + progressStackView.accessibilityLabel = progressLabel.text + progressIndicator.isAccessibilityElement = false + } + + func collectTracks() { + collectTracksTask = CollectTracksTask(gpxFile: gpxFile, fileName: fileName, listener: self) + collectTracksTask?.execute() + } + + func updateButtonsState() { + topButton.isEnabled = !selection.isEmpty + updateBottomButtons() + updateSelectAllButtonTitle() + } + + func updateSelectAllButtonTitle() { + let allSelected = !trackItems.isEmpty && selection.areAllSelected + let title = localizedString(allSelected ? "shared_string_deselect_all" : "shared_string_select_all") + guard let item = navigationItem.rightBarButtonItems?.first else { return } + item.title = title + item.accessibilityLabel = title + } + + func trackItem(from indexPath: IndexPath) -> ImportTrackItem? { + tableData.item(for: indexPath).obj(forKey: RowObjKey.importTrackItem.rawValue) as? ImportTrackItem + } + + func hideSeparator(for cell: UITableViewCell, _ isHidden: Bool) { + let inset = max(UIScreen.main.bounds.width, UIScreen.main.bounds.height) + cell.separatorInset = UIEdgeInsets( + top: 0, + left: isHidden ? inset : 16, + bottom: 0, + right: isHidden ? -inset : 16 + ) + } + + func makeImportTracksDescription(tracksCount: Int) -> NSAttributedString { + let text = tracksCount == 1 + ? String(format: localizedString("import_tracks_descr_one"), fileName) + : String(format: localizedString("import_tracks_descr_other"), fileName, tracksCount) + + let result = NSMutableAttributedString( + string: text, + attributes: [ + .font: UIFont.preferredFont(forTextStyle: .body), + .foregroundColor: UIColor.textColorPrimary + ] + ) + + let fileRange = (text as NSString).range(of: fileName) + if fileRange.location != NSNotFound { + result.addAttribute(.foregroundColor, value: UIColor.textColorActive, range: fileRange) + } + return result + } + + func reloadPreviewRow(for item: ImportTrackItem) { + for section in 0.. Int { + let relative = folderRelativePath(for: path) + return folderNames.firstIndex(of: relative) ?? 0 + } + + func folderDisplayName(for path: String) -> String { + folderRelativePath(for: path) + } + + func folderPath(forDisplayName name: String) -> String { + let gpxPath = OsmAndApp.swiftInstance()?.gpxPath ?? "" + if name == localizedString("shared_string_gpx_tracks") { + return gpxPath + } + var result = gpxPath + for component in name.split(separator: "/") { + result = (result as NSString).appendingPathComponent(String(component)) + } + return result + } + + func folderTrackCounts() -> [NSNumber] { + let gpxPath = OsmAndApp.swiftInstance().gpxPath ?? "" + let items = GpxDbHelper.shared.getItems() + let rootTitle = localizedString("shared_string_gpx_tracks") + + return folderNames.map { name in + let folderPath = folderPath(forDisplayName: name) + let count = items.filter { item in + let filePath = item.file.path() + if name == rootTitle { + return (filePath as NSString).deletingLastPathComponent == gpxPath + } + return filePath.hasPrefix(folderPath + "/") + }.count + return NSNumber(value: count) + } + } + + func indexPathForFoldersRow() -> IndexPath? { + for section in 0.. String { + (fileName as NSString).deletingPathExtension + } + + func folderRelativePath(for absolutePath: String) -> String { + let gpxPath = OsmAndApp.swiftInstance()?.gpxPath ?? "" + if absolutePath.isEmpty || absolutePath == gpxPath { + return localizedString("shared_string_gpx_tracks") + } + let prefix = gpxPath.hasSuffix("/") ? gpxPath : gpxPath + "/" + guard absolutePath.hasPrefix(prefix) else { + return (absolutePath as NSString).lastPathComponent + } + return String(absolutePath.dropFirst(prefix.count)) + } +} + +// MARK: - Actions + +private extension ImportTracksViewController { + @objc func onSelectAllAction() { + if selection.areAllSelected { + selection.deselectAll() + } else { + selection.selectAll() + } + updateButtonsState() + tableView.reloadData() + } + + func importSelectedTracksAction() { + guard !isCollectingTracks, !isSavingTracks, !selection.isEmpty else { return } + let items = selection.selectedItems.filter { $0.savedPath == nil }.sorted { $0.index < $1.index } + guard !items.isEmpty else { return } + saveTracksTask = SaveTracksTask(items: items, destinationDir: selectedFolderPath, listener: self) + saveTracksTask?.execute() + } + + func onImportAsOneTrackClicked() { + guard !isCollectingTracks, !isSavingTracks else { return } + + let plannedPath = SaveGpxAsyncTask.plannedDestinationPath(destinationDir: selectedFolderPath, fileName: fileName) + if FileManager.default.fileExists(atPath: plannedPath) { + showFileExistsAlert { [weak self] overwrite in + self?.startSaveAsOneTrack(overwrite: overwrite) + } + } else { + startSaveAsOneTrack(overwrite: false) + } + } + + func startSaveAsOneTrack(overwrite: Bool) { + saveAsOneTrackTask = SaveGpxAsyncTask( + gpxFile: gpxFile, + destinationDir: selectedFolderPath, + fileName: fileName, + overwrite: overwrite, + importURL: importURL, + listener: self + ) + saveAsOneTrackTask?.execute() + } + + func onTrackItemSelected(at indexPath: IndexPath) { + guard let trackItem = trackItem(from: indexPath) else { return } + selection.toggle(trackItem) + updateButtonsState() + tableView.reloadData() + } + + @objc func showExitConfirmationAction() { + guard !isSavingTracks else { return } + + let alert = UIAlertController( + title: localizedString("import_tracks_cancel_title"), + message: localizedString("import_tracks_cancel_descr"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: localizedString("shared_string_continue"), style: .default)) + alert.addAction(UIAlertAction(title: localizedString("shared_string_close"), style: .destructive) { [weak self] _ in + self?.collectTracksTask?.cancelled = true + self?.dismiss(animated: true) + }) + present(alert, animated: true) + } + + func showFileExistsAlert(onChoice: @escaping (Bool) -> Void) { + let alert = UIAlertController( + title: localizedString("import_tracks"), + message: localizedString("gpx_import_already_exists"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: localizedString("gpx_overwrite"), style: .destructive) { _ in onChoice(true) }) + alert.addAction(UIAlertAction(title: localizedString("gpx_add_new"), style: .default) { _ in onChoice(false) }) + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + present(alert, animated: true) + } + + func onFoldersListSelected() { + guard let vc = OASelectTrackFolderViewController(selectedFolderName: folderDisplayName(for: selectedFolderPath)) else { + return + } + vc.delegate = self + vc.suggestedFolderName = suggestedFolderNameFromFile() + present(UINavigationController(rootViewController: vc), animated: true) + } + + func onAddFolderSelected() { + let vc = OAAddTrackFolderViewController() + vc.delegate = self + vc.suggestedFolderName = suggestedFolderNameFromFile() + present(UINavigationController(rootViewController: vc), animated: true) + } + + func onTrackItemPointsSelected(track: ImportTrackItem) { + let vc = SelectPointsViewController(track: track, allPoints: gpxFile.getPointsList()) + vc.delegate = self + let navController = UINavigationController(rootViewController: vc) + navController.modalPresentationStyle = .fullScreen + present(navController, animated: true) + } +} + +// MARK: - Post Import + +private extension ImportTracksViewController { + func showSaveError(_ message: String) { + OAUtilities.showToast(nil, details: message, duration: 4, verticalOffset: 120, in: view) + } + + func notifyImportFinished(success: Bool) { + delegate?.importTracksViewController?(self, didSaveTrack: success, gpxFile: gpxFile) + delegate?.importTracksViewControllerDidFinishImport?(self, success: success) + importCompletion?(success) + } + + func finishImportSuccessfully() { + if let importURL { + OAUtilities.denyAccess(toFile: importURL.path, removeFromInbox: true) + } + + notifyImportFinished(success: true) + + dismiss(animated: true) { + self.handlePostImportNavigation() + NotificationCenter.default.post(name: NSNotification.Name.OAGPXImportUIHelperDidFinishImport, object: nil) + } + } + + func handlePostImportNavigation() { + if successfulSaveCount <= 1 { + openSavedTrackOnMap() + } else { + openMyPlacesTracksFolder() + } + } + + func openSavedTrackOnMap() { + guard let path = lastSavedPath ?? (gpxFile.path.isEmpty ? nil : gpxFile.path), + let dataItem = OAGPXDatabase.sharedDb().getGPXItem(path) else { return } + + let trackItem = TrackItem(file: dataItem.file) + trackItem.dataItem = dataItem + OARootViewController.instance().navigationController?.popToRootViewController(animated: false) + OARootViewController.instance().mapPanel.openTargetView(withGPX: trackItem) + } + + func openMyPlacesTracksFolder() { + MyPlacesNavigator(rootViewController: OARootViewController.instance()).openTracks(inFolder: selectedFolderPath) + } +} + +// MARK: - CollectTracksListener + +extension ImportTracksViewController: CollectTracksListener { + func tracksCollectionStarted() { + isCollectingTracks = true + updateProgress() + } + + func tracksCollectionFinished(_ items: [ImportTrackItem]) { + collectTracksTask = nil + isCollectingTracks = false + trackItems = items + selection = SelectionManager(allItems: items, initiallySelected: items) + + DispatchQueue.global(qos: .userInitiated).async { + self.foldersSizes = self.folderTrackCounts() + + for item in self.trackItems { + guard let analysis = item.analysis else { continue } + item.statisticsCells = OATrackMenuHeaderView.generateGpxBlockStatistics(analysis, withoutGaps: false) as? [OAGPXTableCellData] ?? [] + } + + DispatchQueue.main.async { + self.postCollectTracks() + } + } + } + + private func postCollectTracks() { + updateProgress() + updateNavbar() + updateButtonsState() + generateData() + tableView.reloadData() + + let params = MapDrawParams.importTrackPreviewParams(size: CGSize(width: tableView.bounds.width - 64, height: 96)) + trackPreviewManager.startPreviews(for: trackItems, params: params) { [weak self] item in + DispatchQueue.main.async { + self?.reloadPreviewRow(for: item) + } + } + } +} + +// MARK: - FolderCardsCellDelegate + +extension ImportTracksViewController: FolderCardsCellDelegate { + func onItemSelected(_ index: Int) { + guard folderNames.indices.contains(index) else { return } + selectedFolderIndex = index + selectedFolderPath = folderPath(forDisplayName: folderNames[index]) + generateData() + tableView.reloadData() + } + + func onAddFolderButtonPressed() { + onAddFolderSelected() + } +} + +// MARK: - OASelectTrackFolderDelegate + +extension ImportTracksViewController: OASelectTrackFolderDelegate { + func onFolderSelected(_ selectedFolderName: String?) { + guard let selectedFolderName else { return } + applyFolderSelection(named: selectedFolderName) + } + + func onFolderAdded(_ addedFolderName: String) { + createAndSelectFolder(named: addedFolderName) + } +} + +// MARK: - OAAddTrackFolderDelegate + +extension ImportTracksViewController: OAAddTrackFolderDelegate { + func onTrackFolderAdded(_ folderName: String) { + createAndSelectFolder(named: folderName) + dismiss(animated: true) + } +} + +// MARK: - SelectPointsDelegate + +extension ImportTracksViewController: SelectPointsDelegate { + func onPointsSelected(_ trackItem: ImportTrackItem, selectedPoints: [WptPt]) { + trackItem.selectedPoints = selectedPoints + tableView.reloadData() + } +} + +// MARK: - SaveImportedGpxListener + +extension ImportTracksViewController: SaveImportedGpxListener { + func onGpxSavingStarted() { + isSavingTracks = true + updateProgress() + updateNavbar() + successfulSaveCount = 0 + } + + func onGpxSaved(error: String?, savedPath: String?) { + if let error { + debugPrint("Save GPX error:", error) + return + } + successfulSaveCount += 1 + lastSavedPath = savedPath + + guard let savedPath, + let item = trackItems.first(where: { $0.savedPath == savedPath }), + selection.selectedItems.contains(item) else { return } + selection.toggle(item) + } + + func onGpxSavingFinished(warning: [String]) { + saveAsOneTrackTask = nil + saveTracksTask = nil + isSavingTracks = false + updateProgress() + updateNavbar() + + if warning.isEmpty { + finishImportSuccessfully() + } else { + showSaveError(warning.joined(separator: "\n")) + updateButtonsState() + tableView.reloadData() + } + } +} diff --git a/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/OATrackPreviewMapRenderer.h b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/OATrackPreviewMapRenderer.h new file mode 100644 index 0000000000..4efbbfb2ba --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/OATrackPreviewMapRenderer.h @@ -0,0 +1,31 @@ +// +// OATrackPreviewMapRenderer.h +// OsmAnd +// +// Created by Vitaliy Sova on 10.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +#import +#import + +@class OASGpxFile; + +NS_ASSUME_NONNULL_BEGIN + +@interface OATrackPreviewMapRenderer : NSObject + ++ (instancetype)shared; + +- (void)renderGpxFile:(OASGpxFile *)gpxFile + widthPx:(NSInteger)widthPx + heightPx:(NSInteger)heightPx + density:(float)density + trackColor:(int)trackColor + completion:(void (^)(UIImage * _Nullable image))completion; + +- (void)cancelAll; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/OATrackPreviewMapRenderer.mm b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/OATrackPreviewMapRenderer.mm new file mode 100644 index 0000000000..d7fff936c4 --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/OATrackPreviewMapRenderer.mm @@ -0,0 +1,368 @@ +// +// OATrackPreviewMapRenderer.mm +// OsmAnd Maps +// +// Created by Vitaliy Sova on 10.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +#import "OATrackPreviewMapRenderer.h" +#import "OARootViewController.h" +#import "OAMapPanelViewController.h" +#import "OAMapViewController.h" +#import "OAMapRendererEnvironment.h" +#import "OANativeUtilities.h" + +#import "OsmAndSharedWrapper.h" +#import "OAUtilities.h" +#import "OsmAnd_Maps-Swift.h" + +#include +#include +#include +#include +#include +#include + +namespace { + constexpr int kMinZoom = 7; + constexpr int kMaxZoom = 17; + constexpr int kInitialZoom = 15; + constexpr CGFloat kTrackLineWidth = 4.0; + constexpr CGFloat kWaypointRadius = 5.0; + constexpr CGFloat kWaypointStrokeWidth = 1.5; +} + +@implementation OATrackPreviewMapRenderer +{ + dispatch_queue_t _queue; + std::atomic_bool _cancelled; +} + ++ (instancetype)shared +{ + static OATrackPreviewMapRenderer *instance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[OATrackPreviewMapRenderer alloc] init]; + }); + return instance; +} + +- (instancetype)init +{ + self = [super init]; + if (self) + { + _queue = dispatch_queue_create("net.osmand.track_preview_map", DISPATCH_QUEUE_SERIAL); + _cancelled = false; + } + return self; +} + +- (void)cancelAll +{ + _cancelled = true; +} + +- (BOOL)isCancelled +{ + return _cancelled; +} + +#pragma mark - Public API + +- (void)renderGpxFile:(OASGpxFile *)gpxFile + widthPx:(NSInteger)widthPx + heightPx:(NSInteger)heightPx + density:(float)density + trackColor:(int)trackColor + completion:(void (^)(UIImage * _Nullable))completion +{ + _cancelled = false; + + OAMapViewController *mapVC = OARootViewController.instance.mapPanel.mapViewController; + std::shared_ptr primitivesProvider = + mapVC.mapRendererEnv.mapPrimitivesProvider; + + if (!primitivesProvider) + { + dispatch_async(dispatch_get_main_queue(), ^{ completion(nil); }); + return; + } + + __weak __typeof(self) weakSelf = self; + dispatch_async(_queue, ^{ + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf || [strongSelf isCancelled]) + { + dispatch_async(dispatch_get_main_queue(), ^{ completion(nil); }); + return; + } + + UIImage *image = [strongSelf renderImageWithProvider:primitivesProvider + gpxFile:gpxFile + widthPx:widthPx + heightPx:heightPx + density:density + trackColor:trackColor]; + dispatch_async(dispatch_get_main_queue(), ^{ + completion([strongSelf isCancelled] ? nil : image); + }); + }); +} + +#pragma mark - Rendering + +- (UIImage *)renderImageWithProvider:(const std::shared_ptr &)primitivesProvider + gpxFile:(OASGpxFile *)gpxFile + widthPx:(NSInteger)widthPx + heightPx:(NSInteger)heightPx + density:(float)density + trackColor:(int)trackColor +{ + OASKQuadRect *rect = [gpxFile getRect]; + if ([rect hasInitialState]) + return nil; + + const double centerLat = rect.centerY; + const double centerLon = rect.centerX; + + const int pixelWidth = (int)round(widthPx * density); + const int pixelHeight = (int)round(heightPx * density); + + const auto rasterProvider = std::make_shared(primitivesProvider, true, false, true); + const uint32_t tileSize = rasterProvider->getTileSize(); + const int zoom = [self zoomLevelForBounds:rect + centerLat:centerLat + centerLon:centerLon + pixelWidth:pixelWidth + pixelHeight:pixelHeight + tileSize:tileSize]; + + const double centerPxX = OsmAnd::Utilities::getTileNumberX(zoom, centerLon) * tileSize; + const double centerPxY = OsmAnd::Utilities::getTileNumberY(zoom, centerLat) * tileSize; + const double leftPx = centerPxX - pixelWidth / 2.0; + const double topPx = centerPxY - pixelHeight / 2.0; + + UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat]; + format.scale = 1; + format.opaque = YES; + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(pixelWidth, pixelHeight) format:format]; + + UIImage *result = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) { + [[UIColor whiteColor] setFill]; + [ctx fillRect:CGRectMake(0, 0, pixelWidth, pixelHeight)]; + + [self drawMapTilesWithProvider:rasterProvider + zoom:zoom + tileSize:tileSize + leftPx:leftPx + topPx:topPx + pixelWidth:pixelWidth + pixelHeight:pixelHeight]; + + [self drawTrackSegmentsForGpxFile:gpxFile + zoom:zoom + tileSize:tileSize + leftPx:leftPx + topPx:topPx + density:density + trackColor:trackColor]; + + [self drawWaypointsForGpxFile:gpxFile + zoom:zoom + tileSize:tileSize + leftPx:leftPx + topPx:topPx + density:density + trackColor:trackColor]; + }]; + + return [UIImage imageWithCGImage:result.CGImage scale:density orientation:UIImageOrientationUp]; +} + +#pragma mark - Zoom + +- (int)zoomLevelForBounds:(OASKQuadRect *)rect + centerLat:(double)centerLat + centerLon:(double)centerLon + pixelWidth:(int)pixelWidth + pixelHeight:(int)pixelHeight + tileSize:(uint32_t)tileSize +{ + auto boundsFitInViewport = [&](int zoom) -> bool { + double centerPxX = OsmAnd::Utilities::getTileNumberX(zoom, centerLon) * tileSize; + double centerPxY = OsmAnd::Utilities::getTileNumberY(zoom, centerLat) * tileSize; + double leftPx = OsmAnd::Utilities::getTileNumberX(zoom, rect.left) * tileSize; + double rightPx = OsmAnd::Utilities::getTileNumberX(zoom, rect.right) * tileSize; + double topPx = OsmAnd::Utilities::getTileNumberY(zoom, rect.top) * tileSize; + double bottomPx = OsmAnd::Utilities::getTileNumberY(zoom, rect.bottom) * tileSize; + return leftPx >= centerPxX - pixelWidth / 2.0 + && rightPx <= centerPxX + pixelWidth / 2.0 + && topPx >= centerPxY - pixelHeight / 2.0 + && bottomPx <= centerPxY + pixelHeight / 2.0; + }; + + int zoom = kInitialZoom; + while (zoom < kMaxZoom && boundsFitInViewport(zoom + 1)) + zoom++; + while (zoom >= kMinZoom && !boundsFitInViewport(zoom)) + zoom--; + return MAX(zoom, kMinZoom); +} + +#pragma mark - Drawing + +- (void)drawMapTilesWithProvider:(const std::shared_ptr &)rasterProvider + zoom:(int)zoom + tileSize:(uint32_t)tileSize + leftPx:(double)leftPx + topPx:(double)topPx + pixelWidth:(int)pixelWidth + pixelHeight:(int)pixelHeight +{ + const int maxTile = (1 << zoom) - 1; + const int txMin = MAX(0, (int)floor(leftPx / tileSize)); + const int txMax = MIN(maxTile, (int)floor((leftPx + pixelWidth) / tileSize)); + const int tyMin = MAX(0, (int)floor(topPx / tileSize)); + const int tyMax = MIN(maxTile, (int)floor((topPx + pixelHeight) / tileSize)); + + std::shared_ptr queryController; + queryController.reset(new OsmAnd::FunctorQueryController([self](const OsmAnd::FunctorQueryController* const) { + return [self isCancelled]; + })); + + for (int ty = tyMin; ty <= tyMax; ty++) + { + for (int tx = txMin; tx <= txMax; tx++) + { + if ([self isCancelled]) + return; + + OsmAnd::IMapTiledDataProvider::Request request; + request.tileId = OsmAnd::TileId::fromXY(tx, ty); + request.zoom = (OsmAnd::ZoomLevel)zoom; + request.queryController = queryController; + + std::shared_ptr data; + if (!rasterProvider->obtainRasterizedTile(request, data) || !data || data->images.isEmpty()) + continue; + + const auto skImage = data->images.constBegin().value(); + UIImage *tileImage = [OANativeUtilities skImageToUIImage:skImage]; + if (!tileImage) + continue; + + CGRect tileRect = CGRectMake(tx * (double)tileSize - leftPx, + ty * (double)tileSize - topPx, + tileSize, tileSize); + [tileImage drawInRect:tileRect]; + } + } +} + +- (void)drawTrackSegmentsForGpxFile:(OASGpxFile *)gpxFile + zoom:(int)zoom + tileSize:(uint32_t)tileSize + leftPx:(double)leftPx + topPx:(double)topPx + density:(float)density + trackColor:(int)trackColor +{ + NSArray *segments = [TrackPreviewColorHelper previewSegmentsFor:gpxFile]; + const CGFloat lineWidth = kTrackLineWidth * density; + + for (OASTrkSegment *segment in segments) + { + if ([self isCancelled]) + return; + + int segmentColor = [TrackPreviewColorHelper resolvedColorWithGpxFile:gpxFile + segment:segment + defaultColor:trackColor]; + UIBezierPath *path = [self trackPathForSegment:segment + zoom:zoom + tileSize:tileSize + leftPx:leftPx + topPx:topPx + lineWidth:lineWidth]; + if (!path) + continue; + + [UIColorFromARGB(segmentColor) setStroke]; + [path stroke]; + } +} + +- (UIBezierPath *)trackPathForSegment:(OASTrkSegment *)segment + zoom:(int)zoom + tileSize:(uint32_t)tileSize + leftPx:(double)leftPx + topPx:(double)topPx + lineWidth:(CGFloat)lineWidth +{ + UIBezierPath *path = [UIBezierPath bezierPath]; + path.lineWidth = lineWidth; + path.lineJoinStyle = kCGLineJoinRound; + path.lineCapStyle = kCGLineCapRound; + + BOOL hasPoints = NO; + for (OASWptPt *point in segment.points) + { + CGPoint mappedPoint = [self mapPoint:point zoom:zoom tileSize:tileSize leftPx:leftPx topPx:topPx]; + if (!hasPoints) + { + [path moveToPoint:mappedPoint]; + hasPoints = YES; + } + else + { + [path addLineToPoint:mappedPoint]; + } + } + + return hasPoints ? path : nil; +} + +- (void)drawWaypointsForGpxFile:(OASGpxFile *)gpxFile + zoom:(int)zoom + tileSize:(uint32_t)tileSize + leftPx:(double)leftPx + topPx:(double)topPx + density:(float)density + trackColor:(int)trackColor +{ + int pointsColor = [TrackPreviewColorHelper resolvedColorWithGpxFile:gpxFile segment:nil defaultColor:trackColor]; + UIColor *waypointColor = UIColorFromARGB(pointsColor); + const CGFloat radius = kWaypointRadius * density; + + for (OASWptPt *point in [gpxFile getPointsList]) + { + if ([self isCancelled]) + return; + + CGPoint mappedPoint = [self mapPoint:point zoom:zoom tileSize:tileSize leftPx:leftPx topPx:topPx]; + CGRect circle = CGRectMake(mappedPoint.x - radius, mappedPoint.y - radius, radius * 2, radius * 2); + UIBezierPath *dot = [UIBezierPath bezierPathWithOvalInRect:circle]; + [waypointColor setFill]; + [dot fill]; + [[UIColor whiteColor] setStroke]; + dot.lineWidth = kWaypointStrokeWidth * density; + [dot stroke]; + } +} + +- (CGPoint)mapPoint:(OASWptPt *)point + zoom:(int)zoom + tileSize:(uint32_t)tileSize + leftPx:(double)leftPx + topPx:(double)topPx +{ + return CGPointMake( + OsmAnd::Utilities::getTileNumberX(zoom, point.lon) * tileSize - leftPx, + OsmAnd::Utilities::getTileNumberY(zoom, point.lat) * tileSize - topPx + ); +} + +@end diff --git a/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackBitmapDrawer.swift b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackBitmapDrawer.swift new file mode 100644 index 0000000000..95288b3d2f --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackBitmapDrawer.swift @@ -0,0 +1,121 @@ +// +// TrackBitmapDrawer.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 10.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit +import OsmAndShared + +protocol MapBitmapDrawerDelegate: AnyObject { + func onBitmapDrawing() + func onBitmapDrawn(_ success: Bool) + func onBitmapDrawn(image: UIImage) +} + +extension MapBitmapDrawerDelegate { + func onBitmapDrawing() {} + func onBitmapDrawn(_ success: Bool) {} +} + +@objcMembers +final class MapDrawParams: NSObject { + let density: Float + let widthPixels: Int + let heightPixels: Int + + init(density: Float, widthPixels: Int, heightPixels: Int) { + self.density = density + self.widthPixels = widthPixels + self.heightPixels = heightPixels + } + + static func importTrackPreviewParams(size: CGSize) -> MapDrawParams { + MapDrawParams( + density: Float(UIScreen.main.scale), + widthPixels: max(1, Int(size.width)), + heightPixels: max(1, Int(size.height)) + ) + } +} + +class MapBitmapDrawer { + let params: MapDrawParams + var isDrawingAllowed = true + + private var listeners: [MapBitmapDrawerDelegate] = [] + + init(params: MapDrawParams) { + self.params = params + } + + func addListener(_ listener: MapBitmapDrawerDelegate) { + guard !listeners.contains(where: { $0 === listener }) else { return } + listeners.append(listener) + } + + func removeListener(_ listener: MapBitmapDrawerDelegate) { + listeners.removeAll { $0 === listener } + } + + func notifyDrawing() { + listeners.forEach { $0.onBitmapDrawing() } + } + + func notifyDrawn(_ success: Bool) { + listeners.forEach { $0.onBitmapDrawn(success) } + } + + func notifyDrawn(image: UIImage) { + listeners.forEach { $0.onBitmapDrawn(image: image) } + } +} + +final class TrackBitmapDrawer: MapBitmapDrawer { + var defaultTrackColor: Int32 = 0 + + private let gpxFile: GpxFile + + init(params: MapDrawParams, gpxFile: GpxFile) { + self.gpxFile = gpxFile + super.init(params: params) + } + + func initAndDraw() { + notifyDrawing() + guard isDrawingAllowed else { return } + + let trackColor = resolvedTrackColor() + OATrackPreviewMapRenderer.shared().renderGpxFile( + gpxFile, + widthPx: params.widthPixels, + heightPx: params.heightPixels, + density: params.density, + trackColor: trackColor + ) { [weak self] image in + guard let self, self.isDrawingAllowed else { return } + if let image { + self.notifyDrawn(true) + self.notifyDrawn(image: image) + } else { + self.drawStubPreview(trackColor: trackColor) + } + } + } + + private func resolvedTrackColor() -> Int32 { + defaultTrackColor != 0 ? defaultTrackColor : TrackPreviewColorHelper.appDefaultTrackColor() + } + + private func drawStubPreview(trackColor: Int32) { + TrackStubPreviewRenderer.shared.renderGpxFile(gpxFile, params: params, trackColor: trackColor) { [weak self] image in + guard let self, self.isDrawingAllowed else { return } + self.notifyDrawn(image != nil) + if let image { + self.notifyDrawn(image: image) + } + } + } +} diff --git a/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackPreviewColorHelper.swift b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackPreviewColorHelper.swift new file mode 100644 index 0000000000..9eaa9b1563 --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackPreviewColorHelper.swift @@ -0,0 +1,47 @@ +// +// TrackPreviewColorHelper.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 12.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared + +@objcMembers +final class TrackPreviewColorHelper: NSObject { + + static func appDefaultTrackColor() -> Int32 { + let settingsColor = Int32(OAAppSettings.sharedManager().currentTrackColor.get()) + if settingsColor != 0 { return settingsColor } + return Int32(bitPattern: UInt32(truncatingIfNeeded: kDefaultTrackColor)) + } + + static func resolvedColor(gpxFile: GpxFile, segment: TrkSegment?, defaultColor: Int32) -> Int32 { + let fallbackColor = defaultColor != 0 ? defaultColor : appDefaultTrackColor() + + if let segment, let segmentColor = segment.getColor(defColor: 0)?.intValue, segmentColor != 0 { + return Int32(segmentColor) + } + + if let track = (gpxFile.tracks as? [Track])?.first, + let trackColor = track.getColor(defColor: 0)?.intValue, trackColor != 0 { + return Int32(trackColor) + } + + if let fileColor = gpxFile.getColor(defColor: 0)?.intValue, fileColor != 0 { + return Int32(fileColor) + } + + return fallbackColor + } + + static func previewSegments(for gpxFile: GpxFile) -> [TrkSegment] { + if let processed = gpxFile.processedPointsToDisplay, !processed.isEmpty { + return processed + } + guard let track = (gpxFile.tracks as? [Track])?.first else { return [] } + return (track.segments as? [TrkSegment]) ?? [] + } +} diff --git a/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackPreviewManager.swift b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackPreviewManager.swift new file mode 100644 index 0000000000..fac9f77390 --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackPreviewManager.swift @@ -0,0 +1,63 @@ +// +// TrackPreviewManager.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 10.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class TrackPreviewManager { + private var listeners: [ImportTrackItem: TrackPreviewDrawerDelegate] = [:] + + func startPreviews( + for items: [ImportTrackItem], + params: MapDrawParams, + onUpdate: @escaping (ImportTrackItem) -> Void + ) { + for item in items { + guard item.previewImage == nil, item.bitmapDrawer == nil else { continue } + + let drawer = TrackBitmapDrawer(params: params, gpxFile: item.selectedGpxFile) + drawer.defaultTrackColor = TrackPreviewColorHelper.appDefaultTrackColor() + + let listener = TrackPreviewDrawerDelegate(item: item, onUpdate: onUpdate) + listeners[item] = listener + drawer.addListener(listener) + + item.bitmapDrawer = drawer + item.isPreviewLoading = true + drawer.initAndDraw() + } + } + + func cancelAll(_ items: [ImportTrackItem]) { + OATrackPreviewMapRenderer.shared().cancelAll() + TrackStubPreviewRenderer.shared.cancelAll() + + for item in items { + item.bitmapDrawer?.isDrawingAllowed = false + item.bitmapDrawer = nil + listeners[item] = nil + } + } +} + +private final class TrackPreviewDrawerDelegate: MapBitmapDrawerDelegate { + private let onUpdate: (ImportTrackItem) -> Void + + private weak var item: ImportTrackItem? + + init(item: ImportTrackItem, onUpdate: @escaping (ImportTrackItem) -> Void) { + self.item = item + self.onUpdate = onUpdate + } + + func onBitmapDrawn(image: UIImage) { + guard let item else { return } + item.previewImage = image + item.isPreviewLoading = false + onUpdate(item) + } +} diff --git a/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackStubPreviewRenderer.swift b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackStubPreviewRenderer.swift new file mode 100644 index 0000000000..72c66256de --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/MapPreviewRenderer/TrackStubPreviewRenderer.swift @@ -0,0 +1,154 @@ +// +// TrackStubPreviewRenderer.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 10.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit +import OsmAndShared + +@objcMembers +final class TrackStubPreviewRenderer: NSObject { + + static let shared = TrackStubPreviewRenderer() + + private let queue = DispatchQueue(label: "net.osmand.track-preview", qos: .userInitiated) + private var cancelled = false + + private var isCancelled: Bool { cancelled } + + // MARK: - Helpers + + private static func segmentPath( + segment: TrkSegment, + bounds: KQuadRect, + size: CGSize, + padding: CGFloat, + lineWidth: CGFloat + ) -> UIBezierPath? { + guard let points = segment.points as? [WptPt], points.count >= 2 else { return nil } + + let path = UIBezierPath() + path.lineWidth = lineWidth + path.lineJoinStyle = .round + path.lineCapStyle = .round + + let step = max(1, points.count / 250) + for index in stride(from: 0, to: points.count, by: step) { + let mappedPoint = mapPoint( + lat: points[index].lat, + lon: points[index].lon, + bounds: bounds, + size: size, + padding: padding + ) + if index == 0 { + path.move(to: mappedPoint) + } else { + path.addLine(to: mappedPoint) + } + } + + if !(points.count - 1).isMultiple(of: step), let last = points.last { + path.addLine(to: mapPoint(lat: last.lat, lon: last.lon, bounds: bounds, size: size, padding: padding)) + } + + return path + } + + private static func mapPoint( + lat: Double, + lon: Double, + bounds: KQuadRect, + size: CGSize, + padding: CGFloat + ) -> CGPoint { + let drawWidth = size.width - padding * 2 + let drawHeight = size.height - padding * 2 + let lonSpan = bounds.right - bounds.left + let latSpan = bounds.top - bounds.bottom + guard lonSpan > 0, latSpan > 0 else { + return CGPoint(x: size.width / 2, y: size.height / 2) + } + + let scale = min(drawWidth / lonSpan, drawHeight / latSpan) + let originX = padding + (drawWidth - lonSpan * scale) / 2 + let originY = padding + (drawHeight - latSpan * scale) / 2 + return CGPoint( + x: originX + CGFloat((lon - bounds.left) * scale), + y: originY + CGFloat((bounds.top - lat) * scale) + ) + } + + // MARK: - Public API + + func renderGpxFile( + _ gpxFile: GpxFile, + params: MapDrawParams, + trackColor: Int32, + completion: @escaping (UIImage?) -> Void + ) { + cancelled = false + queue.async { [weak self] in + guard let self, !self.isCancelled else { + DispatchQueue.main.async { completion(nil) } + return + } + let image = self.renderImage(gpxFile: gpxFile, params: params, trackColor: trackColor) + DispatchQueue.main.async { + completion(self.isCancelled ? nil : image) + } + } + } + + func cancelAll() { + cancelled = true + } + + // MARK: - Rendering + + private func renderImage(gpxFile: GpxFile, params: MapDrawParams, trackColor: Int32) -> UIImage? { + let width = max(1, params.widthPixels) + let height = max(1, params.heightPixels) + let bounds = gpxFile.getRect() + + guard !bounds.hasInitialState() else { return nil } + + let segments = TrackPreviewColorHelper.previewSegments(for: gpxFile) + guard !segments.isEmpty else { return nil } + + let size = CGSize(width: width, height: height) + let padding: CGFloat = 8 + let lineWidth = 3 * CGFloat(params.density) + + let format = UIGraphicsImageRendererFormat() + format.scale = CGFloat(params.density) + format.opaque = true + + return UIGraphicsImageRenderer(size: size, format: format).image { _ in + UIColor.groupBg.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + + for segment in segments { + guard !self.isCancelled else { return } + guard let path = Self.segmentPath( + segment: segment, + bounds: bounds, + size: size, + padding: padding, + lineWidth: lineWidth + ) else { continue } + + let color = TrackPreviewColorHelper.resolvedColor( + gpxFile: gpxFile, + segment: segment, + defaultColor: trackColor + ) + UIColor(argb: Int(color)).setStroke() + path.stroke() + } + } + } +} diff --git a/Sources/Controllers/MyPlaces/Import/SelectPointsViewController.swift b/Sources/Controllers/MyPlaces/Import/SelectPointsViewController.swift new file mode 100644 index 0000000000..2cd6c89ab4 --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/SelectPointsViewController.swift @@ -0,0 +1,636 @@ +// +// SelectPointsViewController.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit +import OsmAndShared + +protocol SelectPointsDelegate: AnyObject { + func onPointsSelected(_ trackItem: ImportTrackItem, selectedPoints: [WptPt]) +} + +final class SelectPointsViewController: OABaseButtonsViewController { + + private enum RowKey: String { + case infoDescr + case selectNearest + case group + case point + } + + private enum RowObjKey: String { + case attributedTitleKey = "attributedTitle" + case wptItem = "wptItem" + case group = "group" + } + + private enum SelectionState { + case none, part, all + } + + private final class WaypointGroup { + let index: Int + let name: String + let items: [OAGpxWptItem] + var isExpanded: Bool + + init(index: Int, name: String, items: [OAGpxWptItem], isExpanded: Bool) { + self.index = index + self.name = name + self.items = items + self.isExpanded = isExpanded + } + } + + weak var delegate: SelectPointsDelegate? + + private let track: ImportTrackItem + private let allPoints: [WptPt] + private let suggestedPoints: [WptPt] + private let selection: SelectionManager + private var groups: [WaypointGroup] = [] + + private var lastUpdate: TimeInterval? + private let updateLock = NSLock() + + // MARK: - Init + + init(track: ImportTrackItem, allPoints: [WptPt]) { + self.track = track + self.allPoints = allPoints + self.selection = SelectionManager(allItems: allPoints, initiallySelected: track.selectedPoints) + self.suggestedPoints = track.suggestedPoints + super.init() + } + + @available(*, unavailable) + override init() { + fatalError("init(track: ImportTrackItem, allPoints: [WptPt])") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupTable() + groups = makeGroups(from: allPoints) + generateData() + tableView.reloadData() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + updateDistanceAndDirection(force: true) + } + + override func registerObservers() { + super.registerObservers() + guard let app = OsmAndApp.swiftInstance() else { return } + let selector = #selector(updateDistanceAndDirection as () -> Void) + addObserver(OAAutoObserverProxy(self, withHandler: selector, andObserve: app.locationServices.updateLocationObserver)) + addObserver(OAAutoObserverProxy(self, withHandler: selector, andObserve: app.locationServices.updateHeadingObserver)) + } + + // MARK: - Table + + override func tableStyle() -> UITableView.Style { + .insetGrouped + } + + override func registerCells() { + addCell(OASimpleTableViewCell.reuseIdentifier) + addCell(OASelectionCollapsableCell.reuseIdentifier) + addCell(OAPointWithRegionTableViewCell.reuseIdentifier) + } + + override func generateData() { + tableData.clearAllData() + appendInfoSection() + groups.forEach { appendGroupSection(for: $0) } + } + + override func getRow(_ indexPath: IndexPath) -> UITableViewCell? { + let item = tableData.item(for: indexPath) + + switch item.cellType { + case OASimpleTableViewCell.reuseIdentifier: + return configuredSimpleCell(for: item, at: indexPath) + case OASelectionCollapsableCell.reuseIdentifier: + return configuredGroupCell(for: item, at: indexPath) + case OAPointWithRegionTableViewCell.reuseIdentifier: + return configuredPointCell(for: item, at: indexPath) + default: + return UITableViewCell() + } + } + + override func onRowSelected(_ indexPath: IndexPath) { + switch tableData.item(for: indexPath).key { + case RowKey.selectNearest.rawValue: + selectNearestAction() + case RowKey.point.rawValue: + togglePoint(at: indexPath) + default: + break + } + } + + override func onRowDeselected(_ indexPath: IndexPath) { + guard tableData.item(for: indexPath).key == RowKey.point.rawValue else { return } + togglePoint(at: indexPath) + } + + override func hideFirstHeader() -> Bool { true } + + override func getCustomHeight(forHeader section: Int) -> CGFloat { + tableData.sectionData(for: UInt(section)).headerText.isEmpty ? 16 : UITableView.automaticDimension + } + + override func getCustomHeight(forFooter section: Int) -> CGFloat { + tableData.sectionData(for: UInt(section)).footerText.isEmpty ? 0 : UITableView.automaticDimension + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard indexPath.section > 0, indexPath.row > 0 else { return } + guard let wptItem = tableData.item(for: indexPath).obj(forKey: RowObjKey.wptItem.rawValue) as? OAGpxWptItem else { return } + + if selection.selectedItems.contains(wptItem.point) { + tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + } else { + tableView.deselectRow(at: indexPath, animated: false) + } + cell.separatorInset = UIEdgeInsets(top: 0, left: cell.contentView.frame.minX + 66, bottom: 0, right: 16) + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + indexPath.section > 0 && indexPath.row > 0 + } + + override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + .none + } + + override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { + false + } + + // MARK: - NavBar + + override func getTitle() -> String? { + localizedString("shared_string_waypoints") + } + + override func getCustomIconForLeftNavbarButton() -> UIImage? { + guard let image = UIImage.templateImageNamed("ic_navbar_close") else { return nil } + return OAUtilities.resize(image, newSize: CGSize(width: 24, height: 24))?.withRenderingMode(.alwaysTemplate) + } + + override func getCustomAccessibilityForLeftNavbarButton() -> String? { + localizedString("shared_string_close") + } + + override func onLeftNavbarButtonPressed() { + showExitConfirmationAction() + } + + override func getRightNavbarButtons() -> [UIBarButtonItem]? { + let title = localizedString(selection.areAllSelected ? "shared_string_deselect_all" : "shared_string_select_all") + let item = UIBarButtonItem(title: title, style: .plain, target: self, action: #selector(onSelectAllAction)) + item.tintColor = .label + item.accessibilityLabel = title + return [item] + } + + override func updateNavbar() { + super.updateNavbar() + (getLeftNavbarButton()?.customView as? UIButton)?.tintColor = .label + } + + // MARK: - Bottom buttons + + override func getTopButtonTitle() -> String? { + localizedString("shared_string_apply") + } + + override func getTopButtonColorScheme() -> EOABaseButtonColorScheme { + .purple + } + + override func isBottomSeparatorVisible() -> Bool { false } + + override func onTopButtonPressed() { + applyAction() + } +} + +// MARK: - Table Data + +private extension SelectPointsViewController { + func appendInfoSection() { + let section = tableData.createNewSection() + + let descriptionRow = section.createNewRow() + descriptionRow.cellType = OASimpleTableViewCell.reuseIdentifier + descriptionRow.key = RowKey.infoDescr.rawValue + descriptionRow.setObj(makeTopDescription(), forKey: RowObjKey.attributedTitleKey.rawValue) + + if !suggestedPoints.isEmpty { + section.footerText = localizedString("auto_select_nearest_footer") + + let selectNearestRow = section.createNewRow() + selectNearestRow.cellType = OASimpleTableViewCell.reuseIdentifier + selectNearestRow.key = RowKey.selectNearest.rawValue + selectNearestRow.title = localizedString("auto_select_nearest_points") + } + } + + private func appendGroupSection(for group: WaypointGroup) { + let section = tableData.createNewSection() + + let groupRow = section.createNewRow() + groupRow.cellType = OASelectionCollapsableCell.reuseIdentifier + groupRow.key = RowKey.group.rawValue + groupRow.title = group.name + groupRow.setObj(group, forKey: RowObjKey.group.rawValue) + + guard group.isExpanded else { return } + + for item in group.items { + let pointRow = section.createNewRow() + pointRow.cellType = OAPointWithRegionTableViewCell.reuseIdentifier + pointRow.key = RowKey.point.rawValue + pointRow.title = item.point.name ?? "" + pointRow.setObj(item, forKey: RowObjKey.wptItem.rawValue) + } + } +} + +// MARK: - Cell Configuration + +private extension SelectPointsViewController { + func configuredSimpleCell(for item: OATableRowData, at indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: OASimpleTableViewCell.reuseIdentifier, for: indexPath) as! OASimpleTableViewCell + cell.leftIconVisibility(false) + cell.setCustomLeftSeparatorInset(true) + cell.textStackView.isHidden = false + + switch item.key { + case RowKey.infoDescr.rawValue: + cell.descriptionLabel.attributedText = item.obj(forKey: RowObjKey.attributedTitleKey.rawValue) as? NSAttributedString + cell.descriptionVisibility(true) + cell.titleVisibility(false) + hideSeparator(for: cell, false) + cell.selectionStyle = .none + cell.accessibilityLabel = cell.descriptionLabel.attributedText?.string + cell.accessibilityTraits = .staticText + case RowKey.selectNearest.rawValue: + cell.titleLabel.text = item.title + cell.titleLabel.textColor = .textColorActive + cell.titleVisibility(true) + cell.descriptionVisibility(false) + hideSeparator(for: cell, true) + cell.selectionStyle = .default + cell.accessibilityLabel = item.title + cell.accessibilityTraits = .button + default: + break + } + return cell + } + + func configuredGroupCell(for item: OATableRowData, at indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: OASelectionCollapsableCell.reuseIdentifier, for: indexPath) as! OASelectionCollapsableCell + guard let group = item.obj(forKey: RowObjKey.group.rawValue) as? WaypointGroup else { return cell } + + cell.selectionStyle = .none + cell.separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) + cell.showOptionsButton(false) + cell.makeSelectable(true) + cell.titleView.text = item.title + cell.leftIconView.image = .icCustomFolder + cell.leftIconView.tintColor = item.iconTintColor ?? .iconColorActive + cell.arrowIconView.tintColor = .iconColorActive + cell.arrowIconView.image = group.isExpanded ? .icCustomArrowDown : .icCustomArrowUp + cell.selectionButton.setImage(groupSelectionImage(for: group), for: .normal) + cell.selectionButton.tintColor = .iconColorActive + + configureGroupButton(cell.openCloseGroupButton, tag: group.index, action: #selector(openCloseGroupAction(_:))) + configureGroupButton(cell.selectionButton, tag: group.index, action: #selector(onGroupSelectTapped(_:))) + configureGroupButton(cell.selectionGroupButton, tag: group.index, action: #selector(onGroupSelectTapped(_:))) + + let state = groupSelectionState(for: group) + let selectedValue: String = switch state { + case .all: localizedString("shared_string_selected") + case .none: localizedString("shared_string_not_selected") + case .part: String(format: localizedString("ltr_or_rtl_combine_via_slash"), + "\(group.items.filter { selection.selectedItems.contains($0.point) }.count)", + "\(group.items.count)") + } + cell.isAccessibilityElement = true + cell.accessibilityLabel = group.name + cell.accessibilityValue = selectedValue + cell.accessibilityTraits = .button + + cell.openCloseGroupButton.accessibilityLabel = localizedString( + group.isExpanded ? "shared_string_collapse" : "shared_string_show" + ) + cell.selectionButton.isAccessibilityElement = false + cell.selectionGroupButton.isAccessibilityElement = false + cell.leftIconView.isAccessibilityElement = false + cell.arrowIconView.isAccessibilityElement = false + + cell.setNeedsUpdateConstraints() + return cell + } + + func configuredPointCell(for item: OATableRowData, at indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: OAPointWithRegionTableViewCell.reuseIdentifier, for: indexPath) as! OAPointWithRegionTableViewCell + guard let wptItem = item.obj(forKey: RowObjKey.wptItem.rawValue) as? OAGpxWptItem else { return cell } + + cell.setRegion(wptItem.point.getAddress() ?? "") + cell.titleView.text = wptItem.point.name ?? "" + cell.iconView.image = wptItem.compositeIconWithDefaultColor() + cell.setShowWaypointButtonVisiblity(false) + updatePointDistanceAndDirectionCell(cell, wptItem: wptItem) + + cell.contentView.backgroundColor = .groupBg + if cell.selectedBackgroundView?.backgroundColor != .groupBg { + let backgroundView = UIView() + backgroundView.backgroundColor = .groupBg + cell.selectedBackgroundView = backgroundView + } + + let isSelected = selection.selectedItems.contains(wptItem.point) + let name = wptItem.point.name ?? localizedString("shared_string_waypoint") + cell.isAccessibilityElement = true + cell.accessibilityLabel = name + cell.accessibilityTraits = isSelected ? [.button, .selected] : .button + cell.accessibilityValue = [ + wptItem.point.getAddress(), + wptItem.distance + ].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: ", ") + cell.iconView.isAccessibilityElement = false + cell.directionIconView.isAccessibilityElement = false + + cell.setNeedsUpdateConstraints() + return cell + } + + func configureGroupButton(_ button: UIButton, tag: Int, action: Selector) { + button.tag = tag + button.removeTarget(nil, action: nil, for: .allEvents) + button.addTarget(self, action: action, for: .touchUpInside) + } +} + +// MARK: - Setup & Helpers + +private extension SelectPointsViewController { + func setupTable() { + tableView.allowsMultipleSelectionDuringEditing = true + tableView.isEditing = true + } + + func hideSeparator(for cell: UITableViewCell, _ isHidden: Bool) { + let inset = max(UIScreen.main.bounds.width, UIScreen.main.bounds.height) + cell.separatorInset = UIEdgeInsets( + top: 0, + left: isHidden ? inset : 16, + bottom: 0, + right: isHidden ? -inset : 16 + ) + } + + func makeTopDescription() -> NSAttributedString { + let text = String(format: localizedString("selected_waypoints_descr"), track.name) + let baseFont = UIFont.preferredFont(forTextStyle: .body) + let boldFont = baseFont.fontDescriptor.withSymbolicTraits(.traitBold).map { UIFont(descriptor: $0, size: 0) } ?? baseFont + + let result = NSMutableAttributedString( + string: text, + attributes: [.font: baseFont, .foregroundColor: UIColor.textColorPrimary] + ) + + let fileRange = (text as NSString).range(of: track.name) + if fileRange.location != NSNotFound { + result.addAttribute(.font, value: boldFont, range: fileRange) + } + return result + } + + func updateSelectAllButtonTitle() { + let title = localizedString(selection.areAllSelected ? "shared_string_deselect_all" : "shared_string_select_all") + guard let item = navigationItem.rightBarButtonItems?.first else { return } + item.title = title + item.accessibilityLabel = title + } +} + +// MARK: - Groups + +private extension SelectPointsViewController { + private func makeGroups(from points: [WptPt]) -> [WaypointGroup] { + var groupedItems: [String: [OAGpxWptItem]] = [:] + let defaultName = localizedString("shared_string_gpx_points") + + for point in points { + let groupName = point.category.flatMap { $0.isEmpty ? nil : $0 } ?? defaultName + groupedItems[groupName, default: []].append(OAGpxWptItem.withGpxWpt(point)) + } + + return groupedItems.keys.sorted().compactMap { name -> (String, [OAGpxWptItem])? in + guard let items = groupedItems[name], !items.isEmpty else { return nil } + return (name, items) + }.enumerated().map { index, entry in + WaypointGroup(index: index, name: entry.0, items: entry.1, isExpanded: true) + } + } + + private func group(at indexPath: IndexPath) -> WaypointGroup? { + tableData.item(for: IndexPath(row: 0, section: indexPath.section)) + .obj(forKey: RowObjKey.group.rawValue) as? WaypointGroup + } + + private func groupSelectionState(for group: WaypointGroup) -> SelectionState { + guard !group.items.isEmpty else { return .none } + let selectedCount = group.items.filter { selection.selectedItems.contains($0.point) }.count + if selectedCount == 0 { return .none } + if selectedCount == group.items.count { return .all } + return .part + } + + private func groupSelectionImage(for group: WaypointGroup) -> UIImage? { + switch groupSelectionState(for: group) { + case .all: return UIImage(named: "ic_system_checkbox_selected") + case .part: return UIImage(named: "ic_system_checkbox_indeterminate") + case .none: return nil + } + } + + private func indexPath(forGroupAt index: Int) -> IndexPath { + IndexPath(row: 0, section: index + 1) + } + + private func toggleExpandGroup(_ group: WaypointGroup) { + group.isExpanded.toggle() + generateData() + tableView.reloadSections(IndexSet(integer: group.index + 1), with: .automatic) + } +} + +// MARK: - Points + +private extension SelectPointsViewController { + func togglePoint(at indexPath: IndexPath) { + guard let group = group(at: indexPath), + let wptItem = tableData.item(for: indexPath).obj(forKey: RowObjKey.wptItem.rawValue) as? OAGpxWptItem, + let point = wptItem.point else { return } + + let previousState = groupSelectionState(for: group) + selection.toggle(point) + + updateSelectAllButtonTitle() + if previousState != groupSelectionState(for: group) { + tableView.reloadRows(at: [IndexPath(row: 0, section: indexPath.section)], with: .none) + } + } +} + +// MARK: - Actions + +private extension SelectPointsViewController { + @objc func onSelectAllAction() { + if selection.areAllSelected { + selection.deselectAll() + } else { + selection.selectAll() + } + updateSelectAllButtonTitle() + tableView.reloadData() + } + + func applyAction() { + delegate?.onPointsSelected(track, selectedPoints: Array(selection.selectedItems)) + dismiss(animated: true) + } + + func selectNearestAction() { + selection.deselectAll() + Set(suggestedPoints).forEach { selection.toggle($0) } + updateSelectAllButtonTitle() + tableView.reloadData() + } + + @objc func showExitConfirmationAction() { + guard selection.hasChanges else { + dismiss(animated: true) + return + } + + let alert = UIAlertController( + title: localizedString("unsaved_changes"), + message: localizedString("selected_waypoints_exit_descr"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: localizedString("shared_string_continue"), style: .default)) + alert.addAction(UIAlertAction(title: localizedString("shared_string_close"), style: .destructive) { [weak self] _ in + self?.dismiss(animated: true) + }) + present(alert, animated: true) + } + + @objc func onGroupSelectTapped(_ sender: UIButton) { + guard let group = groups.first(where: { $0.index == sender.tag }) else { return } + + let points = group.items.compactMap(\.point) + let shouldDeselect = !points.isEmpty && points.allSatisfy { selection.selectedItems.contains($0) } + points.forEach { point in + if shouldDeselect == selection.selectedItems.contains(point) { + selection.toggle(point) + } + } + + updateSelectAllButtonTitle() + tableView.reloadSections(IndexSet(integer: indexPath(forGroupAt: group.index).section), with: .none) + } + + @objc func openCloseGroupAction(_ sender: UIButton) { + guard let group = groups.first(where: { $0.index == sender.tag }) else { return } + toggleExpandGroup(group) + } +} + +// MARK: - Distance And Direction + +private extension SelectPointsViewController { + func updateWaypointDistanceAndDirection(for item: OAGpxWptItem) { + guard let point = item.point, + let location = OsmAndApp.swiftInstance()?.locationServices?.lastKnownLocation else { + item.distance = nil + return + } + + let meters = OADistanceAndDirectionsUpdater.getDistanceFrom( + location, + toDestinationLatitude: point.lat, + destinationLongitude: point.lon + ) + item.distanceMeters = Double(meters) + item.distance = OAOsmAndFormatter.getFormattedDistance(Float(meters)) + item.direction = OADistanceAndDirectionsUpdater.getDirectionAngle( + from: location, + toDestinationLatitude: point.lat, + destinationLongitude: point.lon + ) + } + + @objc func updateDistanceAndDirection() { + updateDistanceAndDirection(force: false) + } + + func updateDistanceAndDirection(force: Bool) { + updateLock.lock() + defer { updateLock.unlock() } + + if let lastUpdate, Date.now.timeIntervalSince1970 - lastUpdate < 0.5, !force { + return + } + lastUpdate = Date.now.timeIntervalSince1970 + + groups.flatMap(\.items).forEach { updateWaypointDistanceAndDirection(for: $0) } + + DispatchQueue.main.async { [weak self] in + self?.refreshVisiblePointCells() + } + } + + func refreshVisiblePointCells() { + let visibleIndexPaths = tableView.indexPathsForVisibleRows?.filter { $0.section > 0 && $0.row > 0 } ?? [] + for indexPath in visibleIndexPaths { + guard let cell = tableView.cellForRow(at: indexPath) as? OAPointWithRegionTableViewCell, + let wptItem = tableData.item(for: indexPath).obj(forKey: RowObjKey.wptItem.rawValue) as? OAGpxWptItem else { continue } + updatePointDistanceAndDirectionCell(cell, wptItem: wptItem) + } + } + + func updatePointDistanceAndDirectionCell(_ cell: OAPointWithRegionTableViewCell, wptItem: OAGpxWptItem) { + if let distance = wptItem.distance, !distance.isEmpty { + cell.setDirection(distance) + cell.directionIconView.image = .icSmallDirection + cell.directionIconView.tintColor = .iconColorActive + UIView.animate(withDuration: 0.2, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction]) { + cell.directionIconView.transform = CGAffineTransform(rotationAngle: wptItem.direction) + } + } else { + cell.setDirection("") + } + } +} diff --git a/Sources/Controllers/MyPlaces/Import/Tasks/CollectTracksTask.swift b/Sources/Controllers/MyPlaces/Import/Tasks/CollectTracksTask.swift new file mode 100644 index 0000000000..35e5ac7c09 --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/Tasks/CollectTracksTask.swift @@ -0,0 +1,174 @@ +// +// CollectTracksTask.swift +// OsmAnd +// +// Created by Vitaliy Sova on 09.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared + +protocol CollectTracksListener: AnyObject { + func tracksCollectionStarted() + func tracksCollectionFinished(_ items: [ImportTrackItem]) +} + +final class CollectTracksTask: OAAsyncTask { + private let gpxFile: GpxFile + private let fileName: String + private weak var listener: CollectTracksListener? + + init(gpxFile: GpxFile, fileName: String, listener: CollectTracksListener?) { + self.gpxFile = gpxFile + self.fileName = fileName + self.listener = listener + super.init() + } + + override func onPreExecute() { + listener?.tracksCollectionStarted() + } + + override func doInBackground() -> Any? { + let baseName = fileName.deletingPathExtension() + let tracks = gpxFile.tracks as? [Track] ?? [] + let author = gpxAuthor() + var items: [ImportTrackItem] = [] + + var index = 0 + for track in tracks { + guard !isCancelled() else { return items } + guard !track.isGeneralTrack() else { continue } + items.append(makeImportTrackItem(from: track, at: index, baseName: baseName, author: author)) + index += 1 + } + + assignWaypoints(from: gpxFile, to: &items) + return items + } + + override func onPostExecute(result: Any?) { + let items = result as? [ImportTrackItem] ?? [] + listener?.tracksCollectionFinished(items) + } + + // MARK: - Track building + + private func makeImportTrackItem( + from track: Track, + at index: Int, + baseName: String, + author: String + ) -> ImportTrackItem { + let trackFile = makeSingleTrackGpxFile(from: track, author: author) + let trackName = resolvedTrackName(for: track, index: index, baseName: baseName) + let analysis = trackFile.getAnalysis( + fileTimestamp: 0, + fromDistance: nil, + toDistance: nil, + pointsAnalyzer: PlatformUtil.shared.getTrackPointsAnalyser() + ) + + let item = ImportTrackItem( + index: index, + name: trackName, + gpxFile: trackFile, + selectedPoints: [], + suggestedPoints: [] + ) + item.analysis = analysis + return item + } + + private func makeSingleTrackGpxFile(from track: Track, author: String) -> GpxFile { + let trackFile = GpxFile(author: author) + trackFile.tracks.add(track) + copyAppearance(from: gpxFile, track: track, to: trackFile) + trackFile.recalculateProcessPoint() + + let metadata = OsmAndShared.Metadata(source: gpxFile.metadata) + metadata.name = nil + trackFile.metadata = metadata + return trackFile + } + + private func resolvedTrackName(for track: Track, index: Int, baseName: String) -> String { + if let name = track.name, !name.isEmpty { + return name + } + return String(format: localizedString("ltr_or_rtl_combine_via_dash"), baseName, "\(index + 1)") + } + + private func assignWaypoints(from sourceFile: GpxFile, to items: inout [ImportTrackItem]) { + for point in sourceFile.getPointsList() { + guard !isCancelled() else { return } + guard let nearestItem = findNearestTrack(for: point, in: items) else { continue } + nearestItem.selectedPoints.append(point) + nearestItem.suggestedPoints.append(point) + } + } + + // MARK: - Helpers + + private func gpxAuthor() -> String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + return "OsmAnd Maps \(version) (\(build))" + } + + private func copyAppearance(from source: GpxFile, track: Track, to target: GpxFile) { + target.setColor(color: track.getColor(defColor: source.getColor(defColor: 0))) + if let width = source.getWidth(defaultWidth: nil) { + target.setWidth(width: width) + } + target.setShowArrows(showArrows: source.isShowArrows()) + target.setShowStartFinish(showStartFinish: source.isShowStartFinish()) + target.setSplitInterval(splitInterval: source.getSplitInterval()) + if let splitType = source.getSplitType() { + target.setSplitType(gpxSplitType: splitType) + } + if let coloringType = source.getColoringType() { + target.setColoringType(coloringType: coloringType) + } + if let palette = source.getGradientColorPalette() { + target.setGradientColorPalette(gradientColorPaletteName: palette) + } + if let type3d = source.get3DVisualizationType() { + target.set3DVisualizationType(visualizationType: type3d) + } + if let wallColor = source.get3DWallColoringType() { + target.set3DWallColoringType(trackWallColoringType: wallColor) + } + if let linePos = source.get3DLinePositionType() { + target.set3DLinePositionType(trackLinePositionType: linePos) + } + target.setAdditionalExaggeration(additionalExaggeration: source.getAdditionalExaggeration()) + target.setElevationMeters(elevation: source.getElevationMeters()) + } + + private func findNearestTrack(for point: WptPt, in items: [ImportTrackItem]) -> ImportTrackItem? { + var nearestItem: ImportTrackItem? + var minDistance = Double.greatestFiniteMagnitude + + for item in items { + guard !isCancelled() else { return nil } + + for waypoint in item.selectedGpxFile.getAllSegmentsPoints() { + guard !isCancelled() else { return nil } + + let distance = KMapUtils.shared.getDistance( + lat1: point.getLatitude(), + lon1: point.getLongitude(), + lat2: waypoint.getLatitude(), + lon2: waypoint.getLongitude() + ) + if distance < minDistance { + minDistance = distance + nearestItem = item + } + } + } + return nearestItem + } +} diff --git a/Sources/Controllers/MyPlaces/Import/Tasks/SaveGpxAsyncTask.swift b/Sources/Controllers/MyPlaces/Import/Tasks/SaveGpxAsyncTask.swift new file mode 100644 index 0000000000..d90b5e5f58 --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/Tasks/SaveGpxAsyncTask.swift @@ -0,0 +1,197 @@ +// +// SaveGpxAsyncTask.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared + +protocol SaveImportedGpxListener: AnyObject { + func onGpxSavingStarted() + func onGpxSaved(error: String?, savedPath: String?) + func onGpxSavingFinished(warning: [String]) +} + +private struct SaveGpxTaskResult { + let savedPath: String? + let writeError: String? + let warning: String? + + static func failure(message: String, savedPath: String? = nil) -> SaveGpxTaskResult { + SaveGpxTaskResult(savedPath: savedPath, writeError: message, warning: message) + } + + static func success(savedPath: String) -> SaveGpxTaskResult { + SaveGpxTaskResult(savedPath: savedPath, writeError: nil, warning: nil) + } +} + +final class SaveGpxAsyncTask: OAAsyncTask { + + private static let importDateFormat: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH-mm_EEE" + return formatter + }() + + private let gpxFile: GpxFile + private let destinationDir: String + private let fileName: String + private let overwrite: Bool + private let importURL: URL? + private weak var listener: SaveImportedGpxListener? + + private var fileManager: FileManager { .default } + + // MARK: - Init + + init(gpxFile: GpxFile, + destinationDir: String, + fileName: String, + overwrite: Bool, + importURL: URL?, + listener: SaveImportedGpxListener?) { + self.gpxFile = gpxFile + self.destinationDir = destinationDir + self.fileName = fileName + self.overwrite = overwrite + self.importURL = importURL + self.listener = listener + super.init() + } + + // MARK: - Static + + static func plannedDestinationPath(destinationDir: String, fileName: String) -> String { + var name = SaveImportedGpxHelper.sanitizedFileName(from: fileName, stripArchiveExtensions: true) + + return (destinationDir as NSString).appendingPathComponent(name) + } + + // MARK: - Override + + override func onPreExecute() { + listener?.onGpxSavingStarted() + } + + override func doInBackground() -> Any? { + guard !gpxFile.isEmpty() else { + return SaveGpxTaskResult.failure(message: localizedString("error_reading_gpx")) + } + + if let directoryError = validateDestinationDirectory() { + return SaveGpxTaskResult.failure(message: directoryError) + } + + let destinationPath = resolveDestinationPath() + if let writeError = writeGpx(to: destinationPath) { + return SaveGpxTaskResult.failure(message: writeError, savedPath: destinationPath) + } + + SaveImportedGpxHelper.processSavedFile(at: destinationPath, gpxFile: gpxFile) + return SaveGpxTaskResult.success(savedPath: destinationPath) + } + + override func onPostExecute(result: Any?) { + guard let result = result as? SaveGpxTaskResult else { + listener?.onGpxSaved(error: localizedString("error_reading_gpx"), savedPath: nil) + listener?.onGpxSavingFinished(warning: [localizedString("error_reading_gpx")]) + return + } + + listener?.onGpxSaved(error: result.writeError, savedPath: result.savedPath) + listener?.onGpxSavingFinished(warning: [result.warning].compactMap { $0 }) + } + + // MARK: - Destination + + private func validateDestinationDirectory() -> String? { + do { + try fileManager.createDirectory(atPath: destinationDir, withIntermediateDirectories: true) + } catch { + return localizedString("import_failed") + } + + guard fileManager.isWritableFile(atPath: destinationDir) else { + return localizedString("import_failed") + } + return nil + } + + private func resolveDestinationPath() -> String { + var name = SaveImportedGpxHelper.sanitizedFileName(from: fileName, stripArchiveExtensions: true) + + if name.isEmpty { + let point = gpxFile.findPointToShow() + let timestamp = point?.time ?? Int64(Date().timeIntervalSince1970 * 1000) + let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000) + name = "import_\(Self.importDateFormat.string(from: date))\(SaveImportedGpxHelper.gpxExtension)" + } + + var destinationPath = (destinationDir as NSString).appendingPathComponent(name) + guard !overwrite else { return destinationPath } + + while fileManager.fileExists(atPath: destinationPath) { + name = OAUtilities.createNewFileName(name) + destinationPath = (destinationDir as NSString).appendingPathComponent(name) + } + return destinationPath + } + + // MARK: - Writing + + private func writeGpx(to destinationPath: String) -> String? { + if overwrite, fileManager.fileExists(atPath: destinationPath) { + try? fileManager.removeItem(atPath: destinationPath) + if let item = OAGPXDatabase.sharedDb().getGPXItem(destinationPath) { + OAGPXDatabase.sharedDb().removeGpxItem(item, withLocalRemove: false) + } + } + + if !gpxFile.path.isEmpty, isTempFileToMove(gpxFile.path) { + return moveTempFile(from: gpxFile.path, to: destinationPath) + } + + if let importURL, fileManager.fileExists(atPath: importURL.path) { + return copyImportedFile(from: importURL.path, to: destinationPath) + } + + return writeGpxFile(to: destinationPath) + } + + private func moveTempFile(from sourcePath: String, to destinationPath: String) -> String? { + do { + try fileManager.moveItem(atPath: sourcePath, toPath: destinationPath) + return nil + } catch { + return error.localizedDescription + } + } + + private func copyImportedFile(from sourcePath: String, to destinationPath: String) -> String? { + do { + try fileManager.copyItem(at: URL(fileURLWithPath: sourcePath), to: URL(fileURLWithPath: destinationPath)) + return nil + } catch { + return error.localizedDescription + } + } + + private func writeGpxFile(to destinationPath: String) -> String? { + let file = KFile(filePath: destinationPath) + if let exception = GpxUtilities.shared.writeGpxFile(file: file, gpxFile: gpxFile) { + return exception.message ?? localizedString("error_reading_gpx") + } + return nil + } + + private func isTempFileToMove(_ path: String) -> Bool { + guard let gpxPath = OsmAndApp.swiftInstance()?.gpxPath else { return false } + let tempDir = (gpxPath as NSString).appendingPathComponent("temp") + return (path as NSString).deletingLastPathComponent == tempDir + } +} diff --git a/Sources/Controllers/MyPlaces/Import/Tasks/SaveImportedGpxHelper.swift b/Sources/Controllers/MyPlaces/Import/Tasks/SaveImportedGpxHelper.swift new file mode 100644 index 0000000000..f304dd037e --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/Tasks/SaveImportedGpxHelper.swift @@ -0,0 +1,73 @@ +// +// SaveImportedGpxHelper.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared + +enum SaveImportedGpxHelper { + static let gpxExtension = ".gpx" + + static func processSavedFile(at path: String, gpxFile: GpxFile) { + gpxFile.path = path + + let file = KFile(filePath: path) + let dataItem = makeOrLoadDataItem(for: file, gpxFile: gpxFile) + persist(dataItem, file: file) + registerInSmartFolder(file: file, dataItem: dataItem) + } + + private static func makeOrLoadDataItem(for file: KFile, gpxFile: GpxFile) -> GpxDataItem { + let dataItem = GpxDbHelper.shared.getItem(file: file) ?? GpxDataItem(file: file) + dataItem.readGpxParams(gpxFile: gpxFile) + + let analysis = gpxFile.getAnalysis( + fileTimestamp: file.lastModified(), + fromDistance: nil, + toDistance: nil, + pointsAnalyzer: PlatformUtil.shared.getTrackPointsAnalyser() + ) + dataItem.setAnalysis(analysis: analysis) + dataItem.updateAppearance() + return dataItem + } + + private static func persist(_ dataItem: GpxDataItem, file: KFile) { + let db = GpxDbHelper.shared + if db.hasGpxDataItem(file: file) { + db.updateDataItem(item: dataItem) + } else { + db.add(item: dataItem) + } + } + + private static func registerInSmartFolder(file: KFile, dataItem: GpxDataItem) { + let trackItem = TrackItem(file: file) + trackItem.dataItem = dataItem + SharedLibSmartFolderHelper.shared.addTrackItemToSmartFolder(item: trackItem) + } + + static func sanitizedFileName(from rawName: String, stripArchiveExtensions: Bool = false) -> String { + var fileName = (rawName as NSString).lastPathComponent.sanitizeFileName() + if fileName.isEmpty || fileName == "." || fileName == ".." { + fileName = "import" + } + if stripArchiveExtensions { + let lowercased = fileName.lowercased() + if lowercased.hasSuffix(".kml") || lowercased.hasSuffix(".kmz") || lowercased.hasSuffix(".zip") { + fileName = String(fileName.dropLast(4)) + } + if fileName.isEmpty || fileName == "." || fileName == ".." { + fileName = "import" + } + } + if !fileName.lowercased().hasSuffix(gpxExtension) { + fileName += gpxExtension + } + return fileName + } +} diff --git a/Sources/Controllers/MyPlaces/Import/Tasks/SaveTracksTask.swift b/Sources/Controllers/MyPlaces/Import/Tasks/SaveTracksTask.swift new file mode 100644 index 0000000000..c5285b01f9 --- /dev/null +++ b/Sources/Controllers/MyPlaces/Import/Tasks/SaveTracksTask.swift @@ -0,0 +1,112 @@ +// +// SaveTracksTask.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared + +private struct SavedTrackResult { + let error: String? + let savedPath: String? +} + +private struct SaveTracksTaskResult { + let trackResults: [SavedTrackResult] + var warnings: [String] { trackResults.compactMap(\.error) } +} + +final class SaveTracksTask: OAAsyncTask { + private let items: [ImportTrackItem] + private let destinationDir: String + private weak var listener: SaveImportedGpxListener? + + init(items: [ImportTrackItem], destinationDir: String, listener: SaveImportedGpxListener?) { + self.items = items + self.destinationDir = destinationDir + self.listener = listener + super.init() + } + + override func onPreExecute() { + listener?.onGpxSavingStarted() + } + + override func doInBackground() -> Any? { + let fileManager = FileManager.default + + if let destinationError = validateDestinationDirectory(using: fileManager) { + return SaveTracksTaskResult(trackResults: [ + SavedTrackResult(error: destinationError, savedPath: nil) + ]) + } + + var results: [SavedTrackResult] = [] + for trackItem in items { + if isCancelled() { break } + results.append(saveTrackItem(trackItem, fileManager: fileManager)) + } + + return SaveTracksTaskResult(trackResults: results) + } + + override func onPostExecute(result: Any?) { + guard let result = result as? SaveTracksTaskResult else { + listener?.onGpxSaved(error: localizedString("error_reading_gpx"), savedPath: nil) + listener?.onGpxSavingFinished(warning: [localizedString("error_reading_gpx")]) + return + } + + for trackResult in result.trackResults { + listener?.onGpxSaved(error: trackResult.error, savedPath: trackResult.savedPath) + } + listener?.onGpxSavingFinished(warning: result.warnings) + } + + // MARK: - Saving + + private func validateDestinationDirectory(using fileManager: FileManager) -> String? { + do { + try fileManager.createDirectory(atPath: destinationDir, withIntermediateDirectories: true) + } catch { + return localizedString("import_failed") + } + + guard fileManager.isWritableFile(atPath: destinationDir) else { + return localizedString("import_failed") + } + return nil + } + + private func saveTrackItem(_ trackItem: ImportTrackItem, fileManager: FileManager) -> SavedTrackResult { + let gpxToSave = trackItem.selectedGpxFile.clone() + gpxToSave.addPoints(collection: trackItem.selectedPoints) + + let destinationPath = uniqueDestinationPath(for: trackItem.name, fileManager: fileManager) + let file = KFile(filePath: destinationPath) + + if let exception = GpxUtilities.shared.writeGpxFile(file: file, gpxFile: gpxToSave) { + let error = exception.message ?? localizedString("error_reading_gpx") + return SavedTrackResult(error: error, savedPath: nil) + } + + trackItem.savedPath = destinationPath + + SaveImportedGpxHelper.processSavedFile(at: destinationPath, gpxFile: gpxToSave) + return SavedTrackResult(error: nil, savedPath: destinationPath) + } + + private func uniqueDestinationPath(for rawName: String, fileManager: FileManager) -> String { + var fileName = SaveImportedGpxHelper.sanitizedFileName(from: rawName) + + var destinationPath = (destinationDir as NSString).appendingPathComponent(fileName) + while fileManager.fileExists(atPath: destinationPath) { + fileName = OAUtilities.createNewFileName(fileName) + destinationPath = (destinationDir as NSString).appendingPathComponent(fileName) + } + return destinationPath + } +} diff --git a/Sources/Controllers/MyPlaces/MyPlacesContainerViewController.swift b/Sources/Controllers/MyPlaces/MyPlacesContainerViewController.swift index 3ace191030..0883d432a3 100644 --- a/Sources/Controllers/MyPlaces/MyPlacesContainerViewController.swift +++ b/Sources/Controllers/MyPlaces/MyPlacesContainerViewController.swift @@ -67,6 +67,7 @@ final class MyPlacesContainerViewController: OACompoundViewController { var selectedTab: Tab = .default var availableTabs: [Tab] = [] + var tracksFolderPathToOpenOnLoad: String? private let segmentedControlIconSize: CGFloat = 24 private var availableViewControllers: [Tab: UIViewController] = [:] @@ -96,6 +97,7 @@ final class MyPlacesContainerViewController: OACompoundViewController { pageViewController?.scrollView?.backgroundColor = .clear navigationController?.navigationBar.prefersLargeTitles = false view.backgroundColor = .viewBg + openTracksFolderIfNeeded() } override func viewWillDisappear(_ animated: Bool) { @@ -230,6 +232,12 @@ final class MyPlacesContainerViewController: OACompoundViewController { } } + private func openTracksFolderIfNeeded() { + guard let path = tracksFolderPathToOpenOnLoad else { return } + tracksFolderPathToOpenOnLoad = nil + (viewController(for: .tracks) as? TracksViewController)?.setFolderToOpenAfterLoad(path) + } + private func switchTo(tab: Tab) { selectedTab = availableTabs.first(where: { $0 == tab }) ?? .default if let viewController = viewController(for: selectedTab) { diff --git a/Sources/Controllers/MyPlaces/TracksViewController.swift b/Sources/Controllers/MyPlaces/TracksViewController.swift index 91e34e7c00..c9490e15c8 100644 --- a/Sources/Controllers/MyPlaces/TracksViewController.swift +++ b/Sources/Controllers/MyPlaces/TracksViewController.swift @@ -84,6 +84,8 @@ final class TracksViewController: UITableViewController, OATrackSavingHelperUpda private var selectedTracks: [GpxDataItem] = [] private var selectedFolders: [String] = [] + private var folderPathToOpenAfterLoad: String? + private var app: OsmAndAppProtocol private var settings: OAAppSettings private var savingHelper: OASavingTrackHelper @@ -225,6 +227,28 @@ final class TracksViewController: UITableViewController, OATrackSavingHelperUpda unregisterNotificationsAndObservers() } + func setFolderToOpenAfterLoad(_ selectedFolderPath: String) { + folderPathToOpenAfterLoad = selectedFolderPath + } + + func navigateToSubfolder(_ absolutePath: String?) { + let gpxPath = app.gpxPath ?? "" + + if absolutePath == nil || absolutePath?.isEmpty == true || absolutePath == gpxPath { + folderPathToOpenAfterLoad = nil + updateAllFoldersVCData(forceLoad: true) + return + } + + folderPathToOpenAfterLoad = absolutePath + + guard rootFolder.getFlattenedSubFolders().contains(where: { + $0.getDirFile().path() == absolutePath + }) else { return } + + openSubfolderIfNeeded() + } + private func registerObservers() { addObserver(OAAutoObserverProxy(self, withHandler: #selector(onObservedRecordedTrackChanged), andObserve: app.trackRecordingObservable)) addObserver(OAAutoObserverProxy(self, withHandler: #selector(onObservedRecordedTrackChanged), andObserve: app.trackStartStopRecObservable)) @@ -997,6 +1021,26 @@ final class TracksViewController: UITableViewController, OATrackSavingHelperUpda updateDistanceAndDirection(false) } + private func openSubfolderIfNeeded() { + guard let absolutePath = folderPathToOpenAfterLoad else { return } + folderPathToOpenAfterLoad = nil + + let gpxPath = app.gpxPath ?? "" + if absolutePath.isEmpty || absolutePath == gpxPath { return } + + guard let subfolder = rootFolder.getFlattenedSubFolders().first(where: { + $0.getDirFile().path() == absolutePath + }) else { return } + + let vc = TracksViewController(isRootFolder: false) + vc.currentFolder = subfolder + vc.currentFolderPath = subfolder.relativePath + vc.rootFolder = rootFolder + vc.visibleTracksFolder = visibleTracksFolder + vc.hostVCDelegate = self + navigationController?.pushViewController(vc, animated: true) + } + // MARK: - Data private func configureFolders() { @@ -2708,6 +2752,7 @@ extension TracksViewController: TrackFolderLoaderTaskLoadTracksListener { func loadTracksFinished(folder: TrackFolder) { debugPrint("function: \(#function)") onLoadFinished(folder: folder) + openSubfolderIfNeeded() } func tracksLoaded(folder: TrackFolder) { diff --git a/Sources/Controllers/RoutePlanning/OAAddTrackFolderViewController.h b/Sources/Controllers/RoutePlanning/OAAddTrackFolderViewController.h index 5aedf2e3ba..921ebcbb92 100644 --- a/Sources/Controllers/RoutePlanning/OAAddTrackFolderViewController.h +++ b/Sources/Controllers/RoutePlanning/OAAddTrackFolderViewController.h @@ -18,5 +18,7 @@ @property (nonatomic, weak) id delegate; +@property (nonatomic, copy, nullable) NSString *suggestedFolderName; + @end diff --git a/Sources/Controllers/RoutePlanning/OAAddTrackFolderViewController.m b/Sources/Controllers/RoutePlanning/OAAddTrackFolderViewController.m index 9fdfaf1d2b..9e98553d25 100644 --- a/Sources/Controllers/RoutePlanning/OAAddTrackFolderViewController.m +++ b/Sources/Controllers/RoutePlanning/OAAddTrackFolderViewController.m @@ -34,10 +34,13 @@ @implementation OAAddTrackFolderViewController - (void)viewDidLoad { + _newFolderName = [_suggestedFolderName trim] ?: @""; + [super viewDidLoad]; + + [self updateFileNameFromEditText:_newFolderName]; self.tableView.separatorColor = [UIColor colorNamed:ACColorNameCustomSeparator]; - _newFolderName = @""; _isFirstLaunch = YES; } @@ -71,7 +74,7 @@ - (void)generateData [data addObject:@[ @{ @"type" : [OATextMultilineTableViewCell getCellIdentifier], - @"title" : @"", + @"title" : _newFolderName, @"key" : @"input_name", } ]]; diff --git a/Sources/Controllers/RoutePlanning/OASaveTrackViewController.mm b/Sources/Controllers/RoutePlanning/OASaveTrackViewController.mm index 20a063ec6e..15dd13e45b 100644 --- a/Sources/Controllers/RoutePlanning/OASaveTrackViewController.mm +++ b/Sources/Controllers/RoutePlanning/OASaveTrackViewController.mm @@ -16,13 +16,12 @@ #import "OAMapLayers.h" #import "OAMapRendererView.h" #import "OAValueTableViewCell.h" -#import "OAFolderCardsCell.h" #import "OASelectTrackFolderViewController.h" #import "OAAddTrackFolderViewController.h" #import "OACollectionViewCellState.h" #import "GeneratedAssetSymbols.h" -@interface OASaveTrackViewController() +@interface OASaveTrackViewController() @end @@ -93,7 +92,7 @@ - (void) traitCollectionDidChange:(UITraitCollection *)previousTraitCollection if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { - OAFolderCardsCell *cell = (OAFolderCardsCell *)[self.tableView cellForRowAtIndexPath:_selectedFolderIndexPath]; + FolderCardsCell *cell = (FolderCardsCell *)[self.tableView cellForRowAtIndexPath:_selectedFolderIndexPath]; [cell.collectionView reloadData]; } } @@ -205,7 +204,7 @@ - (void) generateData @"value" : _selectedFolderName, }, @{ - @"type" : [OAFolderCardsCell getCellIdentifier], + @"type" : [FolderCardsCell getCellIdentifier], @"selectedValue" : @(_selectedFolderIndex), @"values" : _allFolders, @"addButtonTitle" : OALocalizedString(@"add_folder") @@ -381,13 +380,12 @@ - (nonnull UITableViewCell *) tableView:(nonnull UITableView *)tableView cellFor } return cell; } - else if ([cellType isEqualToString:[OAFolderCardsCell getCellIdentifier]]) + else if ([cellType isEqualToString:[FolderCardsCell getCellIdentifier]]) { - OAFolderCardsCell* cell = [tableView dequeueReusableCellWithIdentifier:[OAFolderCardsCell getCellIdentifier]]; + FolderCardsCell* cell = [tableView dequeueReusableCellWithIdentifier:[FolderCardsCell getCellIdentifier]]; if (cell == nil) { - NSArray *nib = [[NSBundle mainBundle] loadNibNamed:[OAFolderCardsCell getCellIdentifier] owner:self options:nil]; - cell = (OAFolderCardsCell *)[nib objectAtIndex:0]; + cell = [[FolderCardsCell alloc]initWithStyle:UITableViewCellStyleDefault reuseIdentifier:[FolderCardsCell getCellIdentifier]]; cell.selectionStyle = UITableViewCellSelectionStyleNone; cell.delegate = self; cell.cellIndex = indexPath; @@ -409,9 +407,9 @@ - (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)ce { NSDictionary *item = _data[indexPath.section][indexPath.row]; NSString *type = item[@"type"]; - if ([type isEqualToString:[OAFolderCardsCell getCellIdentifier]]) + if ([type isEqualToString:[FolderCardsCell getCellIdentifier]]) { - OAFolderCardsCell *folderCell = (OAFolderCardsCell *)cell; + FolderCardsCell *folderCell = (FolderCardsCell *)cell; [folderCell updateContentOffset]; } } diff --git a/Sources/Controllers/RoutePlanning/OASelectTrackFolderViewController.h b/Sources/Controllers/RoutePlanning/OASelectTrackFolderViewController.h index ff1923a30f..aad297b4ff 100644 --- a/Sources/Controllers/RoutePlanning/OASelectTrackFolderViewController.h +++ b/Sources/Controllers/RoutePlanning/OASelectTrackFolderViewController.h @@ -24,6 +24,8 @@ @property (nonatomic, weak) id delegate; +@property (nonatomic, copy, nullable) NSString *suggestedFolderName; + - (instancetype) initWithGPX:(OASTrackItem *)gpx; - (instancetype) initWithSelectedFolderName:(NSString *)selectedFolderName; - (instancetype) initWithSelectedFolderName:(NSString *)selectedFolderName excludedSubfolderPath:(NSString *)excludedSubfolderPath; diff --git a/Sources/Controllers/RoutePlanning/OASelectTrackFolderViewController.m b/Sources/Controllers/RoutePlanning/OASelectTrackFolderViewController.m index 551fb245c2..2ae9f5b99c 100644 --- a/Sources/Controllers/RoutePlanning/OASelectTrackFolderViewController.m +++ b/Sources/Controllers/RoutePlanning/OASelectTrackFolderViewController.m @@ -269,6 +269,7 @@ - (void)onRowSelected:(NSIndexPath *)indexPath { OAAddTrackFolderViewController * addFolderVC = [[OAAddTrackFolderViewController alloc] init]; addFolderVC.delegate = self; + addFolderVC.suggestedFolderName = _suggestedFolderName; UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:addFolderVC]; [self presentViewController:navigationController animated:YES completion:nil]; diff --git a/Sources/Controllers/TargetMenu/OAEditPointViewController.mm b/Sources/Controllers/TargetMenu/OAEditPointViewController.mm index 5525fc1f7a..93335f2eb2 100644 --- a/Sources/Controllers/TargetMenu/OAEditPointViewController.mm +++ b/Sources/Controllers/TargetMenu/OAEditPointViewController.mm @@ -18,7 +18,6 @@ #import "OAShapesTableViewCell.h" #import "OASelectFavoriteGroupViewController.h" #import "OAReplaceFavoriteViewController.h" -#import "OAFolderCardsCell.h" #import "OAFavoritesHelper.h" #import "OAFavoriteItem.h" #import "OAGpxWptItem.h" @@ -64,7 +63,7 @@ #define kSubviewVerticalOffset 8. -@interface OAEditPointViewController() +@interface OAEditPointViewController() @end @@ -495,7 +494,7 @@ - (void)generateData if (selectedGroupIndex < 0) selectedGroupIndex = 0; [section addObject:@{ - @"type" : [OAFolderCardsCell getCellIdentifier], + @"type" : [FolderCardsCell getCellIdentifier], @"selectedValue" : @(selectedGroupIndex), @"values" : _groupNames, @"sizes" : _groupSizes, @@ -760,13 +759,12 @@ - (UITableViewCell *)getRow:(NSIndexPath *)indexPath } return cell; } - else if ([cellType isEqualToString:[OAFolderCardsCell getCellIdentifier]]) + else if ([cellType isEqualToString:[FolderCardsCell getCellIdentifier]]) { - OAFolderCardsCell* cell = [self.tableView dequeueReusableCellWithIdentifier:[OAFolderCardsCell getCellIdentifier]]; + FolderCardsCell* cell = [self.tableView dequeueReusableCellWithIdentifier:[FolderCardsCell getCellIdentifier]]; if (cell == nil) { - NSArray *nib = [[NSBundle mainBundle] loadNibNamed:[OAFolderCardsCell getCellIdentifier] owner:self options:nil]; - cell = nib[0]; + cell = [[FolderCardsCell alloc]initWithStyle:UITableViewCellStyleDefault reuseIdentifier:[FolderCardsCell getCellIdentifier]]; cell.selectionStyle = UITableViewCellSelectionStyleNone; cell.delegate = self; cell.cellIndex = indexPath; @@ -830,9 +828,9 @@ - (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)ce { NSDictionary *item = _data[indexPath.section][indexPath.row]; NSString *type = item[@"type"]; - if ([type isEqualToString:[OAFolderCardsCell getCellIdentifier]]) + if ([type isEqualToString:[FolderCardsCell getCellIdentifier]]) { - OAFolderCardsCell *folderCell = (OAFolderCardsCell *)cell; + FolderCardsCell *folderCell = (FolderCardsCell *)cell; [folderCell updateContentOffset]; } } @@ -1154,7 +1152,7 @@ - (void)onGroupSelected:(NSString *)selectedGroupName { [self onGroupChanged:selectedGroupName]; NSIndexPath *groupsIndexPath = [NSIndexPath indexPathForRow:_selectCategoryCardsRowIndex inSection:_selectCategorySectionIndex]; - OAFolderCardsCell *colorCell = [self.tableView cellForRowAtIndexPath:groupsIndexPath]; + FolderCardsCell *colorCell = [self.tableView cellForRowAtIndexPath:groupsIndexPath]; NSInteger selectedIndex = [_groupNames indexOfObject:selectedGroupName]; [colorCell setSelectedIndex:selectedIndex]; @@ -1260,7 +1258,7 @@ - (void)addGroupWithName:(NSString *)name [self updateUIAnimated:^(BOOL finished) { NSIndexPath *groupsIndexPath = [NSIndexPath indexPathForRow:_selectCategoryCardsRowIndex inSection:_selectCategorySectionIndex]; - OAFolderCardsCell *groupCell = [self.tableView cellForRowAtIndexPath:groupsIndexPath]; + FolderCardsCell *groupCell = [self.tableView cellForRowAtIndexPath:groupsIndexPath]; [UIView transitionWithView:groupCell.collectionView duration:.2 options:UIViewAnimationOptionTransitionCrossDissolve diff --git a/Sources/Helpers/OAGPXImportUIHelper.mm b/Sources/Helpers/OAGPXImportUIHelper.mm index 6634ab7728..bb885284be 100644 --- a/Sources/Helpers/OAGPXImportUIHelper.mm +++ b/Sources/Helpers/OAGPXImportUIHelper.mm @@ -287,20 +287,35 @@ - (void) processUrl:(NSURL *)url showAlerts:(BOOL)showAlerts openGpxView:(BOOL)o NSString *fileName = [_importUrl.path lastPathComponent]; // 123/_2024-07-30_.gpx NSString *importDestFilepath = [_importGpxPath stringByAppendingPathComponent:fileName]; - if ([[OAGPXDatabase sharedDb] containsGPXItem:importDestFilepath]) - { - if (showAlerts) - { - [self showImportGpxAlert:OALocalizedString(@"import_tracks") - message:OALocalizedString(@"gpx_import_already_exists") - cancelButtonTitle:OALocalizedString(@"shared_string_cancel") - otherButtonTitles:@[OALocalizedString(@"gpx_add_new"), OALocalizedString(@"gpx_overwrite")] - openGpxView:openGpxView]; - } + + NSInteger tracksCount = _doc.getTracksCount; + if (tracksCount > 1 && tracksCount < 50) { + dispatch_async(dispatch_get_main_queue(), ^{ + ImportTracksViewController *vc = [[ImportTracksViewController alloc]initWithGpxFile:_doc fileName:fileName selectedFolderPath:importDestFilepath importURL:_importUrl completion:nil]; + OARootViewController *root = [OARootViewController instance]; + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc]; + nav.modalPresentationStyle = UIModalPresentationFullScreen; + [root.navigationController presentViewController:nav animated:YES completion:nil]; + }); + return; } else { - item = [self doImport]; + if ([[OAGPXDatabase sharedDb] containsGPXItem:importDestFilepath]) + { + if (showAlerts) + { + [self showImportGpxAlert:OALocalizedString(@"import_tracks") + message:OALocalizedString(@"gpx_import_already_exists") + cancelButtonTitle:OALocalizedString(@"shared_string_cancel") + otherButtonTitles:@[OALocalizedString(@"gpx_add_new"), OALocalizedString(@"gpx_overwrite")] + openGpxView:openGpxView]; + } + } + else + { + item = [self doImport]; + } } } else diff --git a/Sources/OsmAnd Maps-Bridging-Header.h b/Sources/OsmAnd Maps-Bridging-Header.h index 4e8a4f659d..263de86323 100644 --- a/Sources/OsmAnd Maps-Bridging-Header.h +++ b/Sources/OsmAnd Maps-Bridging-Header.h @@ -6,6 +6,7 @@ #pragma clang diagnostic ignored "-Wnullability-completeness" // Helpers +#import "OAAppVersion.h" #import "OAAppSettings.h" #import "OAColoringType.h" #import "OAColors.h" @@ -110,6 +111,7 @@ #import "OAOsmBugsDBHelper.h" #import "OAOpenStreetMapPoint.h" #import "OAOsmNotePoint.h" +#import "OATrackPreviewMapRenderer.h" // Widgets #import "OAMapWidgetRegistry.h" @@ -221,6 +223,7 @@ #import "OAOsmNoteViewController.h" #import "OAOsmEditingViewController.h" #import "OAOsmUploadPOIViewController.h" +#import "OAAddTrackFolderViewController.h" // Cells #import "OAValueTableViewCell.h" @@ -257,7 +260,9 @@ #import "OAFoldersCell.h" #import "OAShapesTableViewCell.h" #import "OATitleSliderTableViewCell.h" - +#import "OAImageDescTableViewCell.h" +#import "OAPointWithRegionTableViewCell.h" +#import "OAGpxWptItem.h" // Views #import "OASegmentedSlider.h" diff --git a/Sources/SwiftExtensions/Array+Extension.swift b/Sources/SwiftExtensions/Array+Extension.swift new file mode 100644 index 0000000000..bd4daad53a --- /dev/null +++ b/Sources/SwiftExtensions/Array+Extension.swift @@ -0,0 +1,15 @@ +// +// Array+Extension.swift +// OsmAnd Maps +// +// Created by Vitaliy Sova on 10.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import Foundation + +extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} From 40a64052314b94f3d285c01d0713a379c74aef56 Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Tue, 23 Jun 2026 11:00:14 +0200 Subject: [PATCH 32/47] [WIP] UI Fixes --- .../buttonAccentsBlue.colorset/Contents.json | 38 +++++++++++++++++++ .../blue_circle_fill.imageset/Contents.json | 15 ++++++++ .../blue_circle_fill.svg | 3 ++ .../PlanRoute/PlanRouteButtonFactory.swift | 3 +- .../PlanRouteScrollableViewController.swift | 16 ++++---- .../PlanRoute/PlanRouteToolbarsView.swift | 2 +- .../Tabs/PlanRouteRouteViewController.swift | 16 +++++--- 7 files changed, 78 insertions(+), 15 deletions(-) create mode 100644 Resources/Images.xcassets/Colors/Buttons/buttonAccentsBlue.colorset/Contents.json create mode 100644 Resources/Images.xcassets/Icons/blue_circle_fill.imageset/Contents.json create mode 100644 Resources/Images.xcassets/Icons/blue_circle_fill.imageset/blue_circle_fill.svg diff --git a/Resources/Images.xcassets/Colors/Buttons/buttonAccentsBlue.colorset/Contents.json b/Resources/Images.xcassets/Colors/Buttons/buttonAccentsBlue.colorset/Contents.json new file mode 100644 index 0000000000..6549da4dd3 --- /dev/null +++ b/Resources/Images.xcassets/Colors/Buttons/buttonAccentsBlue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x88", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x91", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Icons/blue_circle_fill.imageset/Contents.json b/Resources/Images.xcassets/Icons/blue_circle_fill.imageset/Contents.json new file mode 100644 index 0000000000..630a84a884 --- /dev/null +++ b/Resources/Images.xcassets/Icons/blue_circle_fill.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "blue_circle_fill.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Resources/Images.xcassets/Icons/blue_circle_fill.imageset/blue_circle_fill.svg b/Resources/Images.xcassets/Icons/blue_circle_fill.imageset/blue_circle_fill.svg new file mode 100644 index 0000000000..ce0b4a22c3 --- /dev/null +++ b/Resources/Images.xcassets/Icons/blue_circle_fill.imageset/blue_circle_fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift b/Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift index a786077b27..ccda94d62b 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteButtonFactory.swift @@ -29,10 +29,11 @@ enum PlanRouteButtonFactory { return button } - static func labeledButton(title: String, image: UIImage?, height: CGFloat = bottomButtonHeight) -> UIButton { + static func labeledButton(title: String, image: UIImage?, imagePlacement: NSDirectionalRectEdge = .leading, height: CGFloat = bottomButtonHeight) -> UIButton { var configuration = UIButton.Configuration.plain() configuration.title = title configuration.image = image + configuration.imagePlacement = imagePlacement configuration.imagePadding = 6 configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14) configuration.baseForegroundColor = .textColorPrimary diff --git a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift index cb6ea424ab..ebe6797451 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift @@ -13,7 +13,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController private static let grabberAreaHeight: CGFloat = 16 private static let segmentedControlHeight: CGFloat = 36 private static let bottomToolbarAreaHeight: CGFloat = 60 - private static let horizontalInset: CGFloat = 16 + private static let horizontalInset: CGFloat = 20 private static let cornerRadius: CGFloat = 16 private static let fullScreenTopGap: CGFloat = 8 private static let animationDuration: TimeInterval = 0.3 @@ -157,7 +157,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController } private func setupSheet() { - sheetView.backgroundColor = .groupBg + sheetView.backgroundColor = .viewBg sheetView.layer.cornerRadius = Self.cornerRadius sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] sheetView.clipsToBounds = true @@ -231,11 +231,13 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController } segmentControl.selectedSegmentIndex = tabs.firstIndex(of: selectedTab) ?? 0 segmentControl.backgroundColor = .groupBgColorSecondary - segmentControl.selectedSegmentTintColor = .viewBg - segmentControl.setTitleTextAttributes([.foregroundColor: UIColor.textColorSecondary, - .font: UIFont.scaledSystemFont(ofSize: 13)], for: .normal) - segmentControl.setTitleTextAttributes([.foregroundColor: UIColor.textColorPrimary, - .font: UIFont.scaledSystemFont(ofSize: 13, weight: .semibold)], for: .selected) + segmentControl.selectedSegmentTintColor = UIColor(red: 1, green: 1, blue: 1, alpha: 1) + let segmentAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.textColorPrimary, + .font: UIFont.scaledSystemFont(ofSize: 13, weight: .medium) + ] + segmentControl.setTitleTextAttributes(segmentAttributes, for: .normal) + segmentControl.setTitleTextAttributes(segmentAttributes, for: .selected) segmentControl.addTarget(self, action: #selector(onSegmentChanged), for: .valueChanged) } diff --git a/Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift b/Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift index 5f80ec260a..256b1f07a4 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteToolbarsView.swift @@ -119,7 +119,7 @@ final class PlanRouteBottomToolbarView: UIView { private let redoButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_redo"), size: PlanRouteButtonFactory.bottomButtonHeight) private lazy var addPoiButton = PlanRouteButtonFactory.labeledButton(title: localizedString("poi"), image: .templateImageNamed("ic_custom_add")) - private lazy var routeButton = PlanRouteButtonFactory.labeledButton(title: localizedString("layer_route"), image: .templateImageNamed("ic_custom_add")) + private lazy var routeButton = PlanRouteButtonFactory.labeledButton(title: localizedString("layer_route"), image: .templateImageNamed("ic_custom_add"), imagePlacement: .trailing) override init(frame: CGRect) { super.init(frame: frame) diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift index db1398c2ff..0c46d6f49f 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift @@ -285,6 +285,10 @@ extension PlanRouteRouteViewController: UITableViewDataSource { // MARK: - UITableViewDelegate extension PlanRouteRouteViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + sections[section].headerTitle != nil ? 44 : 0 + } + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { guard let title = sections[section].headerTitle else { return nil } guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: PlanRouteSegmentHeaderView.reuseId) as? PlanRouteSegmentHeaderView else { @@ -366,7 +370,7 @@ extension PlanRouteRouteViewController: UITableViewDelegate { final class PlanRouteSegmentHeaderView: UITableViewHeaderFooterView { static let reuseId = "PlanRouteSegmentHeaderView" - private static let optionsButtonSize: CGFloat = 30 + private static let optionsButtonSize: CGFloat = 44 private let titleLabel = UILabel() private let subtitleLabel = UILabel() @@ -390,7 +394,7 @@ final class PlanRouteSegmentHeaderView: UITableViewHeaderFooterView { } private func setupView() { - titleLabel.font = .scaledSystemFont(ofSize: 22, weight: .bold) + titleLabel.font = .scaledSystemFont(ofSize: 20, weight: .semibold) titleLabel.textColor = .textColorPrimary titleLabel.numberOfLines = 1 @@ -404,9 +408,9 @@ final class PlanRouteSegmentHeaderView: UITableViewHeaderFooterView { var configuration = UIButton.Configuration.plain() configuration.image = UIImage(systemName: "ellipsis") - configuration.baseForegroundColor = .iconColorActive - configuration.background.backgroundColor = UIColor.iconColorActive.withAlphaComponent(0.1) - configuration.background.cornerRadius = Self.optionsButtonSize / 2 + configuration.baseForegroundColor = .buttonAccentsBlue + configuration.background.image = UIImage(named: "blue_circle_fill") + configuration.contentInsets = .zero optionsButton.configuration = configuration optionsButton.showsMenuAsPrimaryAction = true @@ -661,7 +665,7 @@ final class PlanRouteEmptyCell: UITableViewCell { selectionStyle = .none titleLabel.text = localizedString("plan_route_no_points_title") - titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .medium) + titleLabel.font = .scaledSystemFont(ofSize: 17) titleLabel.textColor = .textColorPrimary titleLabel.numberOfLines = 0 From 3a7d8a9ea84c180ade7388c00a58b2332cf50bfb Mon Sep 17 00:00:00 2001 From: vitaliy-sova-ios <38758123+vitaliy-sova-ios@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:35:45 +0300 Subject: [PATCH 33/47] Astro plugin configure view (#5481) * [WIP] Astro plugin first implementation * [WIP] Star map view fixed * Drop redundant migration * Fix AstroArticle * Restore OAMyPositionLayer * [WIP] Sync astro classes with android * [WIP] Sync astro classes with android * [WIP] Sync astro classes with android * [WIP] Sync astro classes with android * [WIP] Sync astro classes with android * [WIP] Sync astro classes with android * Fix sky objects dimensions on the map * Fix touch radius for star map * Astro plugin core fixes * [WIP] Astro context menu in progress * [WIP] Astro context menu in progress * [WIP] Astro context menu in progress * [WIP] Astro context menu in progress * [WIP] Astro context menu in progress * Fix astro context menu locaization * Fix astro plugin locaization * Sync Astro images with android * Fix Astro context menu tabbar * Download embedded stars.db * Fix astro context menu title * Fix astro photo gallery * Fix astro photos UI issues * Fix astro visibility chart * Fix astro schedule card * Fix astro schedule list position * Fix astro cards border * Fix astro article card and viewer * Added astro map to maps and resources * Implement astro map download button * Added astronomy to choose plan * Added Astronomy to plugins list * Added Star map button to drawer * Fix star map time sync * UI adjustments of astro configure view * Fix star map buttons theme * Added regular map to bottom of star map * Make regular map interactive at star map * Fix compass button and time control colors at star map * Fix red filter at star map * Drop redundant label from star map * Fix AR mode availability at star map * Fix AR mode for star map * Sync AR view with camera overlay at star map * Fix camera controls at star map * Added search feature to astro map * Animate title on astro search items screen * Fix UI freeze on watch now items open first time * Fix astro object centering after selection * Fix animation to star object * Fix star map item in drawer menu on plugin on/off * Drop redundant quick action * Put Favorites switch to top at astro config view * Remove all icons * Replaced icons * UI fixes * ConfigureView showing on iPad on the left * Update OsmAnd Maps.xcscheme * Update OsmAnd Maps.xcscheme * Update OsmAnd Maps Release.xcscheme * Remove GCC_WARN_INHIBIT_ALL_WARNINGS --------- Co-authored-by: alex-dev --- .swiftlint.yml | 0 OsmAnd.xcodeproj/project.pbxproj | 228 ++ .../Localizations/en.lproj/InfoPlist.strings | 4 + .../en.lproj/Localizable.strings | 145 ++ Resources/OsmAnd-Info.plist | 4 + Scripts/download-shipped-resources.sh | 23 + .../SettingsItems/OAGlobalSettingsItem.mm | 3 +- Sources/Cards/GalleryGridViewController.swift | 7 +- .../ChoosePlan/OAChoosePlanHelper.h | 2 + .../ChoosePlan/OAChoosePlanHelper.mm | 32 +- Sources/Controllers/Map/OAMapViewController.h | 1 + .../Controllers/Map/OAMapViewController.mm | 5 + .../Panels/OAMapPanelViewController.h | 3 + .../Panels/OAMapPanelViewController.mm | 61 +- .../OAOptionsPanelBlackViewController.m | 69 +- .../OAManageResourcesViewController.mm | 152 +- .../Resources/OAPluginPopupViewController.mm | 12 + .../OAResourcesBaseViewController.mm | 8 +- .../TargetMenu/CardsViewController.swift | 7 +- .../DownloadingCellResourceHelper.swift | 4 + Sources/Helpers/OAResourcesInstaller.mm | 7 + Sources/Helpers/OAResourcesUIHelper.mm | 30 +- Sources/Helpers/OAResourcesUISwiftHelper.h | 4 +- Sources/Helpers/OAResourcesUISwiftHelper.mm | 12 +- Sources/Helpers/OAWikiArticleHelper.h | 1 + Sources/Helpers/OAWikiArticleHelper.mm | 24 + Sources/Plugins/Astronomy/AstroArticle.swift | 123 + .../AstroConfigureViewBottomSheet.swift | 898 ++++++++ .../Astronomy/AstroDataDbProvider.swift | 416 ++++ .../Plugins/Astronomy/AstroDataProvider.swift | 172 ++ Sources/Plugins/Astronomy/AstroUtils.swift | 481 ++++ .../Plugins/Astronomy/AstronomyPlugin.swift | 50 + .../Astronomy/AstronomyPluginSettings.swift | 416 ++++ Sources/Plugins/Astronomy/Catalog.swift | 37 + Sources/Plugins/Astronomy/Constellation.swift | 33 + .../ConstellationInfoBottomSheet.swift | 55 + .../Astronomy/DateTimeSelectionView.swift | 141 ++ Sources/Plugins/Astronomy/SkyObject.swift | 295 +++ .../Astronomy/SkyObjectInfoBottomSheet.swift | 1146 +++++++++ .../Plugins/Astronomy/StarCompassButton.swift | 65 + .../Astronomy/StarMapARModeHelper.swift | 301 +++ Sources/Plugins/Astronomy/StarMapButton.swift | 88 + .../Astronomy/StarMapCameraHelper.swift | 277 +++ .../Astronomy/StarMapResetButton.swift | 17 + .../Astronomy/StarMapTimeControlButton.swift | 20 + .../Astronomy/StarMapViewController.swift | 1423 ++++++++++++ .../Astronomy/StarObjectsViewModel.swift | 69 + Sources/Plugins/Astronomy/StarView.swift | 2038 +++++++++++++++++ .../AstroArticleViewController.swift | 307 +++ .../AstroArticleWebViewClient.swift | 97 + .../AstroBottomSheetBehavior.swift | 45 + .../AstroCatalogsCardViewHolder.swift | 95 + .../contextmenu/AstroChartUtils.swift | 261 +++ .../contextmenu/AstroContextCardFactory.swift | 83 + .../contextmenu/AstroContextMenuAdapter.swift | 181 ++ .../AstroContextMenuFragment.swift | 12 + .../contextmenu/AstroContextMenuItem.swift | 154 ++ .../contextmenu/AstroContextUiState.swift | 36 + .../AstroDescriptionCardViewHolder.swift | 80 + .../AstroGalleryCardViewHolder.swift | 272 +++ .../contextmenu/AstroGalleryLoader.swift | 229 ++ .../AstroKnowledgeBaseController.swift | 154 ++ .../AstroKnowledgeCardViewHolder.swift | 90 + .../AstroScheduleCardController.swift | 218 ++ .../AstroScheduleCardViewHolder.swift | 337 +++ .../contextmenu/AstroScheduleGraphView.swift | 145 ++ .../AstroVisibilityCardController.swift | 287 +++ .../AstroVisibilityCardViewHolder.swift | 182 ++ .../AstroVisibilityGraphView.swift | 658 ++++++ .../contextmenu/GetAstroImagesTask.swift | 55 + .../contextmenu/MetricsAdapter.swift | 107 + .../search/StarMapCatalogsAdapter.swift | 160 ++ .../search/StarMapSearchHelper.swift | 208 ++ .../search/StarMapSearchPreparedData.swift | 96 + .../search/StarMapSearchResultsAdapter.swift | 483 ++++ .../Astronomy/search/StarMapSearchState.swift | 538 +++++ .../search/StarMapSearchViewController.swift | 1809 +++++++++++++++ Sources/Plugins/OAPluginsHelper.h | 3 + Sources/Plugins/OAPluginsHelper.mm | 9 + Sources/Purchases/OAIAPHelper.h | 1 + Sources/Purchases/OAIAPHelper.mm | 5 + Sources/Purchases/OAProducts.h | 5 + Sources/Purchases/OAProducts.mm | 40 + 83 files changed, 16782 insertions(+), 69 deletions(-) mode change 100755 => 100644 .swiftlint.yml create mode 100644 Sources/Plugins/Astronomy/AstroArticle.swift create mode 100644 Sources/Plugins/Astronomy/AstroConfigureViewBottomSheet.swift create mode 100644 Sources/Plugins/Astronomy/AstroDataDbProvider.swift create mode 100644 Sources/Plugins/Astronomy/AstroDataProvider.swift create mode 100644 Sources/Plugins/Astronomy/AstroUtils.swift create mode 100644 Sources/Plugins/Astronomy/AstronomyPlugin.swift create mode 100644 Sources/Plugins/Astronomy/AstronomyPluginSettings.swift create mode 100644 Sources/Plugins/Astronomy/Catalog.swift create mode 100644 Sources/Plugins/Astronomy/Constellation.swift create mode 100644 Sources/Plugins/Astronomy/ConstellationInfoBottomSheet.swift create mode 100644 Sources/Plugins/Astronomy/DateTimeSelectionView.swift create mode 100644 Sources/Plugins/Astronomy/SkyObject.swift create mode 100644 Sources/Plugins/Astronomy/SkyObjectInfoBottomSheet.swift create mode 100644 Sources/Plugins/Astronomy/StarCompassButton.swift create mode 100644 Sources/Plugins/Astronomy/StarMapARModeHelper.swift create mode 100644 Sources/Plugins/Astronomy/StarMapButton.swift create mode 100644 Sources/Plugins/Astronomy/StarMapCameraHelper.swift create mode 100644 Sources/Plugins/Astronomy/StarMapResetButton.swift create mode 100644 Sources/Plugins/Astronomy/StarMapTimeControlButton.swift create mode 100644 Sources/Plugins/Astronomy/StarMapViewController.swift create mode 100644 Sources/Plugins/Astronomy/StarObjectsViewModel.swift create mode 100644 Sources/Plugins/Astronomy/StarView.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroArticleViewController.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroArticleWebViewClient.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroBottomSheetBehavior.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroCatalogsCardViewHolder.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroChartUtils.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroContextCardFactory.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroContextMenuAdapter.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroContextMenuFragment.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroContextMenuItem.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroContextUiState.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroDescriptionCardViewHolder.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroGalleryCardViewHolder.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroGalleryLoader.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroKnowledgeBaseController.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroKnowledgeCardViewHolder.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardController.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardViewHolder.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroScheduleGraphView.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroVisibilityCardController.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroVisibilityCardViewHolder.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/AstroVisibilityGraphView.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/GetAstroImagesTask.swift create mode 100644 Sources/Plugins/Astronomy/contextmenu/MetricsAdapter.swift create mode 100644 Sources/Plugins/Astronomy/search/StarMapCatalogsAdapter.swift create mode 100644 Sources/Plugins/Astronomy/search/StarMapSearchHelper.swift create mode 100644 Sources/Plugins/Astronomy/search/StarMapSearchPreparedData.swift create mode 100644 Sources/Plugins/Astronomy/search/StarMapSearchResultsAdapter.swift create mode 100644 Sources/Plugins/Astronomy/search/StarMapSearchState.swift create mode 100644 Sources/Plugins/Astronomy/search/StarMapSearchViewController.swift diff --git a/.swiftlint.yml b/.swiftlint.yml old mode 100755 new mode 100644 diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index a11d08288f..c61bf7bd16 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -1361,6 +1361,57 @@ 97F6410929C475E7008F527B /* map_action_hill_climbing@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 97F6410529C475E6008F527B /* map_action_hill_climbing@3x.png */; }; 97F6410C29C475E7008F527B /* map_action_hill_climbing@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 97F6410829C475E7008F527B /* map_action_hill_climbing@2x.png */; }; 9A3A2ACC2C2C44B500B28C43 /* OATopIndexFilter.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9A3A2ACB2C2C44B500B28C43 /* OATopIndexFilter.mm */; }; + A5A500022F50000100000002 /* AstronomyPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500012F50000100000001 /* AstronomyPlugin.swift */; }; + A5A500062F50000100000006 /* AstronomyPluginSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500052F50000100000005 /* AstronomyPluginSettings.swift */; }; + A5A500082F50000100000008 /* AstroDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500072F50000100000007 /* AstroDataProvider.swift */; }; + A5A5000A2F5000010000000A /* AstroUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500092F50000100000009 /* AstroUtils.swift */; }; + A5A5000E2F5000010000000E /* StarObjectsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5000D2F5000010000000D /* StarObjectsViewModel.swift */; }; + A5A500102F50000100000010 /* StarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5000F2F5000010000000F /* StarView.swift */; }; + A5A500122F50000100000012 /* StarMapARModeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500112F50000100000011 /* StarMapARModeHelper.swift */; }; + A5A500142F50000100000014 /* StarMapCameraHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500132F50000100000013 /* StarMapCameraHelper.swift */; }; + A5A500162F50000100000016 /* StarMapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500152F50000100000015 /* StarMapViewController.swift */; }; + A5A5001A2F5000010000001A /* Catalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500192F50000100000019 /* Catalog.swift */; }; + A5A5001C2F5000010000001C /* AstroArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5001B2F5000010000001B /* AstroArticle.swift */; }; + A5A5001E2F5000010000001E /* SkyObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5001D2F5000010000001D /* SkyObject.swift */; }; + A5A500202F50000100000020 /* Constellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5001F2F5000010000001F /* Constellation.swift */; }; + A5A500222F50000100000022 /* AstroDataDbProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500212F50000100000021 /* AstroDataDbProvider.swift */; }; + A5A500242F50000100000024 /* AstroConfigureViewBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500232F50000100000023 /* AstroConfigureViewBottomSheet.swift */; }; + A5A500262F50000100000026 /* SkyObjectInfoBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500252F50000100000025 /* SkyObjectInfoBottomSheet.swift */; }; + A5A500282F50000100000028 /* ConstellationInfoBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500272F50000100000027 /* ConstellationInfoBottomSheet.swift */; }; + A5A5002A2F5000010000002A /* DateTimeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500292F50000100000029 /* DateTimeSelectionView.swift */; }; + A5A5002C2F5000010000002C /* StarMapButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5002B2F5000010000002B /* StarMapButton.swift */; }; + A5A5002E2F5000010000002E /* StarMapResetButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5002D2F5000010000002D /* StarMapResetButton.swift */; }; + A5A500302F50000100000030 /* StarMapTimeControlButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5002F2F5000010000002F /* StarMapTimeControlButton.swift */; }; + A5A500322F50000100000032 /* StarCompassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A500312F50000100000031 /* StarCompassButton.swift */; }; + A5A502022F51000100000102 /* AstroContextMenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501022F51000100000102 /* AstroContextMenuItem.swift */; }; + A5A502032F51000100000103 /* AstroContextUiState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501032F51000100000103 /* AstroContextUiState.swift */; }; + A5A502042F51000100000104 /* MetricsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501042F51000100000104 /* MetricsAdapter.swift */; }; + A5A502052F51000100000105 /* AstroChartUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501052F51000100000105 /* AstroChartUtils.swift */; }; + A5A502062F51000100000106 /* AstroVisibilityCardController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501062F51000100000106 /* AstroVisibilityCardController.swift */; }; + A5A502072F51000100000107 /* AstroScheduleCardController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501072F51000100000107 /* AstroScheduleCardController.swift */; }; + A5A502082F51000100000108 /* AstroVisibilityGraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501082F51000100000108 /* AstroVisibilityGraphView.swift */; }; + A5A502092F51000100000109 /* AstroScheduleGraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501092F51000100000109 /* AstroScheduleGraphView.swift */; }; + A5A5020A2F5100010000010A /* AstroContextCardFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5010A2F5100010000010A /* AstroContextCardFactory.swift */; }; + A5A5020B2F5100010000010B /* AstroKnowledgeBaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5010B2F5100010000010B /* AstroKnowledgeBaseController.swift */; }; + A5A5020C2F5100010000010C /* GetAstroImagesTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5010C2F5100010000010C /* GetAstroImagesTask.swift */; }; + A5A5020D2F5100010000010D /* AstroGalleryLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5010D2F5100010000010D /* AstroGalleryLoader.swift */; }; + A5A5020E2F5100010000010E /* AstroArticleWebViewClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5010E2F5100010000010E /* AstroArticleWebViewClient.swift */; }; + A5A5020F2F5100010000010F /* AstroArticleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5010F2F5100010000010F /* AstroArticleViewController.swift */; }; + A5A502102F51000100000110 /* AstroContextMenuAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501102F51000100000110 /* AstroContextMenuAdapter.swift */; }; + A5A502112F51000100000111 /* AstroDescriptionCardViewHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501112F51000100000111 /* AstroDescriptionCardViewHolder.swift */; }; + A5A502122F51000100000112 /* AstroCatalogsCardViewHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501122F51000100000112 /* AstroCatalogsCardViewHolder.swift */; }; + A5A502132F51000100000113 /* AstroKnowledgeCardViewHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501132F51000100000113 /* AstroKnowledgeCardViewHolder.swift */; }; + A5A502142F51000100000114 /* AstroGalleryCardViewHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501142F51000100000114 /* AstroGalleryCardViewHolder.swift */; }; + A5A502152F51000100000115 /* AstroBottomSheetBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501152F51000100000115 /* AstroBottomSheetBehavior.swift */; }; + A5A502162F51000100000116 /* AstroVisibilityCardViewHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501162F51000100000116 /* AstroVisibilityCardViewHolder.swift */; }; + A5A502172F51000100000117 /* AstroScheduleCardViewHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501172F51000100000117 /* AstroScheduleCardViewHolder.swift */; }; + A5A502182F51000100000118 /* AstroContextMenuFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A501182F51000100000118 /* AstroContextMenuFragment.swift */; }; + A5A504012F51000200000401 /* StarMapSearchPreparedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A503012F51000200000301 /* StarMapSearchPreparedData.swift */; }; + A5A504022F51000200000402 /* StarMapSearchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A503022F51000200000302 /* StarMapSearchState.swift */; }; + A5A504032F51000200000403 /* StarMapSearchHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A503032F51000200000303 /* StarMapSearchHelper.swift */; }; + A5A504042F51000200000404 /* StarMapSearchResultsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A503042F51000200000304 /* StarMapSearchResultsAdapter.swift */; }; + A5A504052F51000200000405 /* StarMapCatalogsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A503052F51000200000305 /* StarMapCatalogsAdapter.swift */; }; + A5A504062F51000200000406 /* StarMapSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A503062F51000200000306 /* StarMapSearchViewController.swift */; }; B0D6100B2E010FD6F3088B80 /* OATrackMenuHeaderView.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D61823DCC461FF03FE8103 /* OATrackMenuHeaderView.mm */; }; B0D610BD70EF0179933FD55C /* OAAddWaypointViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D618034074DE575CDAACDD /* OAAddWaypointViewController.mm */; }; B0D610C833B6EA61B1C74268 /* OADeleteWaypointsGroupBottomSheetViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D61BD47F1716F617480A05 /* OADeleteWaypointsGroupBottomSheetViewController.mm */; }; @@ -5124,6 +5175,57 @@ 97F6410829C475E7008F527B /* map_action_hill_climbing@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "map_action_hill_climbing@2x.png"; sourceTree = ""; }; 9A3A2ACA2C2C448300B28C43 /* OATopIndexFilter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OATopIndexFilter.h; sourceTree = ""; }; 9A3A2ACB2C2C44B500B28C43 /* OATopIndexFilter.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OATopIndexFilter.mm; sourceTree = ""; }; + A5A500012F50000100000001 /* AstronomyPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstronomyPlugin.swift; sourceTree = ""; }; + A5A500052F50000100000005 /* AstronomyPluginSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstronomyPluginSettings.swift; sourceTree = ""; }; + A5A500072F50000100000007 /* AstroDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroDataProvider.swift; sourceTree = ""; }; + A5A500092F50000100000009 /* AstroUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroUtils.swift; sourceTree = ""; }; + A5A5000D2F5000010000000D /* StarObjectsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarObjectsViewModel.swift; sourceTree = ""; }; + A5A5000F2F5000010000000F /* StarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarView.swift; sourceTree = ""; }; + A5A500112F50000100000011 /* StarMapARModeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapARModeHelper.swift; sourceTree = ""; }; + A5A500132F50000100000013 /* StarMapCameraHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapCameraHelper.swift; sourceTree = ""; }; + A5A500152F50000100000015 /* StarMapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapViewController.swift; sourceTree = ""; }; + A5A500192F50000100000019 /* Catalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Catalog.swift; sourceTree = ""; }; + A5A5001B2F5000010000001B /* AstroArticle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroArticle.swift; sourceTree = ""; }; + A5A5001D2F5000010000001D /* SkyObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkyObject.swift; sourceTree = ""; }; + A5A5001F2F5000010000001F /* Constellation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constellation.swift; sourceTree = ""; }; + A5A500212F50000100000021 /* AstroDataDbProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroDataDbProvider.swift; sourceTree = ""; }; + A5A500232F50000100000023 /* AstroConfigureViewBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroConfigureViewBottomSheet.swift; sourceTree = ""; }; + A5A500252F50000100000025 /* SkyObjectInfoBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkyObjectInfoBottomSheet.swift; sourceTree = ""; }; + A5A500272F50000100000027 /* ConstellationInfoBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstellationInfoBottomSheet.swift; sourceTree = ""; }; + A5A500292F50000100000029 /* DateTimeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimeSelectionView.swift; sourceTree = ""; }; + A5A5002B2F5000010000002B /* StarMapButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapButton.swift; sourceTree = ""; }; + A5A5002D2F5000010000002D /* StarMapResetButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapResetButton.swift; sourceTree = ""; }; + A5A5002F2F5000010000002F /* StarMapTimeControlButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapTimeControlButton.swift; sourceTree = ""; }; + A5A500312F50000100000031 /* StarCompassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarCompassButton.swift; sourceTree = ""; }; + A5A501022F51000100000102 /* AstroContextMenuItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroContextMenuItem.swift; sourceTree = ""; }; + A5A501032F51000100000103 /* AstroContextUiState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroContextUiState.swift; sourceTree = ""; }; + A5A501042F51000100000104 /* MetricsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsAdapter.swift; sourceTree = ""; }; + A5A501052F51000100000105 /* AstroChartUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroChartUtils.swift; sourceTree = ""; }; + A5A501062F51000100000106 /* AstroVisibilityCardController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroVisibilityCardController.swift; sourceTree = ""; }; + A5A501072F51000100000107 /* AstroScheduleCardController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroScheduleCardController.swift; sourceTree = ""; }; + A5A501082F51000100000108 /* AstroVisibilityGraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroVisibilityGraphView.swift; sourceTree = ""; }; + A5A501092F51000100000109 /* AstroScheduleGraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroScheduleGraphView.swift; sourceTree = ""; }; + A5A5010A2F5100010000010A /* AstroContextCardFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroContextCardFactory.swift; sourceTree = ""; }; + A5A5010B2F5100010000010B /* AstroKnowledgeBaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroKnowledgeBaseController.swift; sourceTree = ""; }; + A5A5010C2F5100010000010C /* GetAstroImagesTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetAstroImagesTask.swift; sourceTree = ""; }; + A5A5010D2F5100010000010D /* AstroGalleryLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroGalleryLoader.swift; sourceTree = ""; }; + A5A5010E2F5100010000010E /* AstroArticleWebViewClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroArticleWebViewClient.swift; sourceTree = ""; }; + A5A5010F2F5100010000010F /* AstroArticleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroArticleViewController.swift; sourceTree = ""; }; + A5A501102F51000100000110 /* AstroContextMenuAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroContextMenuAdapter.swift; sourceTree = ""; }; + A5A501112F51000100000111 /* AstroDescriptionCardViewHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroDescriptionCardViewHolder.swift; sourceTree = ""; }; + A5A501122F51000100000112 /* AstroCatalogsCardViewHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroCatalogsCardViewHolder.swift; sourceTree = ""; }; + A5A501132F51000100000113 /* AstroKnowledgeCardViewHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroKnowledgeCardViewHolder.swift; sourceTree = ""; }; + A5A501142F51000100000114 /* AstroGalleryCardViewHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroGalleryCardViewHolder.swift; sourceTree = ""; }; + A5A501152F51000100000115 /* AstroBottomSheetBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroBottomSheetBehavior.swift; sourceTree = ""; }; + A5A501162F51000100000116 /* AstroVisibilityCardViewHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroVisibilityCardViewHolder.swift; sourceTree = ""; }; + A5A501172F51000100000117 /* AstroScheduleCardViewHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroScheduleCardViewHolder.swift; sourceTree = ""; }; + A5A501182F51000100000118 /* AstroContextMenuFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AstroContextMenuFragment.swift; sourceTree = ""; }; + A5A503012F51000200000301 /* StarMapSearchPreparedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapSearchPreparedData.swift; sourceTree = ""; }; + A5A503022F51000200000302 /* StarMapSearchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapSearchState.swift; sourceTree = ""; }; + A5A503032F51000200000303 /* StarMapSearchHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapSearchHelper.swift; sourceTree = ""; }; + A5A503042F51000200000304 /* StarMapSearchResultsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapSearchResultsAdapter.swift; sourceTree = ""; }; + A5A503052F51000200000305 /* StarMapCatalogsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapCatalogsAdapter.swift; sourceTree = ""; }; + A5A503062F51000200000306 /* StarMapSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarMapSearchViewController.swift; sourceTree = ""; }; ADA2D9C11AEC31EA6BB2AF2C /* Pods-OsmAnd Maps.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OsmAnd Maps.release.xcconfig"; path = "Target Support Files/Pods-OsmAnd Maps/Pods-OsmAnd Maps.release.xcconfig"; sourceTree = ""; }; B0D610268394E6C3CF0F3C8C /* OAEditWaypointsGroupBottomSheetViewController.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = OAEditWaypointsGroupBottomSheetViewController.mm; sourceTree = ""; }; B0D61037ED3E628F3BB6C1AA /* OASelectionCollapsableCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OASelectionCollapsableCell.h; sourceTree = ""; }; @@ -10415,6 +10517,80 @@ path = Resources/Icons/Location; sourceTree = ""; }; + A5A500002F50000000000001 /* Astronomy */ = { + isa = PBXGroup; + children = ( + A5A500012F50000100000001 /* AstronomyPlugin.swift */, + A5A5001B2F5000010000001B /* AstroArticle.swift */, + A5A500192F50000100000019 /* Catalog.swift */, + A5A5001D2F5000010000001D /* SkyObject.swift */, + A5A5001F2F5000010000001F /* Constellation.swift */, + A5A500052F50000100000005 /* AstronomyPluginSettings.swift */, + A5A500072F50000100000007 /* AstroDataProvider.swift */, + A5A500212F50000100000021 /* AstroDataDbProvider.swift */, + A5A500092F50000100000009 /* AstroUtils.swift */, + A5A5000D2F5000010000000D /* StarObjectsViewModel.swift */, + A5A500232F50000100000023 /* AstroConfigureViewBottomSheet.swift */, + A5A500252F50000100000025 /* SkyObjectInfoBottomSheet.swift */, + A5A500272F50000100000027 /* ConstellationInfoBottomSheet.swift */, + A5A501002F51000100000100 /* contextmenu */, + A5A503202F51000200000320 /* search */, + A5A500292F50000100000029 /* DateTimeSelectionView.swift */, + A5A5002B2F5000010000002B /* StarMapButton.swift */, + A5A5002D2F5000010000002D /* StarMapResetButton.swift */, + A5A5002F2F5000010000002F /* StarMapTimeControlButton.swift */, + A5A500312F50000100000031 /* StarCompassButton.swift */, + A5A5000F2F5000010000000F /* StarView.swift */, + A5A500112F50000100000011 /* StarMapARModeHelper.swift */, + A5A500132F50000100000013 /* StarMapCameraHelper.swift */, + A5A500152F50000100000015 /* StarMapViewController.swift */, + ); + path = Astronomy; + sourceTree = ""; + }; + A5A501002F51000100000100 /* contextmenu */ = { + isa = PBXGroup; + children = ( + A5A5010F2F5100010000010F /* AstroArticleViewController.swift */, + A5A5010E2F5100010000010E /* AstroArticleWebViewClient.swift */, + A5A501152F51000100000115 /* AstroBottomSheetBehavior.swift */, + A5A501122F51000100000112 /* AstroCatalogsCardViewHolder.swift */, + A5A501052F51000100000105 /* AstroChartUtils.swift */, + A5A5010A2F5100010000010A /* AstroContextCardFactory.swift */, + A5A501102F51000100000110 /* AstroContextMenuAdapter.swift */, + A5A501182F51000100000118 /* AstroContextMenuFragment.swift */, + A5A501022F51000100000102 /* AstroContextMenuItem.swift */, + A5A501032F51000100000103 /* AstroContextUiState.swift */, + A5A501112F51000100000111 /* AstroDescriptionCardViewHolder.swift */, + A5A501142F51000100000114 /* AstroGalleryCardViewHolder.swift */, + A5A5010D2F5100010000010D /* AstroGalleryLoader.swift */, + A5A501132F51000100000113 /* AstroKnowledgeCardViewHolder.swift */, + A5A5010B2F5100010000010B /* AstroKnowledgeBaseController.swift */, + A5A501072F51000100000107 /* AstroScheduleCardController.swift */, + A5A501172F51000100000117 /* AstroScheduleCardViewHolder.swift */, + A5A501092F51000100000109 /* AstroScheduleGraphView.swift */, + A5A501062F51000100000106 /* AstroVisibilityCardController.swift */, + A5A501162F51000100000116 /* AstroVisibilityCardViewHolder.swift */, + A5A501082F51000100000108 /* AstroVisibilityGraphView.swift */, + A5A5010C2F5100010000010C /* GetAstroImagesTask.swift */, + A5A501042F51000100000104 /* MetricsAdapter.swift */, + ); + path = contextmenu; + sourceTree = ""; + }; + A5A503202F51000200000320 /* search */ = { + isa = PBXGroup; + children = ( + A5A503052F51000200000305 /* StarMapCatalogsAdapter.swift */, + A5A503012F51000200000301 /* StarMapSearchPreparedData.swift */, + A5A503032F51000200000303 /* StarMapSearchHelper.swift */, + A5A503042F51000200000304 /* StarMapSearchResultsAdapter.swift */, + A5A503022F51000200000302 /* StarMapSearchState.swift */, + A5A503062F51000200000306 /* StarMapSearchViewController.swift */, + ); + path = search; + sourceTree = ""; + }; B247453027E35B1D00C18C3F /* Cloud */ = { isa = PBXGroup; children = ( @@ -11280,6 +11456,7 @@ DA5A797626C563A000F274C7 /* Plugins */ = { isa = PBXGroup; children = ( + A5A500002F50000000000001 /* Astronomy */, FA1D6DAC2DCE04640080E374 /* VehicleMetricsPlugin */, FACE409C2AEA9A8000E1E43A /* ExternalSensorsPlugin */, 32119B3A28477112005E1E0C /* Development */, @@ -17229,6 +17406,57 @@ DA5A845C26C563A900F274C7 /* OAGPXLayer.mm in Sources */, DA5A835126C563A800F274C7 /* OAPointHeaderTableViewCell.m in Sources */, 27D33B5F2E74622A00AD4F70 /* OpenWeatherAction.swift in Sources */, + A5A500022F50000100000002 /* AstronomyPlugin.swift in Sources */, + A5A500062F50000100000006 /* AstronomyPluginSettings.swift in Sources */, + A5A500082F50000100000008 /* AstroDataProvider.swift in Sources */, + A5A5000A2F5000010000000A /* AstroUtils.swift in Sources */, + A5A5000E2F5000010000000E /* StarObjectsViewModel.swift in Sources */, + A5A500102F50000100000010 /* StarView.swift in Sources */, + A5A500122F50000100000012 /* StarMapARModeHelper.swift in Sources */, + A5A500142F50000100000014 /* StarMapCameraHelper.swift in Sources */, + A5A500162F50000100000016 /* StarMapViewController.swift in Sources */, + A5A504012F51000200000401 /* StarMapSearchPreparedData.swift in Sources */, + A5A504022F51000200000402 /* StarMapSearchState.swift in Sources */, + A5A504032F51000200000403 /* StarMapSearchHelper.swift in Sources */, + A5A504042F51000200000404 /* StarMapSearchResultsAdapter.swift in Sources */, + A5A504052F51000200000405 /* StarMapCatalogsAdapter.swift in Sources */, + A5A504062F51000200000406 /* StarMapSearchViewController.swift in Sources */, + A5A5001A2F5000010000001A /* Catalog.swift in Sources */, + A5A5001C2F5000010000001C /* AstroArticle.swift in Sources */, + A5A5001E2F5000010000001E /* SkyObject.swift in Sources */, + A5A500202F50000100000020 /* Constellation.swift in Sources */, + A5A500222F50000100000022 /* AstroDataDbProvider.swift in Sources */, + A5A500242F50000100000024 /* AstroConfigureViewBottomSheet.swift in Sources */, + A5A500262F50000100000026 /* SkyObjectInfoBottomSheet.swift in Sources */, + A5A500282F50000100000028 /* ConstellationInfoBottomSheet.swift in Sources */, + A5A5002A2F5000010000002A /* DateTimeSelectionView.swift in Sources */, + A5A5002C2F5000010000002C /* StarMapButton.swift in Sources */, + A5A5002E2F5000010000002E /* StarMapResetButton.swift in Sources */, + A5A500302F50000100000030 /* StarMapTimeControlButton.swift in Sources */, + A5A500322F50000100000032 /* StarCompassButton.swift in Sources */, + A5A502022F51000100000102 /* AstroContextMenuItem.swift in Sources */, + A5A502032F51000100000103 /* AstroContextUiState.swift in Sources */, + A5A502042F51000100000104 /* MetricsAdapter.swift in Sources */, + A5A502052F51000100000105 /* AstroChartUtils.swift in Sources */, + A5A502062F51000100000106 /* AstroVisibilityCardController.swift in Sources */, + A5A502072F51000100000107 /* AstroScheduleCardController.swift in Sources */, + A5A502082F51000100000108 /* AstroVisibilityGraphView.swift in Sources */, + A5A502092F51000100000109 /* AstroScheduleGraphView.swift in Sources */, + A5A5020A2F5100010000010A /* AstroContextCardFactory.swift in Sources */, + A5A5020B2F5100010000010B /* AstroKnowledgeBaseController.swift in Sources */, + A5A5020C2F5100010000010C /* GetAstroImagesTask.swift in Sources */, + A5A5020D2F5100010000010D /* AstroGalleryLoader.swift in Sources */, + A5A5020E2F5100010000010E /* AstroArticleWebViewClient.swift in Sources */, + A5A5020F2F5100010000010F /* AstroArticleViewController.swift in Sources */, + A5A502102F51000100000110 /* AstroContextMenuAdapter.swift in Sources */, + A5A502112F51000100000111 /* AstroDescriptionCardViewHolder.swift in Sources */, + A5A502122F51000100000112 /* AstroCatalogsCardViewHolder.swift in Sources */, + A5A502132F51000100000113 /* AstroKnowledgeCardViewHolder.swift in Sources */, + A5A502142F51000100000114 /* AstroGalleryCardViewHolder.swift in Sources */, + A5A502152F51000100000115 /* AstroBottomSheetBehavior.swift in Sources */, + A5A502162F51000100000116 /* AstroVisibilityCardViewHolder.swift in Sources */, + A5A502172F51000100000117 /* AstroScheduleCardViewHolder.swift in Sources */, + A5A502182F51000100000118 /* AstroContextMenuFragment.swift in Sources */, DA5A824926C563A700F274C7 /* OAAutoCenterMapViewController.m in Sources */, DA5A855F26C563A900F274C7 /* OALocationPointWrapper.mm in Sources */, DA5A81AE26C563A700F274C7 /* OASearchResultMatcher.mm in Sources */, diff --git a/Resources/Localizations/en.lproj/InfoPlist.strings b/Resources/Localizations/en.lproj/InfoPlist.strings index 2ca08abc91..c871f3a154 100644 --- a/Resources/Localizations/en.lproj/InfoPlist.strings +++ b/Resources/Localizations/en.lproj/InfoPlist.strings @@ -15,3 +15,7 @@ "NSBluetoothAlwaysUsageDescription" = "Access to Bluetooth is required to find and connect to BLE sensors."; "NSCalendarsUsageDescription" = "OsmAnd would like to create a calendar event for vehicle pick-up notification."; + +"NSCameraUsageDescription" = "Camera access is needed to show the sky camera overlay in Astronomy."; + +"NSMotionUsageDescription" = "OsmAnd needs motion access to orient the astronomy sky map with device movement."; diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 4b62a989a4..6a67292a12 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -1526,6 +1526,7 @@ "ltr_or_rtl_combine_via_per" = "%@ per %@"; "ltr_or_rtl_combine_with_brackets" = "%@ (%@)"; +"shared_string_beta" = "Beta"; "of" = "%d of %d"; "downloaded_bytes" = "%@ of %@"; @@ -4573,6 +4574,150 @@ "shared_string_int_name" = "International name"; "show_debug_tile" = "Show tile information"; +"astronomy_plugin_name" = "Astronomy"; +"astronomy_plugin_description" = "Star charts, 3D star map"; +"astronomy_map" = "Astronomy Map"; +"purchases_feature_desc_astronomy" = "Explore the night sky with an offline map and celestial catalog. Plan observations using smart visibility graphs, daily schedules, and an interactive AR mode."; +"star_map" = "Star map"; +"astro_configure_view" = "Configure view"; +"astro_visible_objects" = "Visible objects"; +"astro_rendering" = "Rendering"; +"map_2d" = "2D"; +"map_3d" = "3D"; +"red_filter" = "Red filter"; +"astro_ar" = "AR"; +"astro_ar_unavailable" = "AR orientation is not available on this device."; +"astro_camera" = "Camera"; +"astro_stars" = "Stars"; +"astro_star" = "Star"; +"astro_planets" = "Planets"; +"astro_name_sun" = "Sun"; +"astro_name_moon" = "Moon"; +"astro_name_mercury" = "Mercury"; +"astro_name_venus" = "Venus"; +"astro_name_mars" = "Mars"; +"astro_name_jupiter" = "Jupiter"; +"astro_name_saturn" = "Saturn"; +"astro_name_uranus" = "Uranus"; +"astro_name_neptune" = "Neptune"; +"astro_name_pluto" = "Pluto"; +"astro_constellations" = "Constellations"; +"astro_constellation" = "Constellation"; +"astro_solar_system" = "Solar system"; +"astro_galaxies" = "Galaxies"; +"astro_nebulae" = "Nebulae"; +"astro_nebulas" = "Nebulas"; +"astro_star_clusters" = "Star clusters"; +"astro_open_clusters" = "Open Clusters"; +"astro_globular_clusters" = "Globular Clusters"; +"astro_galaxy_clusters" = "Galaxy Clusters"; +"astro_black_holes" = "Black Holes"; +"astro_deep_sky" = "Deep sky"; +"astro_min_magnitude" = "Min. magnitude"; +"astro_alt_short" = "Alt"; +"astro_designations" = "Designations"; +"astro_expand_your_universe_title" = "Expand your universe"; +"astro_expand_your_universe_description" = "Get access to the extended database of deep sky catalogs, Wikipedia articles and AR mode offline."; +"astro_offline_knowledge_base_title" = "Offline Knowledge Base"; +"astro_offline_knowledge_base_description" = "Download the extended database to access Wikipedia articles and deep sky catalogs offline."; +"astro_today_visibility" = "Today visibility"; +"astro_visibility_show_today" = "Show today visibility"; +"astro_schedule_show_current_week" = "Show current week"; +"astro_schedule_next_day_note" = "⁺¹ Next day."; +"astro_rise" = "Rise"; +"astro_set" = "Set"; +"astro_culmination" = "Culmination"; +"astro_az_short" = "Az"; +"astronomy_schedule" = "Schedule"; +"astro_locate" = "Locate"; +"astro_direction" = "Direction"; +"astro_path" = "Path"; +"astro_type_earth" = "Earth"; +"astro_type_star" = "Star"; +"astro_type_galaxy" = "Galaxy"; +"astro_type_black_hole" = "Black hole"; +"astro_type_planet" = "Planet"; +"astro_type_sun" = "Sun"; +"astro_type_satellite" = "Satellite"; +"astro_type_nebula" = "Nebula"; +"astro_type_open_cluster" = "Open cluster"; +"astro_type_globular_cluster" = "Globular cluster"; +"astro_type_galaxy_cluster" = "Galaxy cluster"; +"astro_type_constellation" = "Constellation"; +"shared_string_clear_filters" = "Clear filters"; +"astro_sort_newest_first" = "Newest first"; +"astro_sort_oldest_first" = "Oldest first"; +"no_objects_found" = "No objects found"; +"astro_search_info_solar_system" = "The Solar System is the gravitationally bound system of the Sun and the masses that orbit it, most prominently its eight planets, of which Earth is one."; +"astro_search_info_constellations" = "A constellation is an area on the celestial sphere in which a group of visible stars forms a perceived pattern or outline."; +"astro_search_info_stars" = "Stars are luminous astronomical objects made of plasma, held together by their own gravity."; +"astro_search_info_nebulas" = "A nebula is a giant cloud of dust and gas in space, often a region where stars are born."; +"astro_search_info_star_clusters" = "A star cluster is a group of stars that are gravitationally bound and formed from the same molecular cloud."; +"astro_search_info_deep_sky" = "Deep sky objects include galaxies, nebulae, star clusters, and other distant astronomical targets."; +"astro_go_to_map" = "Go to map"; +"astro_my_data_no_favorites_title" = "Nothing here"; +"astro_my_data_no_favorites_description" = "You can add objects to your favorites from their context menu"; +"astro_my_data_no_daily_paths_title" = "No daily paths"; +"astro_my_data_no_daily_paths_description" = "Enable a daily path for an object from its context menu to see it here"; +"astro_my_data_no_directions_title" = "No directions set"; +"astro_my_data_no_directions_description" = "Directions allow you to keep a visible arrow pointing to an object. Enable it from the context menu."; +"astro_catalog_objects_count" = "%d objects"; +"astro_search_constellation_with_code" = "Constellation (%1$s)"; +"astro_search_type_in_location" = "%1$s in %2$s"; +"astro_search_in_location" = "In %1$s"; +"astro_search_rises_tomorrow" = "↗ %1$s %2$s"; +"astro_search_sets_tomorrow" = "↘ %1$s %2$s"; +"astro_search_never_rises" = "↓ Never rises"; +"astro_search_always_up" = "↑ Always up"; +"astro_search_sets_at" = "↘ Sets %1$s"; +"astro_search_rises_at" = "↗ Rises %1$s"; +"astro_search_magnitude_short" = "mag %.1f"; +"astro_filter_naked_eye" = "Naked eye visible"; +"astro_filter_visible_tonight" = "Visible tonight"; +"astro_filter_visible_now" = "Visible now"; +"astro_filter_show_all" = "Show all"; +"astro_sort_sets_soonest" = "Sets soonest"; +"astro_sort_rises_soonest" = "Rises soonest"; +"astro_sort_faintest_first" = "Faintest first"; +"astro_sort_brightest_first" = "Brightest first"; +"astro_search_empty_description" = "Try changing the filters or observation time."; +"astro_search_input_hint" = "Input text"; +"astro_explore_my_data" = "My data"; +"astro_explore_schedule_subtitle" = "Upcoming astronomical events"; +"astro_explore_schedule" = "Schedule"; +"astro_explore_watch_now_subtitle" = "See what is in the sky tonight"; +"astro_explore_watch_now" = "Watch now"; +"astro_explore_deep_sky_subtitle" = "Galaxy, Galaxy Clusters, Black holes"; +"astro_catalogs" = "Catalogs"; +"filter_tracks_count" = "Filter: %d"; +"sort_name_ascending" = "Name: A – Z"; +"sort_name_descending" = "Name: Z – A"; +"favourites_edit_dialog_category" = "Category"; +"north_abbreviation" = "N"; +"south_abbreviation" = "S"; +"east_abbreviation" = "E"; +"west_abbreviation" = "W"; +"shared_string_azimuth" = "Azimuth"; +"shared_string_magnitude" = "Magnitude"; +"gpx_visibility_txt" = "Visibility"; +"wikidata" = "Wikidata"; +"no_index_file_to_download" = "Downloads not found, please check your connection to the Internet."; +"count_of_lines" = "Count of lines"; +"no_camera_permission" = "Grant camera access."; +"recording_camera_not_available" = "Camera not available"; +"astro_azimuthal_grid" = "Azimuthal grid"; +"azimuthal_grid" = "Azimuthal Grid"; +"meridian_line" = "Meridian Line"; +"equatorial_grid" = "Equatorial Grid"; +"ecliptic_line" = "Ecliptic Line"; +"equator_line" = "Equator Line"; +"galactic_line" = "Galactic Line"; +"astro_directions" = "Directions"; +"astro_daily_path" = "Daily Path"; +"astro_reference_lines" = "Reference lines"; +"astro_magnitude_filter" = "Magnitude filter"; +"astro_red_filter" = "Red filter"; +"shared_string_play" = "Play"; "private_access_routing_req_short" = "Your destination is in a private area. Allow using private roads?"; "view_on_phone" = "View on Phone"; "missing_maps_header" = "Missing or outdated maps"; diff --git a/Resources/OsmAnd-Info.plist b/Resources/OsmAnd-Info.plist index 140f8a5cbd..66be0f29ad 100644 --- a/Resources/OsmAnd-Info.plist +++ b/Resources/OsmAnd-Info.plist @@ -188,12 +188,16 @@ OsmAnd needs Bluetooth to find and connect to Bluetooth LE sensors. NSCalendarsUsageDescription OsmAnd would like to create a calendar event for vehicle pick-up notification. + NSCameraUsageDescription + OsmAnd needs camera access to show the sky camera overlay in Astronomy. NSLocationAlwaysAndWhenInUseUsageDescription This permission is required to use such features as: background GPX recording, navigation etc. NSLocationAlwaysUsageDescription This permission is required to use such features as: background GPX recording, navigation etc. NSLocationWhenInUseUsageDescription This permission is required to use such features as: background GPX recording, navigation etc. when OsmAnd is in foreground only. + NSMotionUsageDescription + OsmAnd needs motion access to orient the astronomy sky map with device movement. NSPhotoLibraryUsageDescription OsmAnd would like to store a photo. UIApplicationSceneManifest diff --git a/Scripts/download-shipped-resources.sh b/Scripts/download-shipped-resources.sh index eb1b6d64b6..16cb3ce5b4 100755 --- a/Scripts/download-shipped-resources.sh +++ b/Scripts/download-shipped-resources.sh @@ -41,5 +41,28 @@ function downloadShippedResource() echo "Shipping '$name' with version '"$(cat "$DEST/$name.stamp")"'" } +# Function downloadShippedGzipResource(name, url) +function downloadShippedGzipResource() +{ + local name=$1 + local url=$2 + local compressedName="$name.gz" + + (cd "$DEST" && \ + curl --remote-time --time-cond "$compressedName" --location --output "$compressedName" --fail "$url" && \ + gzip -dc "$compressedName" > "$name" && \ + $GET_FILE_MODIFICATION "$compressedName" > "$name.stamp" && \ + rm "$compressedName") + retcode=$? + if [ $retcode -ne 0 ]; then + echo "Failed to download and unzip '$name' from $url, aborting..." + exit $retcode + fi + echo "Shipping '$name' with version '"$(cat "$DEST/$name.stamp")"'" +} + # Download world mini-basemap downloadShippedResource WorldMiniBasemap.obf "http://builder.osmand.net/basemap/World_basemap_mini_2.obf" + +# Download astronomy stars database +downloadShippedGzipResource stars.db "https://builder.osmand.net/basemap/astro/stars.db.gz" diff --git a/Sources/Backup/LocalBackup/SettingsItems/OAGlobalSettingsItem.mm b/Sources/Backup/LocalBackup/SettingsItems/OAGlobalSettingsItem.mm index a03f855b1b..63faf7a24a 100644 --- a/Sources/Backup/LocalBackup/SettingsItems/OAGlobalSettingsItem.mm +++ b/Sources/Backup/LocalBackup/SettingsItems/OAGlobalSettingsItem.mm @@ -33,7 +33,8 @@ + (void)initialize @"osmand.wikipedia": kInAppId_Addon_Wiki, @"osmand.weather": kInAppId_Addon_Weather, @"osmand.sensor": kInAppId_Addon_External_Sensors, - @"osmand.vehicle.metrics": kInAppId_Addon_Vehicle_Metrics + @"osmand.vehicle.metrics": kInAppId_Addon_Vehicle_Metrics, + @"osmand.astronomy": kInAppId_Addon_Astronomy // @"osmand.antplus" // @"osmand.accessibility": // @"osmand.rastermaps" diff --git a/Sources/Cards/GalleryGridViewController.swift b/Sources/Cards/GalleryGridViewController.swift index 70abd349ab..a6de77abfc 100644 --- a/Sources/Cards/GalleryGridViewController.swift +++ b/Sources/Cards/GalleryGridViewController.swift @@ -18,6 +18,7 @@ final class GalleryGridViewController: OABaseNavbarViewController { var cards: [AbstractCard] = [] var titleString: String = "" var placeholderImage: UIImage? + var presentsCarouselFromSelf = false // swiftlint:disable all private var collectionView: UICollectionView! // swiftlint:enable all @@ -209,7 +210,11 @@ extension GalleryGridViewController: UICollectionViewDelegate { navController.modalPresentationStyle = .custom navController.modalTransitionStyle = .crossDissolve navController.modalPresentationCapturesStatusBarAppearance = true - OARootViewController.instance().mapPanel?.navigationController?.present(navController, animated: true) + if presentsCarouselFromSelf { + (navigationController ?? self).present(navController, animated: true) + } else { + OARootViewController.instance().mapPanel?.navigationController?.present(navController, animated: true) + } } func collectionView(_ collectionView: UICollectionView, diff --git a/Sources/Controllers/ChoosePlan/OAChoosePlanHelper.h b/Sources/Controllers/ChoosePlan/OAChoosePlanHelper.h index 7a2242ca1b..80a6a368dc 100644 --- a/Sources/Controllers/ChoosePlan/OAChoosePlanHelper.h +++ b/Sources/Controllers/ChoosePlan/OAChoosePlanHelper.h @@ -30,6 +30,7 @@ typedef NS_ENUM(NSUInteger, EOAFeature) EOAFeatureWeather, EOAFeatureSensors, EOAFeatureVehicleMetrics, + EOAFeatureAstronomy, EOAFeatureRegionAfrica, EOAFeatureRegionRussia, @@ -71,6 +72,7 @@ typedef NS_ENUM(NSUInteger, EOAFeature) + (OAFeature *)WEATHER; + (OAFeature *)SENSORS; + (OAFeature *)VEHICLEMETRICS; ++ (OAFeature *)ASTRONOMY; + (NSArray *)OSMAND_PRO_FEATURES; + (NSArray *)MAPS_PLUS_FEATURES; diff --git a/Sources/Controllers/ChoosePlan/OAChoosePlanHelper.mm b/Sources/Controllers/ChoosePlan/OAChoosePlanHelper.mm index 21fd243772..695355d49d 100644 --- a/Sources/Controllers/ChoosePlan/OAChoosePlanHelper.mm +++ b/Sources/Controllers/ChoosePlan/OAChoosePlanHelper.mm @@ -8,9 +8,11 @@ #import "OAChoosePlanHelper.h" #import "OAChoosePlanViewController.h" +#import "OAAppSettings.h" #import "OAIAPHelper.h" #import "OAProducts.h" #import "Localization.h" +#import "GeneratedAssetSymbols.h" static OAFeature * OSMAND_CLOUD; static OAFeature * ADVANCED_WIDGETS; @@ -28,6 +30,7 @@ static OAFeature * WEATHER; static OAFeature * SENSORS; static OAFeature * VEHICLEMETRICS; +static OAFeature * ASTRONOMY; static NSArray * OSMAND_PRO_FEATURES; static NSArray * MAPS_PLUS_FEATURES; @@ -84,6 +87,8 @@ - (NSString *)getTitle return OALocalizedString(@"nautical_depth"); case EOAFeatureWeather: return OALocalizedString(@"shared_string_weather"); + case EOAFeatureAstronomy: + return OALocalizedString(@"astronomy_plugin_name"); case EOAFeatureRegionAfrica: return OALocalizedString(@"product_desc_africa"); case EOAFeatureRegionRussia: @@ -152,11 +157,18 @@ - (NSString *)getDescription return OALocalizedString(@"purchases_feature_desc_nautical"); case EOAFeatureWeather: return OALocalizedString(@"purchases_feature_weather"); + case EOAFeatureAstronomy: + return OALocalizedString(@"purchases_feature_desc_astronomy"); default: return @""; } } +- (UIImage *)getAstronomyIcon +{ + return [UIImage imageNamed:ACImageNameIcCustomAstronomyColored]; +} + - (UIImage *)getIcon { switch (_feature) @@ -201,6 +213,8 @@ - (UIImage *)getIcon return [UIImage imageNamed:@"ic_custom_nautical_depth_colored"]; case EOAFeatureWeather: return [UIImage imageNamed:@"ic_custom_umbrella_colored"]; + case EOAFeatureAstronomy: + return [self getAstronomyIcon]; default: return nil; } @@ -250,6 +264,8 @@ - (UIImage *)getIconBig return [UIImage imageNamed:@"ic_custom_nautical_depth_colored"]; case EOAFeatureWeather: return [UIImage imageNamed:@"ic_custom_umbrella_colored"]; + case EOAFeatureAstronomy: + return [self getAstronomyIcon]; default: return nil; } @@ -376,6 +392,13 @@ + (OAFeature *)VEHICLEMETRICS { return VEHICLEMETRICS; } ++ (OAFeature *)ASTRONOMY +{ + if (!ASTRONOMY) + ASTRONOMY = [[OAFeature alloc] initWithFeature:EOAFeatureAstronomy]; + return ASTRONOMY; +} + + (NSArray *)OSMAND_PRO_FEATURES { if (!OSMAND_PRO_FEATURES) @@ -397,6 +420,7 @@ + (OAFeature *)VEHICLEMETRICS { OAFeature.SENSORS, OAFeature.TERRAIN, OAFeature.NAUTICAL, + OAFeature.ASTRONOMY, ]; } return OSMAND_PRO_FEATURES; @@ -415,7 +439,8 @@ + (OAFeature *)VEHICLEMETRICS { OAFeature.WIKIVOYAGE, OAFeature.SENSORS, OAFeature.TERRAIN, - OAFeature.NAUTICAL + OAFeature.NAUTICAL, + OAFeature.ASTRONOMY ]; } return MAPS_PLUS_FEATURES; @@ -432,7 +457,8 @@ + (OAFeature *)VEHICLEMETRICS { OAFeature.SENSORS, // OAFeature.COMBINED_WIKI, OAFeature.TERRAIN, - OAFeature.NAUTICAL + OAFeature.NAUTICAL, + OAFeature.ASTRONOMY ]; } return MAPS_PLUS_PREVIEW_FEATURES; @@ -482,6 +508,8 @@ + (OAFeature *)getFeature:(EOAFeature)type return OAFeature.NAUTICAL; case EOAFeatureWeather: return OAFeature.WEATHER; + case EOAFeatureAstronomy: + return OAFeature.ASTRONOMY; default: return nil; } diff --git a/Sources/Controllers/Map/OAMapViewController.h b/Sources/Controllers/Map/OAMapViewController.h index a788b3a619..492be47f49 100644 --- a/Sources/Controllers/Map/OAMapViewController.h +++ b/Sources/Controllers/Map/OAMapViewController.h @@ -110,6 +110,7 @@ typedef NS_ENUM(NSInteger, EOAMapPanDirection) { - (float) getMapZoom; - (float)getMap3DModeElevationAngle; - (void) refreshMap; +- (void)setSingleTapContextMenuGestureEnabled:(BOOL)enabled; - (BOOL) hasWptAt:(CLLocationCoordinate2D)location; diff --git a/Sources/Controllers/Map/OAMapViewController.mm b/Sources/Controllers/Map/OAMapViewController.mm index 2c9beb803f..186b04806f 100644 --- a/Sources/Controllers/Map/OAMapViewController.mm +++ b/Sources/Controllers/Map/OAMapViewController.mm @@ -2394,6 +2394,11 @@ - (void) refreshMap [[OAMapViewTrackingUtilities instance] refreshLocation]; } +- (void)setSingleTapContextMenuGestureEnabled:(BOOL)enabled +{ + _grSymbolContextMenu.enabled = enabled; +} + - (void) disableRotationAnd3DView:(BOOL)disabled { _rotationAnd3DViewDisabled = disabled; diff --git a/Sources/Controllers/Panels/OAMapPanelViewController.h b/Sources/Controllers/Panels/OAMapPanelViewController.h index 9ad2257a7f..13a6fb5b8e 100644 --- a/Sources/Controllers/Panels/OAMapPanelViewController.h +++ b/Sources/Controllers/Panels/OAMapPanelViewController.h @@ -43,6 +43,8 @@ NS_ASSUME_NONNULL_BEGIN - (void) doMapReuse:(UIViewController *)destinationViewController destinationView:(UIView *)destinationView; +- (void) restoreMapAfterReuseIfNeeded; + - (void) modifyMapAfterReuse:(Point31)destinationPoint zoom:(CGFloat)zoom azimuth:(float)azimuth elevationAngle:(float)elevationAngle animated:(BOOL)animated; - (void) modifyMapAfterReuse:(OAGpxBounds)mapBounds azimuth:(float)azimuth elevationAngle:(float)elevationAngle animated:(BOOL)animated; @@ -67,6 +69,7 @@ NS_ASSUME_NONNULL_BEGIN - (void) reopenContextMenu; - (void) hideContextMenu; - (BOOL) isContextMenuVisible; +- (NSString *) findRoadNameByLat:(double)lat lon:(double)lon; - (BOOL) isRouteInfoVisible; - (void) processNoSymbolFound:(CLLocationCoordinate2D)coord forceHide:(BOOL)forceHide; diff --git a/Sources/Controllers/Panels/OAMapPanelViewController.mm b/Sources/Controllers/Panels/OAMapPanelViewController.mm index ea08fe8ae8..65f702bd10 100644 --- a/Sources/Controllers/Panels/OAMapPanelViewController.mm +++ b/Sources/Controllers/Panels/OAMapPanelViewController.mm @@ -644,12 +644,25 @@ - (void) doMapReuse:(UIViewController *)destinationViewController destinationVie CGRect newFrame = CGRectMake(0, 0, destinationView.bounds.size.width, destinationView.bounds.size.height); if (!CGRectEqualToRect(_mapViewController.view.frame, newFrame)) _mapViewController.view.frame = newFrame; + _mapViewController.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + + UIViewController *currentParent = [_mapViewController parentViewController]; + if (currentParent && currentParent != destinationViewController) + { + [_mapViewController willMoveToParentViewController:nil]; + [_mapViewController.view removeFromSuperview]; + [_mapViewController removeFromParentViewController]; + } + + if ([_mapViewController parentViewController] != destinationViewController) + [destinationViewController addChildViewController:_mapViewController]; + + if (_mapViewController.view.superview != destinationView) + [destinationView addSubview:_mapViewController.view]; + + if (currentParent != destinationViewController) + [_mapViewController didMoveToParentViewController:destinationViewController]; - [_mapViewController willMoveToParentViewController:nil]; - - [destinationViewController addChildViewController:_mapViewController]; - [destinationView addSubview:_mapViewController.view]; - [_mapViewController didMoveToParentViewController:self]; [destinationView bringSubviewToFront:_mapViewController.view]; _mapViewController.minimap = YES; @@ -736,13 +749,41 @@ - (void) doMapRestore [_mapViewController hideTempGpxTrack]; _mapViewController.view.frame = CGRectMake(0, 0, self.view.bounds.size.width, self.view.bounds.size.height); + _mapViewController.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - [_mapViewController willMoveToParentViewController:nil]; - - [self addChildViewController:_mapViewController]; - [self.view addSubview:_mapViewController.view]; - [_mapViewController didMoveToParentViewController:self]; + UIViewController *currentParent = [_mapViewController parentViewController]; + if (currentParent && currentParent != self) + { + [_mapViewController willMoveToParentViewController:nil]; + [_mapViewController.view removeFromSuperview]; + [_mapViewController removeFromParentViewController]; + } + + if ([_mapViewController parentViewController] != self) + [self addChildViewController:_mapViewController]; + + if (_mapViewController.view.superview != self.view) + [self.view addSubview:_mapViewController.view]; + + if (currentParent != self) + [_mapViewController didMoveToParentViewController:self]; + [self.view sendSubviewToBack:_mapViewController.view]; + _mapViewController.minimap = NO; +} + +- (void) restoreMapAfterReuseIfNeeded +{ + if (_mapNeedsRestore) + { + _mapNeedsRestore = NO; + [self restoreMapAfterReuse]; + } + if ([_mapViewController parentViewController] != self) + { + [self doMapRestore]; + } + _mapViewController.minimap = NO; } - (void) openDestinationViewController diff --git a/Sources/Controllers/Panels/OAOptionsPanelBlackViewController.m b/Sources/Controllers/Panels/OAOptionsPanelBlackViewController.m index 33e65342d7..8c7a707d38 100644 --- a/Sources/Controllers/Panels/OAOptionsPanelBlackViewController.m +++ b/Sources/Controllers/Panels/OAOptionsPanelBlackViewController.m @@ -54,6 +54,7 @@ @interface OAOptionsPanelBlackViewController () @property (weak, nonatomic) IBOutlet UIButton *menuButtonPlugins; @property (weak, nonatomic) IBOutlet UIButton *menuButtonTravelGuides; @property (weak, nonatomic) IBOutlet UIButton *menuButtonExternalSensors; +@property (strong, nonatomic) UIButton *menuButtonStarMap; @end @@ -71,7 +72,8 @@ @implementation OAOptionsPanelBlackViewController CALayer *_menuButtonMapsAndResourcesDiv; CALayer *_menuButtonTravelGuidesDiv; CALayer *_menuButtonExternalSensorsDiv; - + CALayer *_menuButtonStarMapDiv; + NSArray *_menuButtonDivArray; NSArray *_menuButtonsArray; @@ -88,6 +90,7 @@ - (void) viewWillLayoutSubviews - (void) viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; + [self updateLayout]; _weatherChangeObserver = [[OAAutoObserverProxy alloc] initWith:self withHandler:@selector(onWeatherChanged) andObserve:[OsmAndApp instance].data.weatherChangeObservable]; @@ -104,6 +107,11 @@ - (void)viewWillDisappear:(BOOL)animated } } +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:OAPluginsHelperPluginStateChangedNotification object:nil]; +} + - (void)onWeatherChanged { dispatch_async(dispatch_get_main_queue(), ^{ @@ -111,6 +119,13 @@ - (void)onWeatherChanged }); } +- (void)onPluginsChanged:(NSNotification *)notification +{ + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateLayout]; + }); +} + - (void)updateMenu { [self updateLayout]; @@ -125,11 +140,15 @@ - (void) updateLayout CGFloat buttonHeight = 50.0; CGFloat width = kDrawerWidth; BOOL isWeatherPluginEnabled = [[OAPluginsHelper getPlugin:OAWeatherPlugin.class] isEnabled]; + BOOL isAstronomyPluginEnabled = [[OAPluginsHelper getPlugin:AstronomyPlugin.class] isEnabled]; if (isWeatherPluginEnabled) _menuButtonWeatherDiv.hidden = NO; else _menuButtonPlanRouteDiv.hidden = NO; + + if (isAstronomyPluginEnabled) + _menuButtonStarMapDiv.hidden = NO; BOOL isExternalSensorsPluginEnabled = [[OAPluginsHelper getPlugin:OAExternalSensorsPlugin.class] isEnabled]; @@ -157,6 +176,16 @@ - (void) updateLayout { self.menuButtonWeather.hidden = YES; } + + if (isAstronomyPluginEnabled) + { + self.menuButtonStarMap.hidden = NO; + [topButtons addObject:self.menuButtonStarMap]; + } + else + { + self.menuButtonStarMap.hidden = YES; + } if (isExternalSensorsPluginEnabled) { @@ -170,7 +199,7 @@ - (void) updateLayout NSArray *bottomButtons = [self bottomButtons]; - CALayer *bottomDiv = isExternalSensorsPluginEnabled ? _menuButtonExternalSensorsDiv : _menuButtonWeatherDiv; + CALayer *bottomDiv = isExternalSensorsPluginEnabled ? _menuButtonExternalSensorsDiv : (isAstronomyPluginEnabled ? _menuButtonStarMapDiv : (isWeatherPluginEnabled ? _menuButtonWeatherDiv : _menuButtonPlanRouteDiv)); NSInteger buttonsCount = topButtons.count + bottomButtons.count; CGFloat buttonsHeight = buttonHeight * buttonsCount; @@ -278,12 +307,31 @@ - (void)adjustContentBy:(CGFloat)bottomMargin btn:(UIButton *)btn { btn.configuration = config; } +- (UIButton *)createMenuButtonWithAction:(SEL)action +{ + UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom]; + button.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeading; + button.contentVerticalAlignment = UIControlContentVerticalAlignmentCenter; + button.titleLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; + button.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleBottomMargin; + [button setTitleColor:[UIColor colorNamed:ACColorNameTextColorPrimary] forState:UIControlStateNormal]; + [button setTitleColor:[UIColor colorNamed:ACColorNameTextColorSecondary] forState:UIControlStateHighlighted]; + [button addTarget:self action:action forControlEvents:UIControlEventTouchUpInside]; + return button; +} + - (void) viewDidLoad { [super viewDidLoad]; self.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; self.navigationController.delegate = self; + self.menuButtonStarMap = [self createMenuButtonWithAction:@selector(starMapButtonClicked:)]; + [self.scrollView addSubview:self.menuButtonStarMap]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onPluginsChanged:) + name:OAPluginsHelperPluginStateChangedNotification + object:nil]; _menuButtonMapsDiv = [[CALayer alloc] init]; _menuButtonMyDataDiv = [[CALayer alloc] init]; @@ -297,6 +345,7 @@ - (void) viewDidLoad _menuButtonPluginsDiv = [[CALayer alloc] init]; _menuButtonTravelGuidesDiv = [[CALayer alloc] init]; _menuButtonExternalSensorsDiv = [[CALayer alloc] init]; + _menuButtonStarMapDiv = [[CALayer alloc] init]; _menuButtonDivArray = @[_menuButtonMapsDiv, _menuButtonMyDataDiv, @@ -309,7 +358,8 @@ - (void) viewDidLoad _menuButtonSettingsDiv, _menuButtonPluginsDiv, _menuButtonTravelGuidesDiv, - _menuButtonExternalSensorsDiv]; + _menuButtonExternalSensorsDiv, + _menuButtonStarMapDiv]; _menuButtonsArray = @[_menuButtonMaps, _menuButtonMyData, @@ -323,7 +373,8 @@ - (void) viewDidLoad _menuButtonWeather, _menuButtonPlugins, _menuButtonTravelGuides, - _menuButtonExternalSensors]; + _menuButtonExternalSensors, + _menuButtonStarMap]; [_menuButtonMaps setTitle:OALocalizedString(@"configure_map") forState:UIControlStateNormal]; [_menuButtonMyData setTitle:OALocalizedString(@"shared_string_my_places") forState:UIControlStateNormal]; @@ -338,6 +389,7 @@ - (void) viewDidLoad [_menuButtonPlugins setTitle:OALocalizedString(@"plugins_menu_group") forState:UIControlStateNormal]; [_menuButtonTravelGuides setTitle:OALocalizedString(@"travel_guides_beta") forState:UIControlStateNormal]; [_menuButtonExternalSensors setTitle:OALocalizedString(@"external_sensors_plugin_name") forState:UIControlStateNormal]; + [_menuButtonStarMap setTitle:OALocalizedString(@"star_map") forState:UIControlStateNormal]; for (UIButton *button in _menuButtonsArray) { button.titleLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; @@ -357,6 +409,7 @@ - (void) viewDidLoad [_menuButtonPlugins.layer addSublayer:_menuButtonPluginsDiv]; [_menuButtonTravelGuides.layer addSublayer:_menuButtonTravelGuidesDiv]; [_menuButtonExternalSensors.layer addSublayer:_menuButtonExternalSensorsDiv]; + [_menuButtonStarMap.layer addSublayer:_menuButtonStarMapDiv]; [_menuButtonMaps setImage:[UIImage templateImageNamed:ACImageNameLeftMenuIconMap] forState:UIControlStateNormal]; [_menuButtonMyData setImage:[UIImage templateImageNamed:ACImageNameLeftMenuIconMyPlaces] forState:UIControlStateNormal]; @@ -371,6 +424,7 @@ - (void) viewDidLoad [_menuButtonPlugins setImage:[UIImage templateImageNamed:ACImageNameLeftMenuIconPlugins] forState:UIControlStateNormal]; [_menuButtonTravelGuides setImage:[UIImage templateImageNamed:ACImageNameIcCustomBackpack] forState:UIControlStateNormal]; [_menuButtonExternalSensors setImage:[UIImage templateImageNamed:ACImageNameIcCustomSensor] forState:UIControlStateNormal]; + [_menuButtonStarMap setImage:[UIImage templateImageNamed:ACImageNameIcCustomTelescope] forState:UIControlStateNormal]; [self applyingAppTheme]; @@ -452,6 +506,13 @@ - (IBAction)weatherButtonClicked:(id)sender [[OARootViewController instance].mapPanel.hudViewController changeWeatherToolbarVisible]; } +- (IBAction)starMapButtonClicked:(id)sender +{ + [self.sidePanelController toggleLeftPanel:self]; + AstronomyPlugin *plugin = (AstronomyPlugin *)[OAPluginsHelper getEnabledPlugin:AstronomyPlugin.class]; + [plugin showStarMap]; +} + - (IBAction) configureScreenButtonClicked:(id)sender { [self.sidePanelController toggleLeftPanel:self]; diff --git a/Sources/Controllers/Resources/OAManageResourcesViewController.mm b/Sources/Controllers/Resources/OAManageResourcesViewController.mm index 306290b124..e2a0f07146 100644 --- a/Sources/Controllers/Resources/OAManageResourcesViewController.mm +++ b/Sources/Controllers/Resources/OAManageResourcesViewController.mm @@ -83,6 +83,18 @@ @interface OAManageResourcesViewController () > outdatedResources; }; +static BOOL ResourceMatchesRegion(OAWorldRegion *region, + const std::shared_ptr &resource, + const QString &downloadsIdPrefix, + const QString &acceptedExtension) +{ + if ([region.regionId isEqualToString:OsmAnd::WorldRegions::TravelRegionId.toNSString()] && resource->type == OsmAndResourceType::StarMap) + return YES; + if (!acceptedExtension.isEmpty()) + return resource->id.endsWith(acceptedExtension); + return resource->id.startsWith(downloadsIdPrefix); +} + @implementation OAManageResourcesViewController { OsmAndAppInstance _app; @@ -120,14 +132,18 @@ @implementation OAManageResourcesViewController NSInteger _localResourcesSection; NSInteger _localSqliteSection; NSInteger _resourcesSection; + NSInteger _astronomyResourcesSection; NSInteger _localOnlineTileSourcesSection; NSInteger _localTravelSection; + NSInteger _localAstronomySection; NSInteger _localTerrainMapSourcesSection; NSMutableArray *_allResourceItems; + NSMutableArray *_astronomyResourceItems; NSMutableArray *_localResourceItems; NSMutableArray *_localSqliteItems; NSMutableArray *_localOnlineTileSources; NSMutableArray *_localTravelItems; + NSMutableArray *_localAstronomyItems; NSMutableArray *_localTerrainMapSources; NSInteger _weatherForecastRow; @@ -227,10 +243,12 @@ - (instancetype)initWithCoder:(NSCoder *)aDecoder _allSubregionItems = [NSMutableArray array]; _allResourceItems = [NSMutableArray array]; + _astronomyResourceItems = [NSMutableArray array]; _localResourceItems = [NSMutableArray array]; _localSqliteItems = [NSMutableArray array]; _localOnlineTileSources = [NSMutableArray array]; _localTravelItems = [NSMutableArray array]; + _localAstronomyItems = [NSMutableArray array]; _localTerrainMapSources = [NSMutableArray array]; _regionMapItems = [NSMutableArray array]; @@ -748,10 +766,8 @@ + (void) prepareData if (initWorldwideRegionItems) [_searchableWorldwideRegionItems addObject:region]; - const auto regionId = QString::fromNSString(region.regionId); const auto downloadsIdPrefix = QString::fromNSString(region.downloadsIdPrefix).toLower(); const auto acceptedExtension = QString::fromNSString(region.acceptedExtension).toLower(); - bool checkExtension = acceptedExtension.length() > 0; RegionResources regionResources; RegionResources regionResPrevious; @@ -767,15 +783,8 @@ + (void) prepareData { for (const auto& resource : _localResources) { - if (checkExtension) - { - if (resource->id.endsWith(acceptedExtension)) - regionResources.allResources.remove(resource->id); - } - else if (resource->id.startsWith(downloadsIdPrefix)) - { + if (ResourceMatchesRegion(region, resource, downloadsIdPrefix, acceptedExtension)) regionResources.allResources.remove(resource->id); - } } for (const auto& resource : regionResources.outdatedResources) { @@ -794,15 +803,8 @@ + (void) prepareData for (const auto& resource : _outdatedResources) { - if (checkExtension) - { - if (!resource->id.endsWith(acceptedExtension)) - continue; - } - else if (!resource->id.startsWith(downloadsIdPrefix)) - { + if (!ResourceMatchesRegion(region, resource, downloadsIdPrefix, acceptedExtension)) continue; - } regionResources.allResources.insert(resource->id, resource); regionResources.outdatedResources.insert(resource->id, resource); @@ -811,15 +813,8 @@ + (void) prepareData for (const auto& resource : _localResources) { - if (checkExtension) - { - if (!resource->id.endsWith(acceptedExtension)) - continue; - } - else if (!resource->id.startsWith(downloadsIdPrefix)) - { + if (!ResourceMatchesRegion(region, resource, downloadsIdPrefix, acceptedExtension)) continue; - } if (!regionResources.allResources.contains(resource->id)) regionResources.allResources.insert(resource->id, resource); @@ -833,15 +828,8 @@ + (void) prepareData BOOL hasSrtm = NO; for (const auto& resource : _resourcesInRepository) { - if (checkExtension) - { - if (!resource->id.endsWith(acceptedExtension)) - continue; - } - else if (!resource->id.startsWith(downloadsIdPrefix)) - { + if (!ResourceMatchesRegion(region, resource, downloadsIdPrefix, acceptedExtension)) continue; - } switch (resource->type) { @@ -921,6 +909,7 @@ - (void) collectSubregionsDataAndItems // resource available in repository or locally. [_allResourceItems removeAllObjects]; + [_astronomyResourceItems removeAllObjects]; [_allSubregionItems removeAllObjects]; [_regionMapItems removeAllObjects]; [_localRegionMapItems removeAllObjects]; @@ -963,6 +952,7 @@ - (void)collectSubregionItemsFromRegularRegion:(OAWorldRegion *)region NSMutableArray *regionMapArray = [NSMutableArray array]; NSMutableArray *allResourcesArray = [NSMutableArray array]; + NSMutableArray *astronomyResourcesArray = [NSMutableArray array]; NSMutableArray *srtmResourcesArray = [NSMutableArray array]; for (const auto& resource_ : regionResources.allResources) @@ -1074,7 +1064,14 @@ - (void)collectSubregionItemsFromRegularRegion:(OAWorldRegion *)region } else if (travelRegion) { - [_allResourceItems addObjectsFromArray:allResourcesArray]; + for (OAResourceItem *item in allResourcesArray) + { + if (item.resourceType == OsmAndResourceType::StarMap) + [astronomyResourcesArray addObject:item]; + else + [_allResourceItems addObject:item]; + } + [_astronomyResourceItems addObjectsFromArray:astronomyResourcesArray]; } else if (allResourcesArray.count > 1) { @@ -1232,6 +1229,7 @@ - (void) collectResourcesDataAndItems [_allResourceItems addObjectsFromArray:_allSubregionItems]; [_allResourceItems sortUsingComparator:self.resourceItemsComparator]; + [_astronomyResourceItems sortUsingComparator:self.resourceItemsComparator]; [_regionMapItems sortUsingComparator:^NSComparisonResult(OAResourceItem *res1, OAResourceItem *res2) { NSInteger orderValue1 = [OAResourceType getOrderIndex:[OAResourceType toValue:res1.resourceType]]; NSInteger orderValue2 = [OAResourceType getOrderIndex:[OAResourceType toValue:res2.resourceType]]; @@ -1280,6 +1278,7 @@ - (void) collectResourcesDataAndItems // Outdated Resources [_localResourceItems removeAllObjects]; [_localTravelItems removeAllObjects]; + [_localAstronomyItems removeAllObjects]; [_localTerrainMapSources removeAllObjects]; _outdatedMapsCount = 0; _totalOutdatedSize = 0; @@ -1308,7 +1307,14 @@ - (void) collectResourcesDataAndItems if (item.title != nil) { - if (![item.worldRegion.regionId isEqualToString:_travelRegionId]) + if ([item.worldRegion.regionId isEqualToString:_travelRegionId]) + { + if (item.resourceType == OsmAndResourceType::StarMap) + [_localAstronomyItems addObject:item]; + else + [_localTravelItems addObject:item]; + } + else { if (match == self.region) [_localRegionMapItems addObject:item]; @@ -1363,7 +1369,10 @@ - (void) collectResourcesDataAndItems { if ([item.worldRegion.regionId isEqualToString:_travelRegionId]) { - [_localTravelItems addObject:item]; + if (item.resourceType == OsmAndResourceType::StarMap) + [_localAstronomyItems addObject:item]; + else + [_localTravelItems addObject:item]; } else { @@ -1391,6 +1400,7 @@ - (void) collectResourcesDataAndItems [_localResourceItems sortUsingComparator:self.resourceItemsComparator]; [_localRegionMapItems sortUsingComparator:self.resourceItemsComparator]; [_localTravelItems sortUsingComparator:self.resourceItemsComparator]; + [_localAstronomyItems sortUsingComparator:self.resourceItemsComparator]; [_localTerrainMapSources sortUsingComparator:self.resourceItemsComparator]; for (OAResourceItem *item in _regionMapItems) @@ -1419,9 +1429,11 @@ - (void) prepareContent _subscribeEmailSection = -1; _localResourcesSection = -1; _resourcesSection = -1; + _astronomyResourcesSection = -1; _localSqliteSection = -1; _localOnlineTileSourcesSection = -1; _localTravelSection = -1; + _localAstronomySection = -1; _localTerrainMapSourcesSection = -1; _freeMemorySection = -1; @@ -1469,6 +1481,9 @@ - (void) prepareContent if ([[self getResourceItems] count] > 0) _resourcesSection = _lastUnusedSectionIndex++; + if (_currentScope == kAllResourcesScope && [self isTravelGuidesScope] && _astronomyResourceItems.count > 0) + _astronomyResourcesSection = _lastUnusedSectionIndex++; + if (_regionMapSection == -1 && [[self getRegionMapItems] count] > 0) _regionMapSection = _lastUnusedSectionIndex++; @@ -1481,6 +1496,9 @@ - (void) prepareContent if (_currentScope == kLocalResourcesScope && _localTravelItems.count > 0) _localTravelSection = _lastUnusedSectionIndex++; + if (_currentScope == kLocalResourcesScope && _localAstronomyItems.count > 0) + _localAstronomySection = _lastUnusedSectionIndex++; + if (_currentScope == kLocalResourcesScope && _localTerrainMapSources.count > 0) _localTerrainMapSourcesSection = _lastUnusedSectionIndex++; @@ -1543,7 +1561,7 @@ - (void)updateDisplayItem:(OAResourceItem *)item - (BOOL)hasLocalResources { - return _localResourceItems.count > 0 || _localRegionMapItems.count > 0 || _localSqliteItems.count > 0 || _localOnlineTileSources.count > 0 || _localTravelItems.count > 0 || _localTerrainMapSources.count > 0; + return _localResourceItems.count > 0 || _localRegionMapItems.count > 0 || _localSqliteItems.count > 0 || _localOnlineTileSources.count > 0 || _localTravelItems.count > 0 || _localAstronomyItems.count > 0 || _localTerrainMapSources.count > 0; } - (NSMutableArray *) getResourceItems @@ -2052,7 +2070,7 @@ - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView return 1; if (_currentScope == kLocalResourcesScope) - return ([_localResourceItems count] > 0 ? 1 : 0) + ([_localRegionMapItems count] > 0 ? 1 : 0) + (_localSqliteItems.count > 0 ? 1 : 0) + (_displaySubscribeEmailView ? 1 : 0) + (_localOnlineTileSources.count > 0 ? 1 : 0) + (_localTravelItems.count > 0 ? 1 : 0) + (_localTerrainMapSources.count > 0 ? 1 : 0) + 1; + return ([_localResourceItems count] > 0 ? 1 : 0) + ([_localRegionMapItems count] > 0 ? 1 : 0) + (_localSqliteItems.count > 0 ? 1 : 0) + (_displaySubscribeEmailView ? 1 : 0) + (_localOnlineTileSources.count > 0 ? 1 : 0) + (_localTravelItems.count > 0 ? 1 : 0) + (_localAstronomyItems.count > 0 ? 1 : 0) + (_localTerrainMapSources.count > 0 ? 1 : 0) + 1; NSInteger sectionsCount = 0; @@ -2072,6 +2090,8 @@ - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView sectionsCount++; if (_resourcesSection >= 0) sectionsCount++; + if (_astronomyResourcesSection >= 0) + sectionsCount++; if (_regionMapSection >= 0) sectionsCount++; if (_otherMapsSection >= 0) @@ -2101,6 +2121,8 @@ - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger return _downloadDescriptionInfo.getActionButtons.count + 1; if (section == _resourcesSection) return [[self getResourceItems] count]; + if (section == _astronomyResourcesSection) + return [_astronomyResourceItems count]; if (section == _localResourcesSection) return ([self hasLocalResources]) ? 2 : 1; if (section == _regionMapSection) @@ -2111,6 +2133,8 @@ - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger return [_localOnlineTileSources count]; if (section == _localTravelSection) return [_localTravelItems count]; + if (section == _localAstronomySection) + return [_localAstronomyItems count]; if (section == _localTerrainMapSourcesSection) return [_localTerrainMapSources count]; if (section == _otherMapsSection) @@ -2139,7 +2163,9 @@ - (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInte else if (section == _localOnlineTileSourcesSection) return OALocalizedString(@"online_raster_maps"); else if (section == _localTravelSection) - return OALocalizedString(@"shared_string_travel_guides"); + return OALocalizedString(@"shared_string_wikivoyage"); + else if (section == _localAstronomySection) + return OALocalizedString(@"astronomy_plugin_name"); else if (section == _localTerrainMapSourcesSection) return OALocalizedString(@"terrain_3D_maps"); else @@ -2153,10 +2179,12 @@ - (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInte if ([self isNauticalScope]) return OALocalizedString(@"nautical_maps"); else if ([self isTravelGuidesScope]) - return OALocalizedString(@"shared_string_travel_guides"); + return OALocalizedString(@"shared_string_wikivoyage"); else return OALocalizedString(@"res_worldwide"); } + if (section == _astronomyResourcesSection) + return OALocalizedString(@"astronomy_plugin_name"); if (section == _regionMapSection) return OALocalizedString(@"res_world_map"); if (section == _otherMapsSection) @@ -2176,10 +2204,12 @@ - (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInte if ([self isNauticalScope]) return OALocalizedString(@"nautical_maps"); else if ([self isTravelGuidesScope]) - return OALocalizedString(@"shared_string_travel_guides"); + return OALocalizedString(@"shared_string_wikivoyage"); else return OALocalizedString(@"res_mapsres"); } + if (section == _astronomyResourcesSection) + return OALocalizedString(@"astronomy_plugin_name"); if (section == _regionMapSection) return OALocalizedString(@"res_region_map"); if (section == _otherMapsSection) @@ -2281,7 +2311,7 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N if (isLocalCell) { subtitle = [NSString stringWithFormat:@"%lu %@ - %@", - _localResourceItems.count + _localRegionMapItems.count + _localSqliteItems.count + _localOnlineTileSources.count + _localTravelItems.count, + _localResourceItems.count + _localRegionMapItems.count + _localSqliteItems.count + _localOnlineTileSources.count + _localTravelItems.count + _localAstronomyItems.count, OALocalizedString(@"res_maps_inst"), [NSByteCountFormatter stringFromByteCount:_totalInstalledSize countStyle:NSByteCountFormatterCountStyleFile]]; @@ -2326,9 +2356,11 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N cellTypeId = subregionCell; title = OALocalizedString(@"shared_string_travel_guides"); } - else if ((indexPath.section == _resourcesSection && _resourcesSection >= 0) || indexPath.section == _localTerrainMapSourcesSection) + else if ((indexPath.section == _resourcesSection && _resourcesSection >= 0) || indexPath.section == _astronomyResourcesSection || indexPath.section == _localTerrainMapSourcesSection) { - item_ = indexPath.section == _localTerrainMapSourcesSection ? _localTerrainMapSources[indexPath.row] : [self getResourceItems][indexPath.row]; + item_ = indexPath.section == _localTerrainMapSourcesSection + ? _localTerrainMapSources[indexPath.row] + : (indexPath.section == _astronomyResourcesSection ? _astronomyResourceItems[indexPath.row] : [self getResourceItems][indexPath.row]); if ([item_ isKindOfClass:[OAWorldRegion class]]) { @@ -2376,6 +2408,11 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N } } + if (item.resourceType == OsmAndResourceType::StarMap && ![OAIAPHelper isOsmAndProAvailable]) + { + disabled = YES; + item.disabled = disabled; + } if (!item.isFree) { if ((item.resourceType == OsmAndResourceType::SrtmMapRegion || item.resourceType == OsmAndResourceType::HeightmapRegionLegacy || item.resourceType == OsmAndResourceType::GeoTiffRegion) @@ -2395,7 +2432,7 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N disabled = YES; item.disabled = disabled; } - if ([self isTravelGuidesScope] && ![OAPluginsHelper isEnabled:OAWikipediaPlugin.class]) + if ([self isTravelGuidesScope] && item.resourceType != OsmAndResourceType::StarMap && ![OAPluginsHelper isEnabled:OAWikipediaPlugin.class]) { disabled = YES; item.disabled = disabled; @@ -2427,6 +2464,8 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N srtmFormat = [NSString stringWithFormat:@" (%@)", [OAResourceType getSRTMFormatItem:item longFormat:NO]]; subtitle = [NSString stringWithFormat:@"%@%@ • %@", [OAResourceType resourceTypeLocalized:item.resourceType], srtmFormat, [NSByteCountFormatter stringFromByteCount:_sizePkg countStyle:NSByteCountFormatterCountStyleFile]]; + if (item.getDate.length > 0) + subtitle = [NSString stringWithFormat:@"%@ • %@", subtitle, item.getDate]; } else subtitle = [NSString stringWithFormat:@"%@", [OAResourceType resourceTypeLocalized:item.resourceType]]; @@ -2632,6 +2671,17 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N else subtitle = OALocalizedString(@"shared_string_wikivoyage"); } + else if (indexPath.section == _localAstronomySection) + { + OALocalResourceItem *item = _localAstronomyItems[indexPath.row]; + localItem = item; + cellTypeId = localResourceCell; + title = item.title; + if (item.size > 0) + subtitle = [NSString stringWithFormat:@"%@ • %@", [OAResourceType resourceTypeLocalized:item.resourceType], [NSByteCountFormatter stringFromByteCount:item.size countStyle:NSByteCountFormatterCountStyleFile]]; + else + subtitle = [OAResourceType resourceTypeLocalized:item.resourceType]; + } else if (indexPath.section == _freeMapsBannerSection) { @@ -2932,12 +2982,18 @@ - (id)getItemByIndexPath:(NSIndexPath *)indexPath id item; if (indexPath.section == _resourcesSection) item = [self getResourceItems][indexPath.row]; + else if (indexPath.section == _astronomyResourcesSection) + item = _astronomyResourceItems[indexPath.row]; else if (indexPath.section == _regionMapSection) item = [self getRegionMapItems][indexPath.row]; else if (indexPath.section == _localSqliteSection) item = _localSqliteItems[indexPath.row]; else if (indexPath.section == _localOnlineTileSourcesSection) item = _localOnlineTileSources[indexPath.row]; + else if (indexPath.section == _localTravelSection) + item = _localTravelItems[indexPath.row]; + else if (indexPath.section == _localAstronomySection) + item = _localAstronomyItems[indexPath.row]; else if (indexPath.section == _localTerrainMapSourcesSection) item = _localTerrainMapSources[indexPath.row]; else if (indexPath.section == _otherMapsSection) @@ -3264,6 +3320,8 @@ - (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender { if (cellPath.section == _resourcesSection && _resourcesSection >= 0) item = [self getResourceItems][cellPath.row]; + if (cellPath.section == _astronomyResourcesSection) + item = _astronomyResourceItems[cellPath.row]; if (cellPath.section == _regionMapSection && _regionMapSection >= 0) item = [self getRegionMapItems][cellPath.row]; if (cellPath.section == _localSqliteSection) @@ -3272,6 +3330,8 @@ - (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender item = _localOnlineTileSources[cellPath.row]; if (cellPath.section == _localTravelSection) item = _localTravelItems[cellPath.row]; + if (cellPath.section == _localAstronomySection) + item = _localAstronomyItems[cellPath.row]; if (cellPath.section == _localTerrainMapSourcesSection) item = _localTerrainMapSources[cellPath.row]; } diff --git a/Sources/Controllers/Resources/OAPluginPopupViewController.mm b/Sources/Controllers/Resources/OAPluginPopupViewController.mm index 71866ee1bd..6de4189667 100644 --- a/Sources/Controllers/Resources/OAPluginPopupViewController.mm +++ b/Sources/Controllers/Resources/OAPluginPopupViewController.mm @@ -420,6 +420,18 @@ + (void) askForPlugin:(NSString *)productIdentifier [popup.okButton addTarget:popup action:@selector(goToSubscriptions:) forControlEvents:UIControlEventTouchUpInside]; } + else if ([kInAppId_Addon_Astronomy isEqualToString:productIdentifier]) + { + needShow = YES; + title = OALocalizedString(@"astronomy_plugin_name"); + descText = OALocalizedString(@"purchases_feature_desc_astronomy"); + okButtonName = OALocalizedString(@"plugins_menu_group"); + cancelButtonName = OALocalizedString(@"shared_string_cancel"); + iconName = @"ic_custom_telescope"; + popup.okButton.tag = EOAFeatureAstronomy; + + [popup.okButton addTarget:popup action:@selector(goToSubscriptions:) forControlEvents:UIControlEventTouchUpInside]; + } if (needShow) { diff --git a/Sources/Controllers/Resources/OAResourcesBaseViewController.mm b/Sources/Controllers/Resources/OAResourcesBaseViewController.mm index be0ed2a70d..902b0b9d4b 100644 --- a/Sources/Controllers/Resources/OAResourcesBaseViewController.mm +++ b/Sources/Controllers/Resources/OAResourcesBaseViewController.mm @@ -356,6 +356,12 @@ - (void) onItemClicked:(id)senderItem sourceView:(UIView *)sourceView { OARepositoryResourceItem* item = (OARepositoryResourceItem *)item_; + if (item.resourceType == OsmAndResourceType::StarMap && ![OAIAPHelper isOsmAndProAvailable]) + { + [OAChoosePlanHelper showChoosePlanScreenWithFeature:nil navController:self.navigationController]; + return; + } + if (item.resource && [item isFree]) return [self offerDownloadAndInstallOf:item sourceView:sourceView]; @@ -391,7 +397,7 @@ - (void) onItemClicked:(id)senderItem sourceView:(UIView *)sourceView { [OAPluginPopupViewController askForPlugin:kInAppId_Addon_Nautical]; } - else if ([item.worldRegion.regionId isEqualToString:OsmAnd::WorldRegions::TravelRegionId.toNSString()] && ![OAPluginsHelper isEnabled:OAWikipediaPlugin.class]) + else if (item.resourceType != OsmAndResourceType::StarMap && [item.worldRegion.regionId isEqualToString:OsmAnd::WorldRegions::TravelRegionId.toNSString()] && ![OAPluginsHelper isEnabled:OAWikipediaPlugin.class]) { if ([_iapHelper.wiki isPurchased]) [OAPluginPopupViewController askForPlugin:kInAppId_Addon_Wiki]; diff --git a/Sources/Controllers/TargetMenu/CardsViewController.swift b/Sources/Controllers/TargetMenu/CardsViewController.swift index 11b4dceb4c..eb47257787 100644 --- a/Sources/Controllers/TargetMenu/CardsViewController.swift +++ b/Sources/Controllers/TargetMenu/CardsViewController.swift @@ -19,6 +19,7 @@ final class CardsViewController: UIView { var contentType: CollapsableCardsType = .onlinePhoto var title: String = "" var placeholderImage: UIImage? + weak var carouselPresenter: UIViewController? var didChangeHeightAction: ((Section, Float) -> Void)? // swiftlint:disable all var сardsFilter: CardsFilter! { @@ -46,6 +47,10 @@ final class CardsViewController: UIView { func reloadData() { applySnapshot() } + + func setCardsFilter(_ cardsFilter: CardsFilter) { + сardsFilter = cardsFilter + } func showSpinner(show: Bool) { DispatchQueue.main.async { [weak self] in @@ -236,7 +241,7 @@ extension CardsViewController: UICollectionViewDelegate { navController.modalPresentationStyle = .custom navController.modalTransitionStyle = .crossDissolve navController.modalPresentationCapturesStatusBarAppearance = true - OARootViewController.instance().mapPanel?.navigationController?.present(navController, animated: true) + (carouselPresenter ?? OARootViewController.instance().mapPanel?.navigationController)?.present(navController, animated: true) } else { card.onCardPressed(OARootViewController.instance().mapPanel) } diff --git a/Sources/Helpers/DownloadingCellHelper/DownloadingCellResourceHelper.swift b/Sources/Helpers/DownloadingCellHelper/DownloadingCellResourceHelper.swift index 7117654f6f..4a2bad84fe 100644 --- a/Sources/Helpers/DownloadingCellHelper/DownloadingCellResourceHelper.swift +++ b/Sources/Helpers/DownloadingCellHelper/DownloadingCellResourceHelper.swift @@ -132,6 +132,8 @@ class DownloadingCellResourceHelper: DownloadingCellBaseHelper { return !iapHelper.weather.isPurchased() case .srtmMapRegion, .hillshadeRegion, .slopeRegion, .heightmapRegionLegacy, .geoTiffRegion: return !iapHelper.srtm.isPurchased() + case .starMap: + return !OAIAPHelper.isOsmAndProAvailable() default: return false } @@ -246,6 +248,8 @@ class DownloadingCellResourceHelper: DownloadingCellBaseHelper { OAPluginPopupViewController.ask(forPlugin: kInAppId_Addon_Wiki) } else if type == .srtmMapRegion || type == .hillshadeRegion || type == .slopeRegion { OAPluginPopupViewController.ask(forPlugin: kInAppId_Addon_Srtm) + } else if type == .starMap, let navigationController = hostViewController?.navigationController ?? OARootViewController.instance().navigationController { + OAChoosePlanHelper.showChoosePlanScreen(with: nil as OAFeature?, navController: navigationController) } } } diff --git a/Sources/Helpers/OAResourcesInstaller.mm b/Sources/Helpers/OAResourcesInstaller.mm index 46622f0165..95493bfd42 100644 --- a/Sources/Helpers/OAResourcesInstaller.mm +++ b/Sources/Helpers/OAResourcesInstaller.mm @@ -21,6 +21,7 @@ #import "OAMapCreatorHelper.h" #import "OADownloadTask.h" #import "OAIAPHelper.h" +#import "OAPluginsHelper.h" #import "OAGPXDatabase.h" #import "OAWeatherHelper.h" #import "OAWorldRegion.h" @@ -438,6 +439,12 @@ - (void) processResource:(id)task } }); } + + if (success && resourceId == QStringLiteral("stars-articles.stardb")) + { + AstronomyPlugin *plugin = (AstronomyPlugin *)[OAPluginsHelper getPlugin:AstronomyPlugin.class]; + [plugin clearCachedData]; + } } // Remove downloaded file anyways diff --git a/Sources/Helpers/OAResourcesUIHelper.mm b/Sources/Helpers/OAResourcesUIHelper.mm index fef589e4df..daa6ccaa5a 100644 --- a/Sources/Helpers/OAResourcesUIHelper.mm +++ b/Sources/Helpers/OAResourcesUIHelper.mm @@ -95,6 +95,8 @@ + (NSString *)resourceTypeLocalized:(OsmAndResourceType)type return OALocalizedString(@"terrain_map"); case OsmAndResourceType::Travel: return OALocalizedString(@"shared_string_wikivoyage"); + case OsmAndResourceType::StarMap: + return OALocalizedString(@"star_map"); default: return OALocalizedString(@"res_unknown"); } @@ -139,6 +141,9 @@ + (NSString *)getIconName:(OsmAndResourceType)type case OsmAndResourceType::Travel: imageNamed = @"ic_custom_wikipedia"; break; + case OsmAndResourceType::StarMap: + imageNamed = @"ic_custom_star_shine"; + break; case OsmAndResourceType::GeoTiffRegion: case OsmAndResourceType::HeightmapRegionLegacy: imageNamed = @"ic_custom_terrain"; @@ -181,6 +186,8 @@ + (NSInteger)getOrderIndex:(NSNumber *)type // return 65; case OsmAndResourceType::Travel: return 66; + case OsmAndResourceType::StarMap: + return 95; case OsmAndResourceType::LiveUpdateRegion: return 70; case OsmAndResourceType::GpxFile: @@ -216,6 +223,8 @@ + (OsmAndResourceType)resourceTypeByScopeId:(NSString *)scopeId // return OsmAnd::ResourcesManager::ResourceType::MapRegion; else if ([scopeId isEqualToString:@"travel"]) return OsmAnd::ResourcesManager::ResourceType::Travel; + else if ([scopeId isEqualToString:@"starmap"]) + return OsmAnd::ResourcesManager::ResourceType::StarMap; else if ([scopeId isEqualToString:@"live_updates"]) return OsmAndResourceType::LiveUpdateRegion; else if ([scopeId isEqualToString:@"gpx"]) @@ -260,7 +269,8 @@ + (OsmAndResourceType)unknownType [self.class toValue:OsmAndResourceType::MapStylesPresets], [self.class toValue:OsmAndResourceType::OnlineTileSources], [self.class toValue:OsmAndResourceType::WeatherForecast], - [self.class toValue:OsmAndResourceType::Travel] + [self.class toValue:OsmAndResourceType::Travel], + [self.class toValue:OsmAndResourceType::StarMap] ]; } @@ -753,6 +763,9 @@ + (NSString *)titleOfResource:(const std::shared_ptrtype == OsmAndResourceType::StarMap) + return OALocalizedString(@"astronomy_map"); + auto name = resource->id; name = name.remove(QStringLiteral(".travel.obf")).replace('_', ' '); return name.toNSString().capitalizedString; @@ -792,6 +805,9 @@ + (NSString *)titleOfResourceType:(OsmAndResourceType)type nameStr = OALocalizedString(@"%@", region.name); } break; + case OsmAndResourceType::StarMap: + nameStr = OALocalizedString(@"astronomy_map"); + break; default: nameStr = nil; @@ -953,6 +969,9 @@ + (OAWorldRegion*) findRegionOrAnySubregionOf:(OAWorldRegion*)region const auto downloadsIdPrefix = QString::fromNSString(region.downloadsIdPrefix); const auto acceptedExtension = QString::fromNSString(region.acceptedExtension); + if ([region.regionId isEqualToString:OsmAnd::WorldRegions::TravelRegionId.toNSString()] && resourceId.endsWith(QLatin1String(".stardb"))) + return region; + if (!acceptedExtension.isEmpty()) { if (resourceId.endsWith(acceptedExtension)) @@ -1997,6 +2016,7 @@ + (void)deleteResourcesOf:(NSArray *)items progressHUD:(M { dispatch_block_t proc = ^{ OsmAndAppInstance app = [OsmAndApp instance]; + BOOL shouldClearAstronomyCache = NO; for (OALocalResourceItem *item in items) { if (item.resourceType == OsmAndResourceType::WeatherForecast) @@ -2032,6 +2052,8 @@ + (void)deleteResourcesOf:(NSArray *)items progressHUD:(M { if (item.resourceType == OsmAndResourceType::HeightmapRegionLegacy || item.resourceType == OsmAndResourceType::GeoTiffRegion) [app.data.terrainResourcesChangeObservable notifyEvent]; + if (item.resourceType == OsmAndResourceType::StarMap) + shouldClearAstronomyCache = YES; } if (item.resourceType == OsmAndResourceType::MapRegion || item.resourceType == OsmAndResourceType::RoadMapRegion) @@ -2041,6 +2063,12 @@ + (void)deleteResourcesOf:(NSArray *)items progressHUD:(M } } + if (shouldClearAstronomyCache) + { + AstronomyPlugin *plugin = (AstronomyPlugin *)[OAPluginsHelper getPlugin:AstronomyPlugin.class]; + [plugin clearCachedData]; + } + if (block) block(); }; diff --git a/Sources/Helpers/OAResourcesUISwiftHelper.h b/Sources/Helpers/OAResourcesUISwiftHelper.h index 0051c38124..edcbdd1ca4 100644 --- a/Sources/Helpers/OAResourcesUISwiftHelper.h +++ b/Sources/Helpers/OAResourcesUISwiftHelper.h @@ -38,7 +38,8 @@ typedef NS_ENUM(NSInteger, EOAOAResourceSwiftItemType) { EOAOAResourceSwiftItemTypeGpxFile, EOAOAResourceSwiftItemTypeSqliteFile, EOAOAResourceSwiftItemTypeWeatherForecast, - EOAOAResourceSwiftItemTypeTravel + EOAOAResourceSwiftItemTypeTravel, + EOAOAResourceSwiftItemTypeStarMap }; @@ -86,6 +87,7 @@ typedef NS_ENUM(NSInteger, EOAOAResourceSwiftItemType) { + (OAWorldRegion *) worldRegionByScopeId:(NSString *)regionId; + (NSNumber *) resourceTypeByScopeId:(NSString *)scopeId; ++ (void)prepareResourcesData; + (NSArray *) getResourcesInRepositoryIdsByRegionId:(NSString *)regionId resourceTypeNames:(NSArray *)resourceTypeNames; + (NSArray *) getResourcesInRepositoryIdsByRegion:(OAWorldRegion *)region resourceTypes:(NSArray *)resourceTypes; diff --git a/Sources/Helpers/OAResourcesUISwiftHelper.mm b/Sources/Helpers/OAResourcesUISwiftHelper.mm index db84c59360..ac24017b7d 100644 --- a/Sources/Helpers/OAResourcesUISwiftHelper.mm +++ b/Sources/Helpers/OAResourcesUISwiftHelper.mm @@ -101,6 +101,8 @@ - (EOAOAResourceSwiftItemType) resourceType return EOAOAResourceSwiftItemTypeWeatherForecast; case OsmAndResourceType::Travel: return EOAOAResourceSwiftItemTypeTravel; + case OsmAndResourceType::StarMap: + return EOAOAResourceSwiftItemTypeStarMap; default: return EOAOAResourceSwiftItemTypeUnknown; } @@ -268,6 +270,11 @@ + (NSNumber *) resourceTypeByScopeId:(NSString *)scopeId return [OAResourceType toValue:cppType]; } ++ (void)prepareResourcesData +{ + [OAManageResourcesViewController prepareData]; +} + + (NSArray *) getResourcesInRepositoryIdsByRegionId:(NSString *)regionId resourceTypeNames:(NSArray *)resourceTypeNames { OAWorldRegion *region = [self worldRegionByScopeId:regionId]; @@ -502,7 +509,10 @@ + (NSString *)titleOfResourceType:(EOAOAResourceSwiftItemType)type withRegionName:(BOOL)includeRegionName withResourceType:(BOOL)includeResourceType { - return [OAResourcesUIHelper titleOfResourceType:(OsmAndResourceType) type + OsmAndResourceType resourceType = type == EOAOAResourceSwiftItemTypeStarMap + ? OsmAndResourceType::StarMap + : (OsmAndResourceType) type; + return [OAResourcesUIHelper titleOfResourceType:resourceType inRegion:region withRegionName:includeRegionName withResourceType:includeResourceType]; diff --git a/Sources/Helpers/OAWikiArticleHelper.h b/Sources/Helpers/OAWikiArticleHelper.h index a58a702fb2..b31cff039d 100644 --- a/Sources/Helpers/OAWikiArticleHelper.h +++ b/Sources/Helpers/OAWikiArticleHelper.h @@ -46,6 +46,7 @@ typedef void(^OAWikiArticleSearchTaskBlockType)(void); + (NSString *) normalizeFileUrl:(NSString *)url; + (NSString *) getLang:(NSString *)url; + (NSString *) getArticleNameFromUrl:(NSString *)url lang:(NSString *)lang; ++ (nullable NSString *) readArchiveString:(NSData *)archiveData; + (UIMenu *)createLanguagesMenu:(NSArray *)availableLocales selectedLocale:(NSString *)selectedLocale delegate:(id)delegate; @end diff --git a/Sources/Helpers/OAWikiArticleHelper.mm b/Sources/Helpers/OAWikiArticleHelper.mm index c4460b6987..2a2fbe4b7a 100644 --- a/Sources/Helpers/OAWikiArticleHelper.mm +++ b/Sources/Helpers/OAWikiArticleHelper.mm @@ -26,6 +26,7 @@ #include #include +#include #define kPOpened @"

" #define kPClosed @"

" @@ -206,6 +207,29 @@ - (BOOL)isUniqueLocation:(CLLocation *)location regionsByLatLon:(NSDictionary(archiveData.bytes), static_cast(archiveData.length)); + bool ok = false; + const auto archiveItems = archive.getItems(&ok, true); + if (!ok) + return nil; + + for (const auto& archiveItem : constOf(archiveItems)) + { + if (!archiveItem.isValid()) + continue; + + const QString stringContent = archive.extractItemToString(archiveItem.name, true); + if (!stringContent.isEmpty()) + return stringContent.toNSString(); + } + return nil; +} + + (OAWorldRegion *) findWikiRegion:(OAWorldRegion *)mapRegion { if (mapRegion) diff --git a/Sources/Plugins/Astronomy/AstroArticle.swift b/Sources/Plugins/Astronomy/AstroArticle.swift new file mode 100644 index 0000000000..179d02a371 --- /dev/null +++ b/Sources/Plugins/Astronomy/AstroArticle.swift @@ -0,0 +1,123 @@ +// +// AstroArticle.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation + +final class AstroArticle: Equatable, Hashable { + let wikidata: String + let lang: String + let title: String + let description: String + let thumbnailUrl: String? + let summaryJson: String? + private let mobileHtml: Data? + private var mobileHtmlSize: Int? { + mobileHtml?.count + } + + init(wikidata: String, + lang: String, + title: String, + description: String, + thumbnailUrl: String?, + summaryJson: String?, + mobileHtml: Data?) { + self.wikidata = wikidata + self.lang = lang + self.title = title + self.description = description + self.thumbnailUrl = thumbnailUrl + self.summaryJson = summaryJson + self.mobileHtml = mobileHtml + } + + func hasOfflineContent() -> Bool { + mobileHtml?.isEmpty == false + } + + func getMobileHtmlString() -> String? { + guard let mobileHtml, !mobileHtml.isEmpty else { + return nil + } + let html = OAWikiArticleHelper.readArchiveString(mobileHtml) + if html == nil { + NSLog("Failed to decode astronomy article HTML archive for %@", wikidata) + } + return html + } + + func getOnlineArticleUrl() -> String? { + getSummaryArticleUrl() ?? buildFallbackArticleUrl() + } + + private func getSummaryArticleUrl() -> String? { + guard let summaryJson, + let data = summaryJson.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let content = json["content_urls"] as? [String: Any] else { + return nil + } + + if let mobile = content["mobile"] as? [String: Any], + let page = mobile["page"] as? String, + !page.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return page + } + if let desktop = content["desktop"] as? [String: Any], + let page = desktop["page"] as? String, + !page.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return page + } + return nil + } + + private func buildFallbackArticleUrl() -> String? { + guard !lang.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: " ", with: "_") + guard let baseUrl = URL(string: "https://\(lang.lowercased()).wikipedia.org/wiki") else { + return nil + } + return baseUrl.appendingPathComponent(normalizedTitle).absoluteString + } + + static func == (lhs: AstroArticle, rhs: AstroArticle) -> Bool { + lhs.wikidata == rhs.wikidata && + lhs.lang == rhs.lang && + lhs.title == rhs.title && + lhs.description == rhs.description && + lhs.thumbnailUrl == rhs.thumbnailUrl && + lhs.summaryJson == rhs.summaryJson && + lhs.mobileHtml == rhs.mobileHtml + } + + func hash(into hasher: inout Hasher) { + hasher.combine(wikidata) + hasher.combine(lang) + hasher.combine(title) + hasher.combine(description) + hasher.combine(thumbnailUrl) + hasher.combine(summaryJson) + hasher.combine(mobileHtmlSize) + } + + func isEqual(_ object: Any?) -> Bool { + guard let other = object as? AstroArticle else { + return false + } + return wikidata == other.wikidata && + lang == other.lang && + title == other.title && + description == other.description && + thumbnailUrl == other.thumbnailUrl && + summaryJson == other.summaryJson && + mobileHtml == other.mobileHtml + } +} diff --git a/Sources/Plugins/Astronomy/AstroConfigureViewBottomSheet.swift b/Sources/Plugins/Astronomy/AstroConfigureViewBottomSheet.swift new file mode 100644 index 0000000000..277a4d7369 --- /dev/null +++ b/Sources/Plugins/Astronomy/AstroConfigureViewBottomSheet.swift @@ -0,0 +1,898 @@ +// +// AstroConfigureViewBottomSheet.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class AstroConfigureViewBottomSheet: UIViewController, UISheetPresentationControllerDelegate { + private enum Layout { + static let contentPadding: CGFloat = 16 + static let contentPaddingSmall: CGFloat = 15 + static let contentPaddingMedium: CGFloat = 9 + static let contentPaddingMinimal: CGFloat = 2 + static let headerTitleRowHeight: CGFloat = 56 + static let headerTitlePadding: CGFloat = 32 + static let sectionCornerRadius: CGFloat = 26 + static let closeButtonSize: CGFloat = 48 + static let closeCircleSize: CGFloat = 44 + static let closeIconSize: CGFloat = 24 + } + + var config = AstronomyPluginSettings.StarMapConfig() + var commonConfig = AstronomyPluginSettings.CommonConfig() + var onConfigChanged: ((AstronomyPluginSettings.StarMapConfig) -> Void)? + var onCommonConfigChanged: ((AstronomyPluginSettings.CommonConfig) -> Void)? + var onRedFilterChanged: ((Bool) -> Void)? + var onClose: (() -> Void)? + var onDismissed: (() -> Void)? + + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let topCard = UIView() + private let topCardStack = UIStackView() + private let topButtonsRow = UIStackView() + private let visibleObjectsGridContent = UIStackView() + private let personalContent = UIStackView() + private let renderingContent = UIStackView() + + private var themeRenderActions: [() -> Void] = [] + private weak var redFilterCard: AstroActionCard? + + override func viewDidLoad() { + super.viewDidLoad() + setupScrollView() + setupContent() + bindMapActions() + bindVisibleObjects() + bindSwitchRows() + applyTheme() + configureNavigationBar() + applyRedFilter(enabled: config.showRedFilter) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + navigationController?.sheetPresentationController?.delegate = self + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true else { + return + } + applyTheme() + themeRenderActions.forEach { $0() } + applyRedFilter(enabled: config.showRedFilter) + } + + func applyRedFilter(enabled: Bool) { + config.showRedFilter = enabled + if let redFilterCard { + renderToggleCard(card: redFilterCard, + checked: enabled, + drawableEnabled: redFilterIcon(selected: true), + drawableDisabled: redFilterIcon(selected: false), + titleResEnabled: "red_filter") + } + if isViewLoaded { + AstroRedFilter.apply(enabled, to: view) + } + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + onDismissed?() + } + + private func configureNavigationBar() { + let imageClose = OAUtilities.resize(UIImage.templateImageNamed("ic_navbar_close"), + newSize: CGSize(width: 24, height: 24))?.withRenderingMode(.alwaysTemplate) + let closeButton = UIBarButtonItem(image: imageClose, style: .plain, target: self, action: #selector(closeAction)) + closeButton.tintColor = .label + closeButton.accessibilityLabel = localizedString("shared_string_close") + + title = localizedString("astro_configure_view") + navigationItem.title = localizedString("astro_configure_view") + navigationItem.largeTitleDisplayMode = .never + navigationItem.leftBarButtonItem = closeButton + navigationItem.rightBarButtonItem = nil + } + + private func setupScrollView() { + view.addSubview(scrollView) + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.alwaysBounceVertical = true + scrollView.showsVerticalScrollIndicator = true + scrollView.addSubview(contentStack) + + contentStack.axis = .vertical + contentStack.spacing = Layout.contentPadding + contentStack.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -Layout.contentPadding), + contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor) + ]) + } + + private func setupContent() { + setupTopCard() + contentStack.addArrangedSubview(topCard) + contentStack.addArrangedSubview(setupSection(stackView: personalContent, with: localizedString("personal_category_name"))) + contentStack.addArrangedSubview(setupSection(stackView: renderingContent, with: localizedString("astro_rendering"))) + } + + private func setupTopCard() { + topCard.translatesAutoresizingMaskIntoConstraints = false + topCard.addSubview(topCardStack) + + topCardStack.axis = .vertical + topCardStack.spacing = 0 + topCardStack.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + topCardStack.leadingAnchor.constraint(equalTo: topCard.leadingAnchor), + topCardStack.trailingAnchor.constraint(equalTo: topCard.trailingAnchor), + topCardStack.topAnchor.constraint(equalTo: topCard.topAnchor), + topCardStack.bottomAnchor.constraint(equalTo: topCard.bottomAnchor) + ]) + + setupTopButtonsRow() + topCardStack.addArrangedSubview(topButtonsRow) + topCardStack.addArrangedSubview(createHeaderView(text: localizedString("astro_visible_objects"))) + setupVisibleObjectsGrid() + topCardStack.addArrangedSubview(visibleObjectsGridContent) + } + + private func setupTopButtonsRow() { + topButtonsRow.axis = .horizontal + topButtonsRow.distribution = .fillEqually + topButtonsRow.spacing = Layout.contentPaddingSmall + topButtonsRow.layoutMargins = UIEdgeInsets(top: Layout.contentPaddingMedium, + left: Layout.contentPadding, + bottom: Layout.contentPadding, + right: Layout.contentPadding) + topButtonsRow.isLayoutMarginsRelativeArrangement = true + } + + private func setupVisibleObjectsGrid() { + visibleObjectsGridContent.axis = .vertical + visibleObjectsGridContent.spacing = Layout.contentPaddingSmall + visibleObjectsGridContent.layoutMargins = UIEdgeInsets(top: 0, + left: Layout.contentPadding, + bottom: 0, + right: Layout.contentPadding) + visibleObjectsGridContent.isLayoutMarginsRelativeArrangement = true + } + + private func setupSection(stackView: UIStackView, with title: String) -> UIView { + let header = createHeaderView(text: title) + + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + + stackView.layer.cornerRadius = Layout.sectionCornerRadius + stackView.layer.masksToBounds = true + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(header) + container.addSubview(stackView) + + NSLayoutConstraint.activate([ + header.leadingAnchor.constraint(equalTo: container.leadingAnchor), + header.trailingAnchor.constraint(equalTo: container.trailingAnchor), + header.topAnchor.constraint(equalTo: container.topAnchor, constant: Layout.contentPadding), + + stackView.leadingAnchor.constraint(equalTo: container.safeAreaLayoutGuide.leadingAnchor, constant: Layout.contentPadding), + stackView.trailingAnchor.constraint(equalTo: container.safeAreaLayoutGuide.trailingAnchor, constant: -Layout.contentPadding), + stackView.topAnchor.constraint(equalTo: header.bottomAnchor), + stackView.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + + return container + } + + private func createHeaderView(text: String) -> UIView { + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + + let title = UILabel() + title.text = text + title.textColor = .textColorSecondary + title.font = UIFont.systemFont(ofSize: 16, weight: .semibold) + title.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(title) + + NSLayoutConstraint.activate([ + title.leadingAnchor.constraint(equalTo: container.safeAreaLayoutGuide.leadingAnchor, constant: Layout.headerTitlePadding), + title.trailingAnchor.constraint(equalTo: container.safeAreaLayoutGuide.trailingAnchor, constant: -Layout.headerTitlePadding), + title.topAnchor.constraint(equalTo: container.topAnchor, constant: Layout.contentPaddingSmall), + title.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -Layout.contentPaddingSmall) + ]) + + return container + } + + private func bindMapActions() { + bindToggleMapActionCard( + card: addActionCard(to: topButtonsRow), + drawableEnabled: .icCustomGlobeView, + drawableDisabled: .icCustomCelestialPath, + titleResEnabled: "map_3d", + titleResDisabled: "map_2d", + isChecked: { [weak self] in + guard let self else { + return false + } + return !config.is2DMode + }, + toggle: { [weak self] enabled3d in + guard let self else { + return + } + config.is2DMode = !enabled3d + onConfigChanged?(config) + } + ) + + bindToggleMapActionCard( + card: addActionCard(to: topButtonsRow), + drawableEnabled: AstroIcon.template("ic_custom_map"), + drawableDisabled: .icCustomMapOutline, + titleResEnabled: "shared_string_map", + isChecked: { [weak self] in + self?.commonConfig.showRegularMap ?? false + }, + toggle: { [weak self] regularMap in + guard let self else { + return + } + commonConfig.showRegularMap = regularMap + onCommonConfigChanged?(commonConfig) + } + ) + + let redCard = addActionCard(to: topButtonsRow) + redFilterCard = redCard + bindToggleMapActionCard( + card: redCard, + drawableEnabled: redFilterIcon(selected: true), + drawableDisabled: redFilterIcon(selected: false), + titleResEnabled: "red_filter", + isChecked: { [weak self] in + self?.config.showRedFilter ?? false + }, + toggle: { [weak self] checked in + guard let self else { + return + } + config.showRedFilter = checked + if let onRedFilterChanged { + onRedFilterChanged(checked) + } else { + onConfigChanged?(config) + } + } + ) + } + + private func bindVisibleObjects() { + visibleObjectsGridContent.addArrangedSubview(gridRow([ + bindToggleAstroCard( + card: AstroActionCard(), + iconName: "ic_custom_planet_outlined", + titleRes: "astro_solar_system", + isChecked: { c in c.showSun && c.showMoon && c.showPlanets }, + toggle: { c in + let allOn = c.showSun && c.showMoon && c.showPlanets + let newValue = !allOn + var updated = c + updated.showSun = newValue + updated.showMoon = newValue + updated.showPlanets = newValue + return updated + } + ), + bindToggleAstroCard( + card: AstroActionCard(), + iconName: "ic_custom_constellations", + titleRes: "astro_constellations", + isChecked: { $0.showConstellations }, + toggle: { c in + var updated = c + updated.showConstellations.toggle() + return updated + } + ), + bindToggleAstroCard( + card: AstroActionCard(), + iconName: "ic_custom_star_shine", + titleRes: "astro_stars", + isChecked: { $0.showStars }, + toggle: { c in + var updated = c + updated.showStars.toggle() + return updated + } + ) + ])) + + visibleObjectsGridContent.addArrangedSubview(gridRow([ + bindToggleAstroCard( + card: AstroActionCard(), + iconName: "ic_custom_nebulas", + titleRes: "astro_nebulas", + isChecked: { $0.showNebulae }, + toggle: { c in + var updated = c + updated.showNebulae.toggle() + return updated + } + ), + bindToggleAstroCard( + card: AstroActionCard(), + iconName: "ic_custom_star_clusters", + titleRes: "astro_star_clusters", + isChecked: { c in c.showOpenClusters && c.showGlobularClusters }, + toggle: { c in + let allOn = c.showOpenClusters && c.showGlobularClusters + let newValue = !allOn + var updated = c + updated.showOpenClusters = newValue + updated.showGlobularClusters = newValue + return updated + } + ), + bindToggleAstroCard( + card: AstroActionCard(), + iconName: "ic_custom_galaxy", + titleRes: "astro_deep_sky", + isChecked: { c in c.showGalaxies && c.showBlackHoles && c.showGalaxyClusters }, + toggle: { c in + let allOn = c.showGalaxies && c.showBlackHoles && c.showGalaxyClusters + let newValue = !allOn + var updated = c + updated.showGalaxies = newValue + updated.showBlackHoles = newValue + updated.showGalaxyClusters = newValue + return updated + } + ) + ])) + } + + private func bindSwitchRows() { + let current = config + + addSwitchRow( + parent: personalContent, + iconNameEnabled: "ic_custom_target_direction_on", + iconNameDisabled: "ic_custom_target_direction_off", + titleRes: "astro_directions", + checked: current.showDirections + ) { [weak self] checked in + guard let self else { + return + } + var updated = config + updated.showDirections = checked + applyConfigChange(updated) + } + + addSwitchRow( + parent: personalContent, + iconNameEnabled: "ic_custom_bookmark", + iconNameDisabled: "ic_custom_bookmark_outlined", + titleRes: "favorites_item", + checked: current.showFavorites + ) { [weak self] checked in + guard let self else { + return + } + var updated = config + updated.showFavorites = checked + applyConfigChange(updated) + } + + addSwitchRow( + parent: personalContent, + iconNameEnabled: "ic_custom_target_path_on", + iconNameDisabled: "ic_custom_target_path_off", + titleRes: "astro_daily_path", + checked: current.showCelestialPaths, + showDivider: false + ) { [weak self] checked in + guard let self else { + return + } + var updated = config + updated.showCelestialPaths = checked + applyConfigChange(updated) + } + + addSwitchRow( + parent: renderingContent, + iconNameEnabled: "ic_custom_azimuthal_grid", + iconNameDisabled: "ic_custom_azimuthal_grid", + titleRes: "azimuthal_grid", + checked: current.showAzimuthalGrid + ) { [weak self] checked in + guard let self else { + return + } + var updated = config + updated.showAzimuthalGrid = checked + applyConfigChange(updated) + } + + addSwitchRow( + parent: renderingContent, + iconNameEnabled: "ic_custom_meridian_line", + iconNameDisabled: "ic_custom_meridian_line", + titleRes: "meridian_line", + checked: current.showMeridianLine + ) { [weak self] checked in + guard let self else { + return + } + var updated = config + updated.showMeridianLine = checked + applyConfigChange(updated) + } + + addSwitchRow( + parent: renderingContent, + iconNameEnabled: "ic_custom_equatorial_grid", + iconNameDisabled: "ic_custom_equatorial_grid", + titleRes: "equatorial_grid", + checked: current.showEquatorialGrid + ) { [weak self] checked in + guard let self else { + return + } + var updated = config + updated.showEquatorialGrid = checked + applyConfigChange(updated) + } + + addSwitchRow( + parent: renderingContent, + iconNameEnabled: "ic_custom_eliptical_line", + iconNameDisabled: "ic_custom_eliptical_line", + titleRes: "ecliptic_line", + checked: current.showEclipticLine + ) { [weak self] checked in + guard let self else { + return + } + var updated = config + updated.showEclipticLine = checked + applyConfigChange(updated) + } + + addSwitchRow( + parent: renderingContent, + iconNameEnabled: "ic_custom_galaxy_equator", + iconNameDisabled: "ic_custom_galaxy_equator", + titleRes: "equator_line", + checked: current.showEquatorLine, + showDivider: true + ) { [weak self] checked in + guard let self else { + return + } + var updated = config + updated.showEquatorLine = checked + applyConfigChange(updated) + } + + addSwitchRow( + parent: renderingContent, + iconNameEnabled: "ic_custom_galaxy_line", + iconNameDisabled: "ic_custom_galaxy_line", + titleRes: "galactic_line", + checked: current.showGalacticLine, + showDivider: false + ) { [weak self] checked in + guard let self else { + return + } + var updated = config + updated.showGalacticLine = checked + applyConfigChange(updated) + } + } + + private func renderToggleCard( + card: AstroActionCard, + checked: Bool, + drawableEnabled: UIImage?, + drawableDisabled: UIImage? = nil, + titleResEnabled: String, + titleResDisabled: String? = nil + ) { + let icon = checked ? drawableEnabled : (drawableDisabled ?? drawableEnabled) + let titleRes = checked ? titleResEnabled : (titleResDisabled ?? titleResEnabled) + card.render(checked: checked, icon: icon, title: localizedString(titleRes)) + } + + private func bindToggleMapActionCard( + card: AstroActionCard, + drawableEnabled: UIImage?, + drawableDisabled: UIImage? = nil, + titleResEnabled: String, + titleResDisabled: String? = nil, + isChecked: @escaping () -> Bool, + toggle: @escaping (Bool) -> Void + ) { + let render: () -> Void = { [weak self, weak card] in + guard let self, let card else { + return + } + renderToggleCard(card: card, + checked: isChecked(), + drawableEnabled: drawableEnabled, + drawableDisabled: drawableDisabled, + titleResEnabled: titleResEnabled, + titleResDisabled: titleResDisabled) + } + + render() + themeRenderActions.append(render) + + card.addAction(UIAction { _ in + let newValue = !isChecked() + toggle(newValue) + render() + }, for: .touchUpInside) + } + + private func bindToggleAstroCard( + card: AstroActionCard, + iconName: String, + titleRes: String, + isChecked: @escaping (AstronomyPluginSettings.StarMapConfig) -> Bool, + toggle: @escaping (AstronomyPluginSettings.StarMapConfig) -> AstronomyPluginSettings.StarMapConfig + ) -> AstroActionCard { + let render: () -> Void = { [weak self, weak card] in + guard let self, let card else { + return + } + renderToggleCard(card: card, + checked: isChecked(config), + drawableEnabled: AstroIcon.template(iconName), + titleResEnabled: titleRes) + } + + render() + themeRenderActions.append(render) + + card.addAction(UIAction { [weak self, weak card] _ in + guard let self else { + return + } + let newConfig = toggle(config) + applyConfigChange(newConfig) + if let card { + renderToggleCard(card: card, + checked: isChecked(newConfig), + drawableEnabled: AstroIcon.template(iconName), + titleResEnabled: titleRes) + } + }, for: .touchUpInside) + + return card + } + + private func addSwitchRow( + parent: UIStackView, + iconNameEnabled: String, + iconNameDisabled: String, + titleRes: String, + checked: Bool, + showDivider: Bool = true, + onToggle: @escaping (Bool) -> Void + ) { + let row = AstroSwitchRow(iconNameEnabled: iconNameEnabled, + iconNameDisabled: iconNameDisabled, + title: localizedString(titleRes), + checked: checked, + showDivider: showDivider, + onToggle: onToggle) + parent.addArrangedSubview(row) + } + + private func setupSwitchItemIcon(_ imageView: UIImageView, iconName: String, isChecked: Bool) { + imageView.image = AstroIcon.template(iconName) + imageView.tintColor = isChecked ? .iconColorActive : .iconColorDefault + } + + private func addActionCard(to row: UIStackView) -> AstroActionCard { + let card = AstroActionCard() + row.addArrangedSubview(card) + return card + } + + private func gridRow(_ cards: [UIView]) -> UIStackView { + let row = UIStackView(arrangedSubviews: cards) + row.axis = .horizontal + row.distribution = .fillEqually + row.spacing = Layout.contentPaddingSmall + return row + } + + private func divider() -> UIView { + let divider = UIView() + divider.backgroundColor = .customSeparator + divider.translatesAutoresizingMaskIntoConstraints = false + divider.heightAnchor.constraint(equalToConstant: AstroConfigureTheme.separatorHeight).isActive = true + return divider + } + + private func redFilterIcon(selected: Bool) -> UIImage? { + guard selected else { + return .icCustomRedFilterOff + } + return AstroIcon.layeredTemplate(baseName: "ic_custom_red_filter_base_on", + baseColor: .iconColorActive, + overlayName: "ic_custom_red_filter_overlay_on", + overlayColor: .iconColorDisruptive) + } + + private func applyConfigChange(_ newConfig: AstronomyPluginSettings.StarMapConfig) { + config = newConfig + onConfigChanged?(newConfig) + } + + private func applyTheme() { + view.backgroundColor = .viewBg + } + + @objc + private func closeAction() { + onClose?() + } +} + +private enum AstroConfigureTheme { + static var separatorHeight: CGFloat { + 1 / UIScreen.main.scale + } + + static var actionTileBackground: UIColor { + UIColor(named: "groupBgColorSecondary") ?? .buttonBgColorSecondary + } + + static var actionTileSelectedBackground: UIColor { + UIColor(named: "cellBgColorSelected") ?? .buttonBgColorTertiary + } +} + +private final class AstroActionCard: UIControl { + + override var isHighlighted: Bool { + didSet { + alpha = isHighlighted ? 0.65 : 1 + } + } + + private let iconView = UIImageView() + private let titleLabel = UILabel() + private var checked = false + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + applyStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true { + applyStyle() + } + } + + func render(checked: Bool, icon: UIImage?, title: String) { + self.checked = checked + iconView.image = icon + titleLabel.text = title + applyStyle() + } + + private func setupView() { + translatesAutoresizingMaskIntoConstraints = false + layer.cornerRadius = 10 + layer.masksToBounds = true + + iconView.contentMode = .scaleAspectFit + iconView.translatesAutoresizingMaskIntoConstraints = false + iconView.isUserInteractionEnabled = false + + titleLabel.font = .preferredFont(forTextStyle: .subheadline) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 2 + titleLabel.adjustsFontSizeToFitWidth = true + titleLabel.minimumScaleFactor = 0.8 + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.isUserInteractionEnabled = false + + addSubview(iconView) + addSubview(titleLabel) + + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: 75), + iconView.topAnchor.constraint(equalTo: topAnchor, constant: 12), + iconView.centerXAnchor.constraint(equalTo: centerXAnchor), + iconView.widthAnchor.constraint(equalToConstant: 24), + iconView.heightAnchor.constraint(equalToConstant: 24), + titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4), + titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10) + ]) + } + + private func applyStyle() { + backgroundColor = checked ? .buttonBgColorTertiary : .groupBg + layer.borderWidth = checked ? 2 : 0 + layer.borderColor = UIColor.buttonBgColorPrimary.cgColor + iconView.tintColor = .iconColorActive + titleLabel.textColor = .buttonTextColorSecondary + } +} + +private final class AstroSwitchRow: UIControl { + + override var isHighlighted: Bool { + didSet { + alpha = isHighlighted ? 0.65 : 1 + } + } + + private let iconNameEnabled: String + private let iconNameDisabled: String + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let switcher = UISwitch() + private let dividerView = UIView() + private let onToggle: (Bool) -> Void + private var checked: Bool + + init(iconNameEnabled: String, + iconNameDisabled: String, + title: String, + checked: Bool, + showDivider: Bool, + onToggle: @escaping (Bool) -> Void) { + self.iconNameEnabled = iconNameEnabled + self.iconNameDisabled = iconNameDisabled + self.checked = checked + self.onToggle = onToggle + super.init(frame: .zero) + setupView(title: title, checked: checked, showDivider: showDivider) + applyStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true { + applyStyle() + } + } + + private func setupView(title: String, checked: Bool, showDivider: Bool) { + translatesAutoresizingMaskIntoConstraints = false + backgroundColor = .clear + addAction(UIAction { [weak self] _ in + self?.toggleFromRow() + }, for: .touchUpInside) + + let contentView = UIView() + contentView.backgroundColor = .groupBg + contentView.isUserInteractionEnabled = false + contentView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentView) + + iconView.image = AstroIcon.template(checked ? iconNameEnabled : iconNameDisabled) + iconView.contentMode = .scaleAspectFit + iconView.translatesAutoresizingMaskIntoConstraints = false + + titleLabel.text = title + titleLabel.textColor = .textColorPrimary + titleLabel.font = .preferredFont(forTextStyle: .body) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.adjustsFontSizeToFitWidth = true + titleLabel.minimumScaleFactor = 0.8 + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + switcher.isOn = checked + switcher.translatesAutoresizingMaskIntoConstraints = false + switcher.addAction(UIAction { [weak self] _ in + self?.switchChanged() + }, for: .valueChanged) + + dividerView.backgroundColor = .customSeparator + dividerView.isHidden = !showDivider + dividerView.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(iconView) + contentView.addSubview(titleLabel) + addSubview(switcher) + addSubview(dividerView) + + let dividerHeight = showDivider ? AstroConfigureTheme.separatorHeight : 0 + + NSLayoutConstraint.activate([ + heightAnchor.constraint(greaterThanOrEqualToConstant: 52), + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.topAnchor.constraint(equalTo: topAnchor), + contentView.bottomAnchor.constraint(equalTo: dividerView.topAnchor), + dividerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 56), + dividerView.trailingAnchor.constraint(equalTo: trailingAnchor), + dividerView.bottomAnchor.constraint(equalTo: bottomAnchor), + dividerView.heightAnchor.constraint(equalToConstant: dividerHeight), + iconView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 24), + iconView.heightAnchor.constraint(equalToConstant: 24), + switcher.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + switcher.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: switcher.leadingAnchor, constant: -16), + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + } + + private func toggleFromRow() { + setChecked(!checked, sendAction: true) + } + + private func switchChanged() { + setChecked(switcher.isOn, sendAction: true) + } + + private func setChecked(_ checked: Bool, sendAction: Bool) { + self.checked = checked + switcher.setOn(checked, animated: true) + applyStyle() + if sendAction { + onToggle(checked) + } + } + + private func applyStyle() { + iconView.image = AstroIcon.template(checked ? iconNameEnabled : iconNameDisabled) + iconView.tintColor = checked ? .iconColorActive : .iconColorDefault + titleLabel.textColor = .textColorPrimary + dividerView.backgroundColor = .customSeparator + } +} diff --git a/Sources/Plugins/Astronomy/AstroDataDbProvider.swift b/Sources/Plugins/Astronomy/AstroDataDbProvider.swift new file mode 100644 index 0000000000..e8ae95a080 --- /dev/null +++ b/Sources/Plugins/Astronomy/AstroDataDbProvider.swift @@ -0,0 +1,416 @@ +// +// AstroDataDbProvider.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared +import SQLite3 +import UIKit + +final class AstroDataDbProvider: AstroDataProvider { + private enum Constants { + static let astroDir = "astro" + static let databaseName = "stars.db" + static let databaseNameExtended = "stars-articles.stardb" + static let queryChunkSize = 900 + } + + override func getSkyObjectsImpl(preferredLocale: String?) -> [SkyObject] { + guard let db = openDatabase() else { + var objects: [SkyObject] = [] + getPlanets(&objects) + return objects + } + + var objects: [SkyObject] = [] + let rows = db.rows(""" + SELECT wikidata, name, type, ra, dec, mag, hip, radius, distance, mass, centerwid + FROM Objects + WHERE type != ? + """, arguments: ["constellations"]) + for row in rows { + guard let typeStr = row.string("type"), + var type = SkyObjectType.fromDbType(typeStr), + let originalName = row.string("name") else { + continue + } + + let wikidata = row.string("wikidata") ?? "" + var body: Body? + var color = getTypeColor(type) + + if typeStr == "solar_system" { + body = getBody(wid: wikidata) + guard let body else { + continue + } + type = body === Body.sun ? .SUN : (body === Body.moon ? .MOON : .PLANET) + color = AstroUtils.bodyColor(body) + } + + objects.append(SkyObject(id: generateId(type: type, name: originalName), + hip: row.int("hip") ?? -1, + wid: wikidata, + centerWId: row.string("centerwid"), + type: type, + body: body, + name: originalName, + ra: row.double("ra") ?? 0, + dec: row.double("dec") ?? 0, + magnitude: row.double("mag") ?? 25, + color: color, + radius: row.double("radius"), + distance: row.double("distance"), + mass: row.double("mass"))) + } + + loadLocalizedNames(db: db, preferredLocale: preferredLocale, objects: objects) + loadCatalogs(db: db, objects: objects) + + if objects.isEmpty { + getPlanets(&objects) + } + return objects + } + + override func getCatalogsImpl() -> [Catalog] { + guard let db = openDatabase() else { + return [] + } + + return db.rows("SELECT catalogWid, catalogName FROM Catalogs").compactMap { row in + guard let wid = row.string("catalogWid"), let name = row.string("catalogName") else { + return nil + } + return Catalog(wid: wid, name: name, catalogId: "") + } + } + + override func getConstellationsImpl(preferredLocale: String?) -> [Constellation] { + guard let db = openDatabase() else { + return [] + } + + var constellations: [Constellation] = [] + let rows = db.rows(""" + SELECT name, wikidata, lines + FROM Objects + WHERE type = ? + """, arguments: ["constellations"]) + for row in rows { + guard let name = row.string("name") else { + continue + } + let lines = parseLines(row.string("lines")) + if !lines.isEmpty { + constellations.append(Constellation( + name: name, + wid: row.string("wikidata") ?? "", + lines: lines + )) + } + } + loadLocalizedNames(db: db, preferredLocale: preferredLocale, objects: constellations) + loadCatalogs(db: db, objects: constellations) + return constellations + } + + override func getAstroArticleImpl(wikidataId: String, lang: String? = nil) -> AstroArticle? { + guard let db = openDatabase() else { + return nil + } + + let bestLang = localeLanguage(lang) + let rows = db.rows(""" + SELECT wikidata, lang, title, extract, thumbnail_url, summary_json, mobile_html + FROM Wikipedia + WHERE wikidata = ? AND (lang = ? OR lang = ?) + """, arguments: [wikidataId, bestLang, "en"]) + + var bestArticle: AstroArticle? + var enArticle: AstroArticle? + for row in rows { + let rowLang = row.string("lang") ?? "" + let article = AstroArticle(wikidata: wikidataId, + lang: rowLang, + title: row.string("title") ?? "", + description: row.string("extract") ?? "", + thumbnailUrl: row.string("thumbnail_url"), + summaryJson: row.string("summary_json"), + mobileHtml: row.data("mobile_html")) + if rowLang == bestLang { + bestArticle = article + } + if rowLang == "en" { + enArticle = article + } + } + return bestArticle ?? enArticle + } + + private func openDatabase() -> SQLiteDatabase? { + for path in databaseLookupPaths() where FileManager.default.fileExists(atPath: path) { + if let db = SQLiteDatabase(path: path) { + return db + } + } + if let path = Bundle.main.path(forResource: "stars", ofType: "db", inDirectory: "Shipped"), + let db = SQLiteDatabase(path: path) { + return db + } + return nil + } + + private func databaseLookupPaths() -> [String] { + guard let documentsPath = OsmAndApp.swiftInstance()?.documentsPath else { + return [] + } + let astroDir = documentsPath.appendingPathComponent(RESOURCES_DIR).appendingPathComponent(Constants.astroDir) + return [ + astroDir.appendingPathComponent(Constants.databaseNameExtended), + astroDir.appendingPathComponent(Constants.databaseName) + ] + } + + private func loadLocalizedNames(db: SQLiteDatabase, preferredLocale: String?, objects: [SkyObject]) { + let wikidataIds = uniqueWikidataIds(from: objects) + guard !wikidataIds.isEmpty else { + return + } + + let targets = localePriorities(preferredLocale) + var namesMap: [String: [String: String]] = [:] + for chunk in chunked(wikidataIds, size: Constants.queryChunkSize) { + let placeholders = Array(repeating: "?", count: chunk.count).joined(separator: ",") + let rows = db.rows(""" + SELECT wikidata, name, type + FROM Names + WHERE wikidata IN (\(placeholders)) + """, arguments: chunk) + for row in rows { + guard let wid = row.string("wikidata"), + let name = row.string("name"), + let type = row.string("type") else { + continue + } + let types = type.split(separator: ",").map { $0.trimmingCharacters(in: CharacterSet(charactersIn: " \"")) } + for target in types where targets.contains(target) { + namesMap[wid, default: [:]][target] = name + } + } + } + + for object in objects where !object.wid.isEmpty { + guard let names = namesMap[object.wid] else { + continue + } + for target in targets { + if let name = names[target] { + object.localizedName = name + break + } + } + } + } + + private func loadCatalogs(db: SQLiteDatabase, objects: [SkyObject]) { + let wikidataIds = uniqueWikidataIds(from: objects) + guard !wikidataIds.isEmpty else { + return + } + + let catalogs = Dictionary(uniqueKeysWithValues: getCatalogs().map { ($0.wid, $0) }) + var allCatalogsMap: [String: [Catalog]] = [:] + for chunk in chunked(wikidataIds, size: Constants.queryChunkSize) { + let placeholders = Array(repeating: "?", count: chunk.count).joined(separator: ",") + let rows = db.rows(""" + SELECT catalogWid, catalogId, wikidataid + FROM CatalogIds + WHERE wikidataid IN (\(placeholders)) + """, arguments: chunk) + for row in rows { + guard let wikiId = row.string("wikidataid"), + let catalogWid = row.string("catalogWid"), + let catalogId = row.string("catalogId"), + let baseCatalog = catalogs[catalogWid] else { + continue + } + allCatalogsMap[wikiId, default: []].append(Catalog(wid: baseCatalog.wid, + name: baseCatalog.name, + catalogId: catalogId)) + } + } + + for object in objects where !object.wid.isEmpty { + if let catalogs = allCatalogsMap[object.wid], !catalogs.isEmpty { + object.catalogs = catalogs + } + } + } + + private func getBody(wid: String) -> Body? { + AstroUtils.solarSystemWikidataIds[wid] + } + + private func localeLanguage(_ preferredLocale: String?) -> String { + let locale = preferredLocale ?? OsmAndApp.swiftInstance()?.getLanguageCode() ?? Locale.current.languageCode ?? "en" + return locale.split(separator: "-").first.map(String.init)?.lowercased() ?? "en" + } + + private func localePriorities(_ preferredLocale: String?) -> [String] { + let lang = localeLanguage(preferredLocale) + return [lang, "\(lang)wiki", "en", "enwiki", "mul"] + } + + private func uniqueWikidataIds(from objects: [SkyObject]) -> [String] { + var seen = Set() + var result: [String] = [] + for object in objects where !object.wid.isEmpty && seen.insert(object.wid).inserted { + result.append(object.wid) + } + return result + } + + private func chunked(_ values: [T], size: Int) -> [[T]] { + stride(from: 0, to: values.count, by: size).map { + Array(values[$0.. String? { + switch values[key] { + case .string(let value): + return value + case .int(let value): + return String(value) + case .double(let value): + return String(value) + default: + return nil + } + } + + func int(_ key: String) -> Int? { + switch values[key] { + case .int(let value): + return value + case .double(let value): + return Int(value) + case .string(let value): + return Int(value) + default: + return nil + } + } + + func double(_ key: String) -> Double? { + switch values[key] { + case .double(let value): + return value + case .int(let value): + return Double(value) + case .string(let value): + return Double(value) + default: + return nil + } + } + + func data(_ key: String) -> Data? { + switch values[key] { + case .data(let value): + return value + case .string(let value): + return value.data(using: .utf8) + default: + return nil + } + } +} + +private final class SQLiteDatabase { + private var handle: OpaquePointer? + private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + init?(path: String) { + guard sqlite3_open_v2(path, &handle, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + if handle != nil { + sqlite3_close(handle) + } + return nil + } + } + + deinit { + sqlite3_close(handle) + } + + func rows(_ sql: String, arguments: [String] = []) -> [SQLiteRow] { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + return [] + } + defer { + sqlite3_finalize(statement) + } + + for (index, argument) in arguments.enumerated() { + guard sqlite3_bind_text(statement, Int32(index + 1), argument, -1, transient) == SQLITE_OK else { + return [] + } + } + + var result: [SQLiteRow] = [] + while sqlite3_step(statement) == SQLITE_ROW { + let count = sqlite3_column_count(statement) + var values: [String: SQLiteValue] = [:] + for index in 0.. SQLiteValue { + switch sqlite3_column_type(statement, index) { + case SQLITE_INTEGER: + return .int(Int(sqlite3_column_int64(statement, index))) + case SQLITE_FLOAT: + return .double(sqlite3_column_double(statement, index)) + case SQLITE_TEXT: + guard let text = sqlite3_column_text(statement, index) else { + return .null + } + let cString = UnsafeRawPointer(text).assumingMemoryBound(to: CChar.self) + return .string(String(cString: cString)) + case SQLITE_BLOB: + guard let bytes = sqlite3_column_blob(statement, index) else { + return .null + } + return .data(Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, index)))) + default: + return .null + } + } +} diff --git a/Sources/Plugins/Astronomy/AstroDataProvider.swift b/Sources/Plugins/Astronomy/AstroDataProvider.swift new file mode 100644 index 0000000000..d39f36e5c5 --- /dev/null +++ b/Sources/Plugins/Astronomy/AstroDataProvider.swift @@ -0,0 +1,172 @@ +// +// AstroDataProvider.swift +// OsmAnd Maps +// +// Created by Codex on 19.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared +import UIKit + +class AstroDataProvider { + private var cachedSkyObjects: [SkyObject]? + private var cachedCatalogs: [Catalog]? + private var cachedConstellations: [Constellation]? + + func getSkyObjectsImpl(preferredLocale: String?) -> [SkyObject] { + [] + } + + func getCatalogsImpl() -> [Catalog] { + [] + } + + func getConstellationsImpl(preferredLocale: String?) -> [Constellation] { + [] + } + + func getAstroArticleImpl(wikidataId: String, lang: String? = nil) -> AstroArticle? { + nil + } + + func getCatalogs() -> [Catalog] { + if let cachedCatalogs { + return cachedCatalogs + } + let catalogs = getCatalogsImpl() + cachedCatalogs = catalogs + return catalogs + } + + func getSkyObjects(preferredLocale: String?) -> [SkyObject] { + if cachedCatalogs == nil { + cachedCatalogs = getCatalogsImpl() + } + if let cachedSkyObjects { + return cachedSkyObjects + } + let objects = getSkyObjectsImpl(preferredLocale: preferredLocale) + cachedSkyObjects = objects + return objects + } + + func getConstellations(preferredLocale: String?) -> [Constellation] { + if let cachedConstellations { + return cachedConstellations + } + let constellations = getConstellationsImpl(preferredLocale: preferredLocale) + var skyObjectMap: [Int: SkyObject] = [:] + for object in getSkyObjects(preferredLocale: preferredLocale) { + skyObjectMap[object.hip] = object + } + for constellation in constellations { + if let center = AstroUtils.calculateConstellationCenter(constellation, skyObjectMap: skyObjectMap) { + constellation.ra = center.0 + constellation.dec = center.1 + } else { + constellation.ra = 0 + constellation.dec = 0 + } + } + cachedConstellations = constellations + return constellations + } + + func getAstroArticle(wikidataId: String, lang: String? = nil) -> AstroArticle? { + getAstroArticleImpl(wikidataId: wikidataId, lang: lang) + } + + func clearCache() { + cachedSkyObjects = nil + cachedCatalogs = nil + cachedConstellations = nil + } + + func getPlanets(_ objects: inout [SkyObject]) { + let planets: [(Body, UIColor, String)] = [ + (Body.sun, AstroUtils.bodyColor(Body.sun), "Q525"), + (Body.moon, AstroUtils.bodyColor(Body.moon), "Q405"), + (Body.mercury, AstroUtils.bodyColor(Body.mercury), "Q308"), + (Body.venus, AstroUtils.bodyColor(Body.venus), "Q313"), + (Body.mars, AstroUtils.bodyColor(Body.mars), "Q111"), + (Body.jupiter, AstroUtils.bodyColor(Body.jupiter), "Q319"), + (Body.saturn, AstroUtils.bodyColor(Body.saturn), "Q193"), + (Body.uranus, AstroUtils.bodyColor(Body.uranus), "Q324"), + (Body.neptune, AstroUtils.bodyColor(Body.neptune), "Q332"), + (Body.pluto, AstroUtils.bodyColor(Body.pluto), "Q339") + ] + + for (body, color, wid) in planets { + objects.append(SkyObject(id: body.name.lowercased(), + hip: -1, + wid: wid, + type: body === Body.sun ? .SUN : (body === Body.moon ? .MOON : .PLANET), + body: body, + name: AstroUtils.bodyName(body), + ra: 0, + dec: 0, + magnitude: -2, + color: color)) + } + } + + func getTypeColor(_ type: SkyObjectType) -> UIColor { + switch type { + case .STAR: + return .white + case .GALAXY, .GALAXY_CLUSTER: + return .lightGray + case .BLACK_HOLE: + return .magenta + case .NEBULA: + return UIColor(red: 0.88, green: 0.81, blue: 0.96, alpha: 1.0) + case .OPEN_CLUSTER: + return UIColor(red: 1.0, green: 1.0, blue: 0.88, alpha: 1.0) + case .GLOBULAR_CLUSTER: + return UIColor(red: 1.0, green: 0.98, blue: 0.80, alpha: 1.0) + default: + return .white + } + } + + func parseLines(_ json: String?) -> [(Int, Int)] { + guard let json, !json.isEmpty else { + return [] + } + guard let data = json.data(using: .utf8) else { + NSLog("Error parsing constellation lines: %@", json) + return [] + } + + guard let array = try? JSONSerialization.jsonObject(with: data) as? [[Int]] else { + NSLog("Error parsing constellation lines: %@", json) + return [] + } + return array.compactMap { segment in + guard segment.count >= 2 else { + return nil + } + return (segment[0], segment[1]) + } + } + + func generateId(type: SkyObjectType, name: String) -> String { + switch type { + case .STAR, .GALAXY: + return name.lowercased() + .replacingOccurrences(of: " ", with: "_") + .filter { type == .STAR || $0.isLetter || $0.isNumber || $0 == "_" } + case .BLACK_HOLE: + return "bh_" + name.lowercased() + .replacingOccurrences(of: "*", with: "_") + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "-", with: "_") + .replacingOccurrences(of: "(", with: "_") + .replacingOccurrences(of: ")", with: "_") + default: + return name.lowercased().replacingOccurrences(of: " ", with: "_") + } + } +} diff --git a/Sources/Plugins/Astronomy/AstroUtils.swift b/Sources/Plugins/Astronomy/AstroUtils.swift new file mode 100644 index 0000000000..6248e3dec7 --- /dev/null +++ b/Sources/Plugins/Astronomy/AstroUtils.swift @@ -0,0 +1,481 @@ +// +// AstroUtils.swift +// OsmAnd Maps +// +// Created by Codex on 19.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import CoreLocation +import Foundation +import OsmAndShared +import QuartzCore +import UIKit + +enum AstroIcon { + static func template(_ name: String) -> UIImage? { + UIImage.templateImageNamed(name) + } + + static func original(_ name: String) -> UIImage? { + UIImage(named: name)?.withRenderingMode(.alwaysOriginal) + } + + static func template(_ name: String, size: CGSize) -> UIImage? { + guard let image = template(name) else { + return nil + } + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + return UIGraphicsImageRenderer(size: size, format: format).image { _ in + image.withRenderingMode(.alwaysTemplate).draw(in: CGRect(origin: .zero, size: size)) + }.withRenderingMode(.alwaysTemplate) + } + + static func layeredTemplate(baseName: String, + baseColor: UIColor, + overlayName: String, + overlayColor: UIColor, + size: CGSize = CGSize(width: 24, height: 24)) -> UIImage? { + guard let base = template(baseName), + let overlay = template(overlayName) else { + return template(baseName) + } + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + return UIGraphicsImageRenderer(size: size, format: format).image { _ in + base.withTintColor(baseColor, renderingMode: .alwaysOriginal).draw(in: CGRect(origin: .zero, size: size)) + overlay.withTintColor(overlayColor, renderingMode: .alwaysOriginal).draw(in: CGRect(origin: .zero, size: size)) + }.withRenderingMode(.alwaysTemplate) + } +} + +private final class AstroRedFilterOverlayView: UIView { + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + isUserInteractionEnabled = false + backgroundColor = .red + layer.compositingFilter = "multiplyBlendMode" + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = superview?.layer.cornerRadius ?? 0 + layer.masksToBounds = layer.cornerRadius > 0 + } +} + +enum AstroRedFilter { + private static let overlayTag = 0xA570 + + static func apply(_ enabled: Bool, to views: UIView?...) { + CATransaction.begin() + CATransaction.setDisableActions(true) + for view in views { + apply(enabled, to: view) + } + CATransaction.commit() + } + + private static func apply(_ enabled: Bool, to view: UIView?) { + guard let view else { + return + } + if enabled { + let overlay: AstroRedFilterOverlayView + if let existing = view.viewWithTag(overlayTag) as? AstroRedFilterOverlayView { + overlay = existing + } else { + overlay = AstroRedFilterOverlayView(frame: view.bounds) + overlay.tag = overlayTag + overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(overlay) + } + overlay.frame = view.bounds + overlay.setNeedsLayout() + overlay.layoutIfNeeded() + view.bringSubviewToFront(overlay) + } else { + view.viewWithTag(overlayTag)?.removeFromSuperview() + } + } +} + +enum AstroUtils { + private static let customStarLock = NSLock() + + static let solarSystemWikidataIds: [String: Body] = [ + "Q525": Body.sun, + "Q405": Body.moon, + "Q308": Body.mercury, + "Q313": Body.venus, + "Q111": Body.mars, + "Q319": Body.jupiter, + "Q193": Body.saturn, + "Q324": Body.uranus, + "Q332": Body.neptune, + "Q339": Body.pluto + ] + + struct Twilight { + let sunrise: Date? + let sunset: Date? + let civilDawn: Date? + let civilDusk: Date? + let nauticalDawn: Date? + let nauticalDusk: Date? + let astroDawn: Date? + let astroDusk: Date? + } + + static func astronomyTime(from date: Date) -> Time { + Time.companion.fromMillisecondsSince1970(millis: Int64(date.timeIntervalSince1970 * 1000.0)) + } + + static func observer(from location: CLLocation?) -> Observer { + let coordinate = location?.coordinate ?? CLLocationCoordinate2D(latitude: 0, longitude: 0) + let altitude = location?.altitude.isFinite == true ? location?.altitude ?? 0 : 0 + return Observer(latitude: coordinate.latitude, longitude: coordinate.longitude, height: altitude) + } + + static func horizontalPosition(for object: SkyObject, time: Time, observer: Observer) -> Topocentric? { + if let body = object.body { + let equatorial = AstronomyKt.equator( + body: body, + time: time, + observer: observer, + equdate: EquatorEpoch.ofdate, + aberration: Aberration.corrected + ) + object.ra = equatorial.ra + object.dec = equatorial.dec + object.distAu = equatorial.dist + return AstronomyKt.horizon(time: time, + observer: observer, + ra: equatorial.ra, + dec: equatorial.dec, + refraction: Refraction.normal) + } + + return AstronomyKt.horizon(time: time, + observer: observer, + ra: object.ra, + dec: object.dec, + refraction: Refraction.normal) + } + + static func withCustomStar(ra: Double, dec: Double, block: (Body) -> T) -> T { + customStarLock.lock() + defer { customStarLock.unlock() } + AstronomyKt.defineStar(body: Body.star1, ra: ra, dec: dec, distanceLightYears: 1000.0) + return block(Body.star1) + } + + static func altitude(_ body: Body, at date: Date, observer: Observer) -> Double { + let time = astronomyTime(from: date) + let equatorial = AstronomyKt.equator(body: body, + time: time, + observer: observer, + equdate: EquatorEpoch.ofdate, + aberration: Aberration.corrected) + let horizontal = AstronomyKt.horizon(time: time, + observer: observer, + ra: equatorial.ra, + dec: equatorial.dec, + refraction: Refraction.normal) + return horizontal.altitude + } + + static func altitude(_ object: SkyObject, at date: Date, observer: Observer) -> Double { + if let body = object.body { + return altitude(body, at: date, observer: observer) + } + return withCustomStar(ra: object.ra, dec: object.dec) { body in + altitude(body, at: date, observer: observer) + } + } + + static func nextRiseSet(body: Body, + startSearch: Date, + observer: Observer, + windowStart: Date? = nil, + windowEnd: Date? = nil, + limitDays: Double = 2.0) -> (rise: Date?, set: Date?) { + let searchStart = astronomyTime(from: startSearch) + let nextRise = AstronomyKt.searchRiseSet(body: body, + observer: observer, + direction: Direction.rise, + startTime: searchStart, + limitDays: limitDays, + metersAboveGround: 0.0) + let nextSet = AstronomyKt.searchRiseSet(body: body, + observer: observer, + direction: Direction.set, + startTime: searchStart, + limitDays: limitDays, + metersAboveGround: 0.0) + return (filterRiseSetDate(date(from: nextRise), windowStart: windowStart, windowEnd: windowEnd), + filterRiseSetDate(date(from: nextSet), windowStart: windowStart, windowEnd: windowEnd)) + } + + static func nextRiseSet(object: SkyObject, + startSearch: Date, + observer: Observer, + windowStart: Date? = nil, + windowEnd: Date? = nil, + limitDays: Double = 2.0) -> (rise: Date?, set: Date?) { + if let body = object.body { + return nextRiseSet(body: body, + startSearch: startSearch, + observer: observer, + windowStart: windowStart, + windowEnd: windowEnd, + limitDays: limitDays) + } + return withCustomStar(ra: object.ra, dec: object.dec) { body in + nextRiseSet(body: body, + startSearch: startSearch, + observer: observer, + windowStart: windowStart, + windowEnd: windowEnd, + limitDays: limitDays) + } + } + + static func date(from time: Time?) -> Date? { + guard let time else { + return nil + } + return Date(timeIntervalSince1970: TimeInterval(time.toMillisecondsSince1970()) / 1000.0) + } + + static func computeTwilight(startLocal: Date, + endLocal: Date, + observer: Observer, + timeZone: TimeZone) -> Twilight { + func findAlt(direction: Direction, degrees: Double) -> Date? { + let startTime = astronomyTime(from: startLocal) + let time = AstronomyKt.searchAltitude(body: Body.sun, + observer: observer, + direction: direction, + startTime: startTime, + limitDays: 2.0, + altitude: degrees) + return date(from: time) + } + let searchStart = astronomyTime(from: startLocal) + let sunrise = AstronomyKt.searchRiseSet(body: Body.sun, + observer: observer, + direction: Direction.rise, + startTime: searchStart, + limitDays: 2.0, + metersAboveGround: 0.0) + let sunset = AstronomyKt.searchRiseSet(body: Body.sun, + observer: observer, + direction: Direction.set, + startTime: searchStart, + limitDays: 2.0, + metersAboveGround: 0.0) + return Twilight(sunrise: date(from: sunrise), + sunset: date(from: sunset), + civilDawn: findAlt(direction: .rise, degrees: -6.0), + civilDusk: findAlt(direction: .set, degrees: -6.0), + nauticalDawn: findAlt(direction: .rise, degrees: -12.0), + nauticalDusk: findAlt(direction: .set, degrees: -12.0), + astroDawn: findAlt(direction: .rise, degrees: -18.0), + astroDusk: findAlt(direction: .set, degrees: -18.0)) + } + + static func formatLocalTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .none + formatter.timeZone = .current + return formatter.string(from: date) + } + + private static func filterRiseSetDate(_ date: Date?, windowStart: Date?, windowEnd: Date?) -> Date? { + guard let date else { + return nil + } + if let windowStart, date < windowStart { + return nil + } + if let windowEnd, date > windowEnd { + return nil + } + return date + } + + static func bodyName(_ body: Body) -> String { + bodyDisplayName(body) + } + + static func bodyDisplayName(_ body: Body) -> String { + if body === Body.sun { + return localizedString("astro_name_sun") + } else if body === Body.moon { + return localizedString("astro_name_moon") + } else if body === Body.mercury { + return localizedString("astro_name_mercury") + } else if body === Body.venus { + return localizedString("astro_name_venus") + } else if body === Body.mars { + return localizedString("astro_name_mars") + } else if body === Body.jupiter { + return localizedString("astro_name_jupiter") + } else if body === Body.saturn { + return localizedString("astro_name_saturn") + } else if body === Body.uranus { + return localizedString("astro_name_uranus") + } else if body === Body.neptune { + return localizedString("astro_name_neptune") + } else if body === Body.pluto { + return localizedString("astro_name_pluto") + } else { + return body.name + } + } + + static func bodyColor(_ body: Body) -> UIColor { + color(for: body) + } + + static func color(for body: Body) -> UIColor { + if body === Body.sun { + return UIColor(red: 1.0, green: 0.69, blue: 0.20, alpha: 1.0) + } else if body === Body.moon { + return UIColor(white: 0.88, alpha: 1.0) + } else if body === Body.mars { + return UIColor(red: 0.95, green: 0.36, blue: 0.22, alpha: 1.0) + } else if body === Body.jupiter { + return UIColor(red: 0.95, green: 0.73, blue: 0.48, alpha: 1.0) + } else if body === Body.saturn { + return UIColor(red: 0.95, green: 0.82, blue: 0.52, alpha: 1.0) + } else if body === Body.neptune || body === Body.uranus { + return UIColor(red: 0.42, green: 0.73, blue: 1.0, alpha: 1.0) + } else { + return UIColor(red: 0.87, green: 0.90, blue: 1.0, alpha: 1.0) + } + } + + static func color(for type: SkyObjectType, magnitude: Double?) -> UIColor { + switch type { + case .STAR: + let brightness = max(0.45, min(1.0, 1.0 - ((magnitude ?? 2.0) / 8.0))) + return UIColor(red: brightness, green: brightness, blue: 1.0, alpha: 1.0) + case .GALAXY, .GALAXY_CLUSTER: + return UIColor(red: 0.52, green: 0.74, blue: 1.0, alpha: 1.0) + case .NEBULA: + return UIColor(red: 0.85, green: 0.45, blue: 0.95, alpha: 1.0) + case .OPEN_CLUSTER, .GLOBULAR_CLUSTER: + return UIColor(red: 0.50, green: 0.95, blue: 0.78, alpha: 1.0) + case .BLACK_HOLE: + return UIColor(red: 0.95, green: 0.45, blue: 0.35, alpha: 1.0) + case .CONSTELLATION: + return UIColor(red: 0.80, green: 0.86, blue: 1.0, alpha: 1.0) + case .SUN, .MOON, .PLANET: + return UIColor.white + } + } + + static func getObjectTypeIcon(_ type: SkyObjectType) -> String { + switch type { + case .SUN: + return "ic_custom_sun" + case .MOON: + return "ic_custom_moon" + case .PLANET: + return "ic_action_ufo" + case .STAR: + return "ic_custom_favorites" + case .GALAXY, .GALAXY_CLUSTER: + return "ic_world_globe_dark" + case .NEBULA: + return "ic_custom_clouds" + case .BLACK_HOLE: + return "ic_action_circle" + case .CONSTELLATION: + return "ic_custom_celestial_path" + case .OPEN_CLUSTER, .GLOBULAR_CLUSTER: + return "ic_custom_favorites" + } + } + + static func getObjectTypeName(_ type: SkyObjectType) -> String { + localizedString(type.titleKey) + } + + static func calculateConstellationCenter(_ constellation: Constellation, skyObjectMap: [Int: SkyObject]) -> (Double, Double)? { + var sumX = 0.0 + var sumY = 0.0 + var sumZ = 0.0 + var count = 0 + var uniqueStars = Set() + for (first, second) in constellation.lines { + uniqueStars.insert(first) + uniqueStars.insert(second) + } + + for id in uniqueStars { + guard let star = skyObjectMap[id] else { + continue + } + let raRad = star.ra * 15.0 * .pi / 180.0 + let decRad = star.dec * .pi / 180.0 + sumX += cos(decRad) * cos(raRad) + sumY += cos(decRad) * sin(raRad) + sumZ += sin(decRad) + count += 1 + } + + guard count > 0 else { + return nil + } + + let avgX = sumX / Double(count) + let avgY = sumY / Double(count) + let avgZ = sumZ / Double(count) + let hyp = sqrt(avgX * avgX + avgY * avgY) + let decRad = atan2(avgZ, hyp) + var raRad = atan2(avgY, avgX) + if raRad < 0 { + raRad += 2 * .pi + } + return (raRad * 180.0 / .pi / 15.0, decRad * 180.0 / .pi) + } + + static func normalizedDegrees(_ degrees: Double) -> Double { + var value = degrees.truncatingRemainder(dividingBy: 360.0) + if value < 0 { + value += 360.0 + } + return value + } + + static func shortestAngleDelta(from source: Double, to target: Double) -> Double { + var delta = normalizedDegrees(target) - normalizedDegrees(source) + if delta > 180 { + delta -= 360 + } else if delta < -180 { + delta += 360 + } + return delta + } + + static func formattedTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } +} diff --git a/Sources/Plugins/Astronomy/AstronomyPlugin.swift b/Sources/Plugins/Astronomy/AstronomyPlugin.swift new file mode 100644 index 0000000000..479a53435b --- /dev/null +++ b/Sources/Plugins/Astronomy/AstronomyPlugin.swift @@ -0,0 +1,50 @@ +// +// AstronomyPlugin.swift +// OsmAnd Maps +// +// Created by Codex on 19.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +@objc(AstronomyPlugin) +final class AstronomyPlugin: OAPlugin { + let dataProvider: AstroDataDbProvider + var recentSearchChips: [StarMapRecentChip] = [] + + override init() { + dataProvider = AstroDataDbProvider() + super.init() + } + + override func getId() -> String? { + kInAppId_Addon_Astronomy + } + + override func isEnabled() -> Bool { + super.isEnabled() && (OAIAPHelper.isOsmAndProAvailable() || OAIAPHelper.isMapsPlusAvailable()) + } + + override func getName() -> String { + String(format: localizedString("ltr_or_rtl_combine_with_brackets"), localizedString("astronomy_plugin_name"), localizedString("shared_string_beta")) + } + + override func getDescription() -> String { + localizedString("purchases_feature_desc_astronomy") + } + + override func getLogoResourceId() -> String? { + "ic_custom_telescope" + } + + @objc func showStarMap() { + let controller = StarMapViewController(plugin: self) + controller.modalPresentationStyle = .fullScreen + OARootViewController.instance().present(controller, animated: true) + } + + @objc func clearCachedData() { + dataProvider.clearCache() + } +} diff --git a/Sources/Plugins/Astronomy/AstronomyPluginSettings.swift b/Sources/Plugins/Astronomy/AstronomyPluginSettings.swift new file mode 100644 index 0000000000..2b1caf2e3d --- /dev/null +++ b/Sources/Plugins/Astronomy/AstronomyPluginSettings.swift @@ -0,0 +1,416 @@ +// +// AstronomyPluginSettings.swift +// OsmAnd Maps +// +// Created by Codex on 19.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import UIKit + +struct AstronomyPluginSettings { + enum DirectionColor: Int, CaseIterable { + case BLUE + case GREEN + case ORANGE + case RED + case YELLOW + case TEAL + case PURPLE + + var color: UIColor { + switch self { + case .BLUE: + return .systemBlue + case .GREEN: + return .systemGreen + case .ORANGE: + return .systemOrange + case .RED: + return .systemRed + case .YELLOW: + return .systemYellow + case .TEAL: + return .systemTeal + case .PURPLE: + return .systemPurple + } + } + } + + struct FavoriteConfig: Equatable { + var id: String + } + + struct DirectionConfig: Equatable { + var id: String + var colorIndex: Int = 0 + } + + struct CelestialPathConfig: Equatable { + var id: String + } + + struct CommonConfig: Equatable { + var showRegularMap = false + } + + struct StarMapConfig: Equatable { + var showAzimuthalGrid = true + var showEquatorialGrid = false + var showEclipticLine = false + var showMeridianLine = false + var showEquatorLine = false + var showGalacticLine = false + var showFavorites = true + var showDirections = true + var showCelestialPaths = true + var showRedFilter = false + var showSun = true + var showMoon = true + var showPlanets = true + var showConstellations = false + var showStars = false + var showGalaxies = false + var showNebulae = false + var showOpenClusters = false + var showGlobularClusters = false + var showGalaxyClusters = false + var showBlackHoles = false + var is2DMode = false + var showMagnitudeFilter = false + var magnitudeFilter: Double? + var favorites: [FavoriteConfig] = [] + var directions: [DirectionConfig] = [] + var celestialPaths: [CelestialPathConfig] = [] + } + + static let storageKey = "astronomy_settings" + private static let keyCommon = "common" + private static let keyShowRegularMap = "showRegularMap" + private static let keyStarMap = "star_map" + private static let keyShowAzimuthal = "showAzimuthalGrid" + private static let keyShowEquatorial = "showEquatorialGrid" + private static let keyShowEcliptic = "showEclipticLine" + private static let keyShowMeridian = "showMeridianLine" + private static let keyShowEquator = "showEquatorLine" + private static let keyShowGalactic = "showGalacticLine" + private static let keyShowSun = "showSun" + private static let keyShowMoon = "showMoon" + private static let keyShowPlanets = "showPlanets" + private static let keyShowFavorites = "showFavorites" + private static let keyShowDirections = "showDirections" + private static let keyShowCelestialPaths = "showCelestialPaths" + private static let keyShowRedFilter = "showRedFilter" + private static let keyShowConstellations = "showConstellations" + private static let keyShowStars = "showStars" + private static let keyShowGalaxies = "showGalaxies" + private static let keyShowNebulae = "showNebulae" + private static let keyShowOpenClusters = "showOpenClusters" + private static let keyShowGlobularClusters = "showGlobularClusters" + private static let keyShowGalaxyClusters = "showGalaxyClusters" + private static let keyShowBlackHoles = "showBlackHoles" + private static let keyIs2DMode = "is2DMode" + private static let keyShowMagnitudeFilter = "showMagnitudeFilter" + private static let keyMagnitudeFilter = "magnitudeFilter" + private static let keyFavorites = "favorites" + private static let keyDirections = "directions" + private static let keyCelestialPaths = "celestialPaths" + private static let keyId = "id" + private static let keyColorIndex = "colorIndex" + private static let storageQueue = DispatchQueue(label: "net.osmand.astronomy.settings") + + var common = CommonConfig() + var starMap = StarMapConfig() + + static func load() -> AstronomyPluginSettings { + storageQueue.sync { + loadUnlocked() + } + } + + func save() { + Self.storageQueue.sync { + Self.saveUnlocked(self) + } + } + + private static func loadUnlocked() -> AstronomyPluginSettings { + guard let root = settingsJsonUnlocked() else { + return AstronomyPluginSettings() + } + var settings = AstronomyPluginSettings() + settings.common = parseCommonConfig(root[Self.keyCommon] as? [String: Any]) + settings.starMap = parseStarMapConfig(root[Self.keyStarMap] as? [String: Any]) + return settings + } + + private static func saveUnlocked(_ settings: AstronomyPluginSettings) { + var root: [String: Any] = [:] + root[Self.keyCommon] = [ + Self.keyShowRegularMap: settings.common.showRegularMap + ] + root[Self.keyStarMap] = Self.serializeStarMapConfig(settings.starMap) + guard let data = try? JSONSerialization.data(withJSONObject: root), + let string = String(data: data, encoding: .utf8) else { + return + } + UserDefaults.standard.set(string, forKey: Self.storageKey) + } + + func getCommonConfig() -> CommonConfig { + common + } + + mutating func setCommonConfig(_ config: CommonConfig) { + self = Self.storageQueue.sync { + var settings = Self.loadUnlocked() + settings.common = config + Self.saveUnlocked(settings) + return settings + } + } + + func getStarMapConfig() -> StarMapConfig { + starMap + } + + mutating func setStarMapConfig(_ config: StarMapConfig) { + self = Self.storageQueue.sync { + var settings = Self.loadUnlocked() + settings.starMap = config + Self.saveUnlocked(settings) + return settings + } + } + + @discardableResult + mutating func updateStarMapConfig(_ transform: (StarMapConfig) -> StarMapConfig) -> StarMapConfig { + let result = Self.storageQueue.sync { + var settings = Self.loadUnlocked() + let updated = transform(settings.starMap) + if updated != settings.starMap { + settings.starMap = updated + Self.saveUnlocked(settings) + } + return (settings, updated) + } + self = result.0 + return result.1 + } + + mutating func addFavorite(id: String) { + updateStarMapConfig { config in + guard !config.favorites.contains(where: { $0.id == id }) else { + return config + } + var updated = config + updated.favorites.append(FavoriteConfig(id: id)) + return updated + } + } + + mutating func removeFavorite(id: String) { + updateStarMapConfig { config in + var updated = config + updated.favorites.removeAll { $0.id == id } + return updated + } + } + + mutating func addDirection(id: String) -> Int { + var resultColor = 0 + updateStarMapConfig { config in + if let direction = config.directions.first(where: { $0.id == id }) { + resultColor = direction.colorIndex + return config + } + let maxColor = config.directions.map(\.colorIndex).max() ?? -1 + let nextColor = (maxColor + 1) % DirectionColor.allCases.count + resultColor = nextColor + var updated = config + updated.directions.append(DirectionConfig(id: id, colorIndex: nextColor)) + return updated + } + return resultColor + } + + mutating func removeDirection(id: String) { + updateStarMapConfig { config in + var updated = config + updated.directions.removeAll { $0.id == id } + return updated + } + } + + mutating func addCelestialPath(id: String) { + updateStarMapConfig { config in + guard !config.celestialPaths.contains(where: { $0.id == id }) else { + return config + } + var updated = config + updated.celestialPaths.append(CelestialPathConfig(id: id)) + return updated + } + } + + mutating func removeCelestialPath(id: String) { + updateStarMapConfig { config in + var updated = config + updated.celestialPaths.removeAll { $0.id == id } + return updated + } + } + + private static func settingsJsonUnlocked() -> [String: Any]? { + guard let json = UserDefaults.standard.string(forKey: storageKey), + !json.isEmpty, + let data = json.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return root + } + + private static func parseCommonConfig(_ json: [String: Any]?) -> CommonConfig { + CommonConfig(showRegularMap: bool(json?[keyShowRegularMap], fallback: false)) + } + + private static func parseStarMapConfig(_ json: [String: Any]?) -> StarMapConfig { + var nextColor = 0 + return StarMapConfig(showAzimuthalGrid: bool(json?[keyShowAzimuthal], fallback: true), + showEquatorialGrid: bool(json?[keyShowEquatorial], fallback: false), + showEclipticLine: bool(json?[keyShowEcliptic], fallback: false), + showMeridianLine: bool(json?[keyShowMeridian], fallback: false), + showEquatorLine: bool(json?[keyShowEquator], fallback: false), + showGalacticLine: bool(json?[keyShowGalactic], fallback: false), + showFavorites: bool(json?[keyShowFavorites], fallback: true), + showDirections: bool(json?[keyShowDirections], fallback: true), + showCelestialPaths: bool(json?[keyShowCelestialPaths], fallback: true), + showRedFilter: bool(json?[keyShowRedFilter], fallback: false), + showSun: bool(json?[keyShowSun], fallback: true), + showMoon: bool(json?[keyShowMoon], fallback: true), + showPlanets: bool(json?[keyShowPlanets], fallback: true), + showConstellations: bool(json?[keyShowConstellations], fallback: false), + showStars: bool(json?[keyShowStars], fallback: false), + showGalaxies: bool(json?[keyShowGalaxies], fallback: false), + showNebulae: bool(json?[keyShowNebulae], fallback: false), + showOpenClusters: bool(json?[keyShowOpenClusters], fallback: false), + showGlobularClusters: bool(json?[keyShowGlobularClusters], fallback: false), + showGalaxyClusters: bool(json?[keyShowGalaxyClusters], fallback: false), + showBlackHoles: bool(json?[keyShowBlackHoles], fallback: false), + is2DMode: bool(json?[keyIs2DMode], fallback: false), + showMagnitudeFilter: bool(json?[keyShowMagnitudeFilter], fallback: false), + magnitudeFilter: double(json?[keyMagnitudeFilter]), + favorites: parseItems(json?[keyFavorites]) { FavoriteConfig(id: $0) }, + directions: parseItems(json?[keyDirections]) { item, id in + defer { nextColor += 1 } + return DirectionConfig(id: id, + colorIndex: int(item[keyColorIndex], fallback: nextColor % DirectionColor.allCases.count)) + }, + celestialPaths: parseItems(json?[keyCelestialPaths]) { CelestialPathConfig(id: $0) }) + } + + private static func serializeStarMapConfig(_ config: StarMapConfig) -> [String: Any] { + var json: [String: Any] = [ + keyShowAzimuthal: config.showAzimuthalGrid, + keyShowEquatorial: config.showEquatorialGrid, + keyShowEcliptic: config.showEclipticLine, + keyShowMeridian: config.showMeridianLine, + keyShowEquator: config.showEquatorLine, + keyShowGalactic: config.showGalacticLine, + keyShowFavorites: config.showFavorites, + keyShowDirections: config.showDirections, + keyShowCelestialPaths: config.showCelestialPaths, + keyShowRedFilter: config.showRedFilter, + keyShowSun: config.showSun, + keyShowMoon: config.showMoon, + keyShowPlanets: config.showPlanets, + keyShowConstellations: config.showConstellations, + keyShowStars: config.showStars, + keyShowGalaxies: config.showGalaxies, + keyShowNebulae: config.showNebulae, + keyShowOpenClusters: config.showOpenClusters, + keyShowGlobularClusters: config.showGlobularClusters, + keyShowGalaxyClusters: config.showGalaxyClusters, + keyShowBlackHoles: config.showBlackHoles, + keyIs2DMode: config.is2DMode, + keyShowMagnitudeFilter: config.showMagnitudeFilter + ] + if let magnitudeFilter = config.magnitudeFilter { + json[keyMagnitudeFilter] = magnitudeFilter + } + if !config.favorites.isEmpty { + json[keyFavorites] = config.favorites.map { [keyId: $0.id] } + } + if !config.directions.isEmpty { + json[keyDirections] = config.directions.map { [keyId: $0.id, keyColorIndex: $0.colorIndex] } + } + if !config.celestialPaths.isEmpty { + json[keyCelestialPaths] = config.celestialPaths.map { [keyId: $0.id] } + } + return json + } + + private static func parseItems(_ value: Any?, factory: (String) -> T) -> [T] { + guard let array = value as? [[String: Any]] else { + return [] + } + return array.compactMap { item in + guard let id = item[keyId] as? String, !id.isEmpty else { + return nil + } + return factory(id) + } + } + + private static func parseItems(_ value: Any?, factory: ([String: Any], String) -> T) -> [T] { + guard let array = value as? [[String: Any]] else { + return [] + } + return array.compactMap { item in + guard let id = item[keyId] as? String, !id.isEmpty else { + return nil + } + return factory(item, id) + } + } + + private static func bool(_ value: Any?, fallback defaultValue: Bool) -> Bool { + if let value = value as? Bool { + return value + } + if let value = value as? NSNumber { + return value.boolValue + } + return defaultValue + } + + private static func int(_ value: Any?, fallback defaultValue: Int) -> Int { + if let value = value as? Int { + return value + } + if let value = value as? NSNumber { + return value.intValue + } + if let value = value as? String { + return Int(value) ?? defaultValue + } + return defaultValue + } + + private static func double(_ value: Any?) -> Double? { + if let value = value as? Double { + return value.isNaN ? nil : value + } + if let value = value as? NSNumber { + let double = value.doubleValue + return double.isNaN ? nil : double + } + if let value = value as? String, let double = Double(value) { + return double.isNaN ? nil : double + } + return nil + } +} diff --git a/Sources/Plugins/Astronomy/Catalog.swift b/Sources/Plugins/Astronomy/Catalog.swift new file mode 100644 index 0000000000..64deaf4c3a --- /dev/null +++ b/Sources/Plugins/Astronomy/Catalog.swift @@ -0,0 +1,37 @@ +// +// Catalog.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation + +final class Catalog: NSObject { + let wid: String + let name: String + let catalogId: String + + override var hash: Int { + var hasher = Hasher() + hasher.combine(wid) + hasher.combine(name) + hasher.combine(catalogId) + return hasher.finalize() + } + + init(wid: String, name: String, catalogId: String) { + self.wid = wid + self.name = name + self.catalogId = catalogId + super.init() + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Catalog else { + return false + } + return wid == other.wid && name == other.name && catalogId == other.catalogId + } +} diff --git a/Sources/Plugins/Astronomy/Constellation.swift b/Sources/Plugins/Astronomy/Constellation.swift new file mode 100644 index 0000000000..2338432b6f --- /dev/null +++ b/Sources/Plugins/Astronomy/Constellation.swift @@ -0,0 +1,33 @@ +// +// Constellation.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import UIKit + +final class Constellation: SkyObject { + let lines: [(Int, Int)] + + init(name: String, + wid: String, + lines: [(Int, Int)], + localizedName: String? = nil) { + self.lines = lines + super.init(id: "const_\(name.lowercased().replacingOccurrences(of: " ", with: "_"))", + hip: -1, + catalogs: [], + wid: wid, + type: .CONSTELLATION, + body: nil, + name: name, + ra: 0, + dec: 0, + magnitude: 2, + color: .white, + localizedName: localizedName) + } +} diff --git a/Sources/Plugins/Astronomy/ConstellationInfoBottomSheet.swift b/Sources/Plugins/Astronomy/ConstellationInfoBottomSheet.swift new file mode 100644 index 0000000000..0f4c1bc3c2 --- /dev/null +++ b/Sources/Plugins/Astronomy/ConstellationInfoBottomSheet.swift @@ -0,0 +1,55 @@ +// +// ConstellationInfoBottomSheet.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class ConstellationInfoFragment: UIViewController { + private let constellation: Constellation + var onClose: (() -> Void)? + + init(constellation: Constellation) { + self.constellation = constellation + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(white: 0.03, alpha: 0.96) + let closeButton = UIButton(type: .system) + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeButton.setImage(.icCustomClose, for: .normal) + closeButton.tintColor = UIColor(white: 0.8, alpha: 1) + closeButton.backgroundColor = UIColor(white: 0.12, alpha: 1) + closeButton.layer.cornerRadius = 16 + closeButton.addAction(UIAction { [weak self] _ in + self?.onClose?() + }, for: .touchUpInside) + + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .white + label.numberOfLines = 0 + label.text = "\(constellation.niceName())\n\(localizedString("astro_constellation"))\n\(localizedString("count_of_lines")): \(constellation.lines.count)" + view.addSubview(closeButton) + view.addSubview(label) + NSLayoutConstraint.activate([ + closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + closeButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 12), + closeButton.widthAnchor.constraint(equalToConstant: 32), + closeButton.heightAnchor.constraint(equalToConstant: 32), + + label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + label.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -12), + label.topAnchor.constraint(equalTo: view.topAnchor, constant: 20) + ]) + } +} diff --git a/Sources/Plugins/Astronomy/DateTimeSelectionView.swift b/Sources/Plugins/Astronomy/DateTimeSelectionView.swift new file mode 100644 index 0000000000..8ed094ebeb --- /dev/null +++ b/Sources/Plugins/Astronomy/DateTimeSelectionView.swift @@ -0,0 +1,141 @@ +// +// DateTimeSelectionView.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class DateTimeSelectionView: UIView { + private enum Field: Hashable { + case year + case month + case day + case hour + case minute + } + + private var currentDate = Date() + private var onDateTimeChangeListener: ((Date) -> Void)? + private let calendar = Calendar.current + private var labels: [Field: UILabel] = [:] + + override init(frame: CGRect) { + super.init(frame: frame) + initViews() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + initViews() + } + + private func initViews() { + backgroundColor = UIColor.black.withAlphaComponent(0.67) + layer.cornerRadius = 0 + layer.shadowOpacity = 0 + + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 4 + stack.layoutMargins = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) + stack.isLayoutMarginsRelativeArrangement = true + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + + addColumn(.year, to: stack) + addColumn(.month, to: stack) + addColumn(.day, to: stack) + stack.setCustomSpacing(8, after: stack.arrangedSubviews[2]) + addColumn(.hour, to: stack) + addColumn(.minute, to: stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + updateDisplay() + } + + private func addColumn(_ field: Field, to parent: UIStackView) { + let column = UIStackView() + column.axis = .vertical + column.alignment = .center + column.spacing = 2 + + let up = makeStepButton(iconName: "ic_custom_arrow_up") + up.addAction(UIAction { [weak self] _ in self?.step(field, amount: field == .minute ? 5 : 1) }, for: .touchUpInside) + column.addArrangedSubview(up) + + let label = UILabel() + label.textColor = .white + label.font = UIFont.monospacedDigitSystemFont(ofSize: 18, weight: .bold) + label.textAlignment = .center + label.widthAnchor.constraint(greaterThanOrEqualToConstant: field == .year ? 52 : 32).isActive = true + labels[field] = label + column.addArrangedSubview(label) + + let down = makeStepButton(iconName: "ic_custom_arrow_down") + down.addAction(UIAction { [weak self] _ in self?.step(field, amount: field == .minute ? -5 : -1) }, for: .touchUpInside) + column.addArrangedSubview(down) + + parent.addArrangedSubview(column) + } + + private func makeStepButton(iconName: String) -> UIButton { + let button = UIButton(type: .system) + button.tintColor = .white + button.setImage(AstroIcon.template(iconName), for: .normal) + button.widthAnchor.constraint(equalToConstant: 40).isActive = true + button.heightAnchor.constraint(equalToConstant: 40).isActive = true + return button + } + + private func step(_ field: Field, amount: Int) { + let component: Calendar.Component + switch field { + case .year: + component = .year + case .month: + component = .month + case .day: + component = .day + case .hour: + component = .hour + case .minute: + component = .minute + } + if let date = calendar.date(byAdding: component, value: amount, to: currentDate) { + currentDate = date + updateDisplay() + onDateTimeChangeListener?(currentDate) + } + } + + private func updateDisplay() { + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: currentDate) + labels[.year]?.text = String(format: "%04d", components.year ?? 0) + labels[.month]?.text = String(format: "%02d", components.month ?? 0) + labels[.day]?.text = String(format: "%02d", components.day ?? 0) + labels[.hour]?.text = String(format: "%02d", components.hour ?? 0) + labels[.minute]?.text = String(format: "%02d", components.minute ?? 0) + } + + func setOnDateTimeChangeListener(_ listener: @escaping (Date) -> Void) { + onDateTimeChangeListener = listener + } + + func setDateTime(_ date: Date) { + currentDate = date + updateDisplay() + } + + func getDateTime() -> Date { + currentDate + } +} diff --git a/Sources/Plugins/Astronomy/SkyObject.swift b/Sources/Plugins/Astronomy/SkyObject.swift new file mode 100644 index 0000000000..e0af92b67f --- /dev/null +++ b/Sources/Plugins/Astronomy/SkyObject.swift @@ -0,0 +1,295 @@ +// +// SkyObject.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared +import UIKit + +enum SkyObjectType: String, Codable, CaseIterable { + case STAR + case GALAXY + case BLACK_HOLE + case PLANET + case SUN + case MOON + case NEBULA + case OPEN_CLUSTER + case GLOBULAR_CLUSTER + case GALAXY_CLUSTER + case CONSTELLATION + + var titleKey: String { + switch self { + case .STAR: + return "astro_type_star" + case .GALAXY: + return "astro_type_galaxy" + case .BLACK_HOLE: + return "astro_type_black_hole" + case .PLANET: + return "astro_type_planet" + case .SUN: + return "astro_type_star" + case .MOON: + return "astro_type_satellite" + case .NEBULA: + return "astro_type_nebula" + case .OPEN_CLUSTER: + return "astro_type_open_cluster" + case .GLOBULAR_CLUSTER: + return "astro_type_globular_cluster" + case .GALAXY_CLUSTER: + return "astro_type_galaxy_cluster" + case .CONSTELLATION: + return "astro_type_constellation" + } + } + + var localizedName: String { + switch self { + case .STAR: + return localizedString("astro_stars") + case .GALAXY: + return localizedString("astro_galaxies") + case .BLACK_HOLE: + return localizedString("astro_black_holes") + case .PLANET: + return localizedString("astro_planets") + case .SUN: + return localizedString("astro_name_sun") + case .MOON: + return localizedString("astro_name_moon") + case .NEBULA: + return localizedString("astro_nebulae") + case .OPEN_CLUSTER: + return localizedString("astro_open_clusters") + case .GLOBULAR_CLUSTER: + return localizedString("astro_globular_clusters") + case .GALAXY_CLUSTER: + return localizedString("astro_galaxy_clusters") + case .CONSTELLATION: + return localizedString("astro_constellations") + } + } + + func isSunSystem() -> Bool { + self == .SUN || self == .MOON || self == .PLANET + } + + static func fromDbType(_ value: String?) -> SkyObjectType? { + switch value?.lowercased() { + case "stars", "star": + return .STAR + case "galaxies", "galaxy": + return .GALAXY + case "black_holes", "black_hole": + return .BLACK_HOLE + case "nebulae", "nebula": + return .NEBULA + case "open_clusters", "open_cluster": + return .OPEN_CLUSTER + case "globular_clusters", "globular_cluster": + return .GLOBULAR_CLUSTER + case "galaxy_clusters", "galaxy_cluster": + return .GALAXY_CLUSTER + case "constellations", "constellation": + return .CONSTELLATION + case "solar_system", "planet": + return .PLANET + default: + return nil + } + } +} + +class SkyObject: NSObject { + private static let hdCatalogWid = "Q111130" + private static let hicCatalogWid = "Q28914996" + private static let hipCatalogWid = "Q537199" + + let id: String + let hip: Int + var catalogs: [Catalog] + let wid: String + let centerWId: String? + let type: SkyObjectType + let body: Body? + let name: String + var ra: Double + var dec: Double + let magnitude: Double + let color: UIColor + var radius: Double? + var distance: Double? + var mass: Double? + var localizedName: String? + var azimuth: Double + var altitude: Double + var distAu: Double + var isFavorite: Bool + var showDirection: Bool + var showCelestialPath: Bool + var colorIndex: Int + var startAzimuth: Double + var startAltitude: Double + var targetAzimuth: Double + var targetAltitude: Double + var lastUpdateTime: Double + + override var hash: Int { + id.hashValue + } + + override var description: String { + "SkyObject(id='\(id)', name='\(name)', type=\(type))" + } + + init(id: String, + hip: Int, + catalogs: [Catalog] = [], + wid: String, + centerWId: String? = nil, + type: SkyObjectType, + body: Body?, + name: String, + ra: Double, + dec: Double, + magnitude: Double, + color: UIColor, + radius: Double? = nil, + distance: Double? = nil, + mass: Double? = nil, + localizedName: String? = nil) { + self.id = id + self.hip = hip + self.catalogs = catalogs + self.wid = wid + self.centerWId = centerWId + self.type = type + self.body = body + self.name = name + self.ra = ra + self.dec = dec + self.magnitude = magnitude + self.color = color + self.radius = radius + self.distance = distance + self.mass = mass + self.localizedName = localizedName + self.azimuth = 0 + self.altitude = 0 + self.distAu = 0 + self.isFavorite = false + self.showDirection = false + self.showCelestialPath = false + self.colorIndex = 0 + self.startAzimuth = 0 + self.startAltitude = 0 + self.targetAzimuth = 0 + self.targetAltitude = 0 + self.lastUpdateTime = -1 + super.init() + } + + convenience init(id: String, + hip: Int? = nil, + catalogs: [Catalog] = [], + wid: String? = nil, + centerWId: String? = nil, + type: SkyObjectType, + body: Body? = nil, + name: String?, + ra: Double, + dec: Double, + magnitude: Double? = nil, + color: UIColor, + radius: Double? = nil, + distance: Double? = nil, + mass: Double? = nil, + localizedName: String? = nil) { + self.init(id: id, + hip: hip ?? -1, + catalogs: catalogs, + wid: wid ?? "", + centerWId: centerWId, + type: type, + body: body, + name: name ?? "", + ra: ra, + dec: dec, + magnitude: magnitude ?? 25, + color: color, + radius: radius, + distance: distance, + mass: mass, + localizedName: localizedName) + } + + func niceName() -> String { + getDisplayName() + } + + func hasMissingPrimaryName() -> Bool { + getPrimaryDisplayName() == nil + } + + func getDisplayName() -> String { + if let primaryName = getPrimaryDisplayName() { + return primaryName + } + if let catalogName = getCatalogFallbackName() { + return catalogName + } + if let hipName = getHipFallbackName() { + return hipName + } + if !wid.isEmpty { + return wid + } + return name + } + + private func getPrimaryDisplayName() -> String? { + if let localizedName, !localizedName.isEmpty { + return localizedName + } + if !name.isEmpty && name.caseInsensitiveCompare(wid) != .orderedSame { + return name + } + return nil + } + + private func getCatalogFallbackName() -> String? { + var catalogId = catalogs.first { $0.wid == Self.hdCatalogWid }?.catalogId + if catalogId?.isEmpty == false { + return catalogId + } + + catalogId = catalogs.first { $0.wid == Self.hicCatalogWid }?.catalogId + if catalogId?.isEmpty == false { + return catalogId + } + + catalogId = catalogs.first { $0.wid == Self.hipCatalogWid }?.catalogId + if catalogId?.isEmpty == false { + return catalogId + } + return nil + } + + private func getHipFallbackName() -> String? { + hip > 0 ? "HIP \(hip)" : nil + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? SkyObject else { + return false + } + return id == other.id + } +} diff --git a/Sources/Plugins/Astronomy/SkyObjectInfoBottomSheet.swift b/Sources/Plugins/Astronomy/SkyObjectInfoBottomSheet.swift new file mode 100644 index 0000000000..6d8b750d23 --- /dev/null +++ b/Sources/Plugins/Astronomy/SkyObjectInfoBottomSheet.swift @@ -0,0 +1,1146 @@ +// +// SkyObjectInfoBottomSheet.swift +// OsmAnd Maps +// +// Replaced by AstroContextMenuViewController. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import OsmAndShared +import UIKit + +struct AstroContextMenuDependencies { + let currentDate: () -> Date + let observer: () -> Observer + let dataProvider: AstroDataProvider? + let preferredLocale: () -> String? + let trackableObjects: () -> [SkyObject] + let constellations: () -> [Constellation] + let onClose: () -> Void + let onDismissed: () -> Void + let onCenterObject: (SkyObject) -> Void + let onFavoriteChanged: (SkyObject, Bool) -> Void + let onDirectionChanged: (SkyObject, Bool) -> Int + let onCelestialPathChanged: (SkyObject, Bool) -> Void + let onSetObjectPinned: (SkyObject, Bool, Bool) -> Void + let onRefreshObjects: () -> Void + let onCatalogClick: (Catalog) -> Void +} + +final class AstroContextMenuViewController: UIViewController, UIScrollViewDelegate, UITabBarDelegate, UISheetPresentationControllerDelegate { + enum Tab: Int { + case overview = 0 + case visibility = 1 + case schedule = 2 + } + + private let dependencies: AstroContextMenuDependencies + private var skyObject: SkyObject? + private var article: AstroArticle? + private var uiState = AstroContextUiState() + + private let visibilityController = AstroVisibilityCardController() + private let scheduleController = AstroScheduleCardController() + private let knowledgeBaseController = AstroKnowledgeBaseController() + private let cardFactory = AstroContextCardFactory() + private let metricsAdapter = MetricsAdapter() + private lazy var galleryLoader = AstroGalleryLoader(onStateChanged: { [weak self] wid, state in + self?.onGalleryStateChanged(wid: wid, state: state) + }) + private lazy var adapter = AstroContextMenuAdapter( + presentingController: self, + onDescriptionRead: { [weak self] item in self?.openDescriptionCard(item) }, + onGalleryToggle: { [weak self] wid in self?.onGalleryToggle(wid) }, + onUpdateImage: { [weak self] in + guard let wid = self?.skyObject?.wid, !wid.isEmpty else { + return + } + self?.loadGallery(wid) + }, + onKnowledgeCardAction: { [weak self] in self?.onKnowledgeCardAction() }, + onVisibilityResetToToday: { [weak self] in self?.resetVisibilityToToday() }, + onVisibilityCursorChanged: { [weak self] referenceTimeMillis in self?.onVisibilityCursorChanged(referenceTimeMillis) }, + onScheduleResetPeriod: { [weak self] in self?.resetScheduleToCurrentPeriod() }, + onScheduleShiftPeriod: { [weak self] daysDelta in self?.shiftSchedulePeriod(daysDelta: daysDelta) }, + onScheduleSelectDate: { [weak self] date in self?.selectVisibilityDate(date) }, + onCatalogsToggleExpanded: { [weak self] in self?.toggleCatalogsExpanded() }, + onCatalogClick: { [weak self] catalog in self?.openCatalogSearch(catalog) } + ) + + private let sheetHeaderView = UIView() + private let titleLabel = UILabel() + private let closeButton = UIButton(type: .close) + private let headerType = UILabel() + private let metricsContainer = UIView() + private let actionsStack = UIStackView() + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let cardsStack = UIStackView() + private let tabBar = UITabBar() + private lazy var overviewTabItem = makeTabBarItem(title: localizedString("shared_string_overview"), + iconName: overviewTabIconName(for: skyObject?.type), + tag: Tab.overview.rawValue) + private lazy var visibilityTabItem = makeTabBarItem(title: localizedString("gpx_visibility_txt"), + iconName: "ic_custom_telescope_colored", + tag: Tab.visibility.rawValue) + private lazy var scheduleTabItem = makeTabBarItem(title: localizedString("astronomy_schedule"), + iconName: "ic_custom_date", + tag: Tab.schedule.rawValue) + private var cardViewsByKey: [AstroContextCardKey: UIView] = [:] + private var selectedTab: Tab = .overview + private var isProgrammaticTabScroll = false + private var redFilterEnabled = false + private var downloadTaskProgressObserver: OAAutoObserverProxy? + private var downloadTaskCompletedObserver: OAAutoObserverProxy? + private var localResourcesChangedObserver: OAAutoObserverProxy? + private var latestKnowledgeDownloadProgress: Float? + private var knowledgeDownloadProgressRenderScheduled = false + private var lastRenderedKnowledgeDownloadButtonTitle: String? + private var displayedKnowledgeDownloadActive = false + private let knowledgeDownloadButtonRefreshInterval: TimeInterval = 0.5 + + private let saveButton = UIButton(type: .system) + private let locationButton = UIButton(type: .system) + private let directionButton = UIButton(type: .system) + private let pathButton = UIButton(type: .system) + + init(object: SkyObject, dependencies: AstroContextMenuDependencies) { + self.skyObject = object + self.dependencies = dependencies + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + detachDownloadObservers() + galleryLoader.cancel() + visibilityController.cancelPendingWork() + scheduleController.cancelPendingWork() + } + + override func viewDidLoad() { + super.viewDidLoad() + setupView() + applyTheme() + configureNavigationBar() + bindControllerCallbacks() + setupDownloadObservers() + if let skyObject { + updateObjectInfo(skyObject) + } + applyRedFilter(enabled: redFilterEnabled) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + navigationController?.sheetPresentationController?.delegate = self + syncTabBarVisibilityWithSheetDetent(animated: false) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true { + applyTheme() + cardsStack.arrangedSubviews.forEach { $0.setNeedsDisplay() } + applyRedFilter(enabled: redFilterEnabled) + } + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + let tabBarHeight = tabBar.bounds.height > 0 ? tabBar.bounds.height : 49 + let bottomInset = tabBar.alpha > 0 ? tabBarHeight + view.safeAreaInsets.bottom + 16 : 16 + scrollView.contentInset.bottom = bottomInset + scrollView.verticalScrollIndicatorInsets.bottom = bottomInset + } + + func updateObjectInfo(_ obj: SkyObject) { + skyObject = obj + guard isViewLoaded else { + return + } + let currentTime = dependencies.currentDate() + let currentDate = normalizedDay(currentTime) + let objectChanged = uiState.selectedObjectId != obj.id + if objectChanged { + resetOverviewStateForNewObject() + } + if objectChanged { + galleryLoader.cancel() + uiState = AstroContextUiState(selectedObjectId: obj.id, + currentLocalDate: currentDate, + visibilityCursorReferenceTimeMillis: millis(currentTime), + schedulePeriodStart: currentDate) + } else { + uiState.selectedObjectId = obj.id + uiState.currentLocalDate = currentDate + uiState.visibilityCursorReferenceTimeMillis = uiState.visibilityCursorReferenceTimeMillis ?? millis(currentTime) + uiState.schedulePeriodStart = uiState.schedulePeriodStart ?? currentDate + } + + article = dependencies.dataProvider?.getAstroArticle(wikidataId: obj.wid, lang: dependencies.preferredLocale()) + setTitle(obj.niceName()) + updateOverviewTabIcon(for: obj.type) + headerType.text = buildHeaderTypeText(obj) + + updateMetrics(obj) + updateButtons(obj) + updateVisibilityCard(obj) + updateScheduleCard(obj) + ensureKnowledgeCardPrerequisites() + if case .loading = uiState.galleryState { + galleryLoader.startLoading(obj.wid) + } + submitCards() + } + + func applyRedFilter(enabled: Bool) { + redFilterEnabled = enabled + guard isViewLoaded else { + return + } + AstroRedFilter.apply(enabled, to: view) + } + + func onTimeChanged() { + guard let obj = skyObject, + isViewLoaded else { + return + } + updateMetrics(obj, useTargetCoordinates: true) + let currentDate = normalizedDay(dependencies.currentDate()) + let previousDate = uiState.currentLocalDate + if previousDate == currentDate { + return + } + + let currentScheduleStart = uiState.schedulePeriodStart + let shouldShiftSchedulePeriod = currentScheduleStart == nil || currentScheduleStart == previousDate + uiState.currentLocalDate = currentDate + uiState.schedulePeriodStart = shouldShiftSchedulePeriod ? currentDate : currentScheduleStart + + if uiState.selectedVisibilityDateOverride == nil { + updateVisibilityCard(obj) + } + if shouldShiftSchedulePeriod { + updateScheduleCard(obj, periodStartOverride: currentDate) + } + submitCards() + } + + func onLocationChanged() { + guard let obj = skyObject, + isViewLoaded else { + return + } + updateMetrics(obj) + updateVisibilityCard(obj) + updateScheduleCard(obj, periodStartOverride: uiState.schedulePeriodStart) + submitCards() + } + + private func setTabBarVisible(_ visible: Bool, animated: Bool) { + guard isViewLoaded else { + return + } + let updates = { + self.tabBar.alpha = visible ? 1 : 0 + self.tabBar.isUserInteractionEnabled = visible + let tabBarHeight = self.tabBar.bounds.height > 0 ? self.tabBar.bounds.height : 49 + let bottomInset = visible ? tabBarHeight + self.view.safeAreaInsets.bottom + 16 : 16 + self.scrollView.contentInset.bottom = bottomInset + self.scrollView.verticalScrollIndicatorInsets.bottom = bottomInset + } + if animated { + UIView.animate(withDuration: 0.2, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction], animations: updates) + } else { + updates() + } + } + + private func syncTabBarVisibilityWithSheetDetent(animated: Bool) { + setTabBarVisible(navigationController?.sheetPresentationController?.selectedDetentIdentifier == .large, + animated: animated) + } + + private func setupView() { + view.backgroundColor = AstroContextMenuTheme.pageBackground + + sheetHeaderView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(sheetHeaderView) + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.font = .systemFont(ofSize: 24, weight: .bold) + titleLabel.textColor = AstroContextMenuTheme.primaryText + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.numberOfLines = 1 + titleLabel.accessibilityTraits = .header + sheetHeaderView.addSubview(titleLabel) + + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeButton.tintColor = AstroContextMenuTheme.primaryText + closeButton.accessibilityLabel = localizedString("shared_string_close") + closeButton.addAction(UIAction { [weak self] _ in + self?.dependencies.onClose() + }, for: .touchUpInside) + sheetHeaderView.addSubview(closeButton) + + headerType.translatesAutoresizingMaskIntoConstraints = false + headerType.font = .systemFont(ofSize: 17) + headerType.numberOfLines = 2 + + metricsContainer.translatesAutoresizingMaskIntoConstraints = false + + actionsStack.axis = .horizontal + actionsStack.alignment = .fill + actionsStack.distribution = .fillEqually + actionsStack.spacing = 8 + actionsStack.translatesAutoresizingMaskIntoConstraints = false + [saveButton, locationButton, directionButton, pathButton].forEach { + configureActionButton($0) + actionsStack.addArrangedSubview($0) + } + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.alwaysBounceVertical = true + scrollView.delegate = self + scrollView.backgroundColor = .clear + view.addSubview(scrollView) + + contentStack.axis = .vertical + contentStack.spacing = 12 + contentStack.isLayoutMarginsRelativeArrangement = true + contentStack.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 16, trailing: 16) + contentStack.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentStack) + + contentStack.addArrangedSubview(headerType) + contentStack.setCustomSpacing(16, after: headerType) + contentStack.addArrangedSubview(metricsContainer) + contentStack.addArrangedSubview(actionsStack) + + cardsStack.axis = .vertical + cardsStack.spacing = 12 + cardsStack.translatesAutoresizingMaskIntoConstraints = false + contentStack.addArrangedSubview(cardsStack) + + setupTabBar() + view.addSubview(tabBar) + + NSLayoutConstraint.activate([ + sheetHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + sheetHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + sheetHeaderView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + + titleLabel.leadingAnchor.constraint(equalTo: sheetHeaderView.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: closeButton.leadingAnchor, constant: -16), + titleLabel.topAnchor.constraint(equalTo: sheetHeaderView.topAnchor, constant: 18), + titleLabel.bottomAnchor.constraint(equalTo: sheetHeaderView.bottomAnchor, constant: -4), + + closeButton.trailingAnchor.constraint(equalTo: sheetHeaderView.trailingAnchor, constant: -16), + closeButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + closeButton.widthAnchor.constraint(equalToConstant: 44), + closeButton.heightAnchor.constraint(equalToConstant: 44), + + metricsContainer.heightAnchor.constraint(equalToConstant: 62), + actionsStack.heightAnchor.constraint(equalToConstant: 66), + + tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tabBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: sheetHeaderView.bottomAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor) + ]) + } + + private func setupTabBar() { + tabBar.translatesAutoresizingMaskIntoConstraints = false + tabBar.delegate = self + tabBar.isTranslucent = true + tabBar.setContentCompressionResistancePriority(.required, for: .vertical) + tabBar.setItems([overviewTabItem, visibilityTabItem, scheduleTabItem], animated: false) + tabBar.selectedItem = overviewTabItem + tabBar.alpha = 0 + tabBar.isUserInteractionEnabled = false + + scrollView.contentInset.bottom = 16 + scrollView.verticalScrollIndicatorInsets.bottom = 16 + } + + private func applyTheme() { + view.backgroundColor = AstroContextMenuTheme.pageBackground + titleLabel.textColor = AstroContextMenuTheme.primaryText + closeButton.tintColor = AstroContextMenuTheme.primaryText + headerType.textColor = AstroContextMenuTheme.secondaryText + metricsContainer.backgroundColor = .clear + configureTabBarAppearance() + configureNavigationBar() + [saveButton, locationButton, directionButton, pathButton].forEach { button in + var config = button.configuration ?? UIButton.Configuration.filled() + config.baseBackgroundColor = AstroContextMenuTheme.actionBackground + config.baseForegroundColor = AstroContextMenuTheme.activeIcon + button.configuration = config + } + } + + private func configureNavigationBar() { + title = nil + navigationItem.title = nil + navigationItem.largeTitleDisplayMode = .never + navigationItem.leftBarButtonItem = nil + navigationItem.rightBarButtonItem = nil + navigationController?.setNavigationBarHidden(true, animated: false) + } + + private func configureTabBarAppearance() { + tabBar.tintColor = AstroContextMenuTheme.activeIcon + tabBar.unselectedItemTintColor = AstroContextMenuTheme.secondaryIcon + } + + private func makeTabBarItem(title: String, iconName: String, tag: Int) -> UITabBarItem { + let image = AstroIcon.template(iconName) + let item = UITabBarItem(title: title, image: image, selectedImage: image) + item.tag = tag + item.accessibilityLabel = title + return item + } + + private func updateOverviewTabIcon(for type: SkyObjectType?) { + let image = AstroIcon.template(overviewTabIconName(for: type)) + overviewTabItem.image = image + overviewTabItem.selectedImage = image + } + + private func overviewTabIconName(for type: SkyObjectType?) -> String { + guard let type else { + return "ic_custom_planet_outlined" + } + switch type { + case .SUN, .MOON, .PLANET: + return "ic_custom_planet_outlined" + case .CONSTELLATION: + return "ic_custom_constellations" + case .STAR: + return "ic_custom_star_shine" + case .NEBULA: + return "ic_custom_nebulas" + case .OPEN_CLUSTER, .GLOBULAR_CLUSTER: + return "ic_custom_star_clusters" + case .GALAXY, .GALAXY_CLUSTER, .BLACK_HOLE: + return "ic_custom_galaxy" + } + } + + private func configureActionButton(_ button: UIButton) { + var config = UIButton.Configuration.filled() + config.imagePlacement = .top + config.imagePadding = 3 + config.baseBackgroundColor = AstroContextMenuTheme.actionBackground + config.baseForegroundColor = AstroContextMenuTheme.activeIcon + config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 4, bottom: 7, trailing: 4) + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = .systemFont(ofSize: 15, weight: .regular) + return outgoing + } + button.configuration = config + button.layer.cornerRadius = 8 + button.clipsToBounds = true + button.titleLabel?.adjustsFontSizeToFitWidth = true + button.titleLabel?.minimumScaleFactor = 0.65 + } + + private func bindControllerCallbacks() { + visibilityController.onDataChanged = { [weak self] in self?.submitCards() } + scheduleController.onDataChanged = { [weak self] in self?.submitCards() } + } + + private func buildHeaderTypeText(_ obj: SkyObject) -> String { + let typeName = localizedString(obj.type.titleKey) + let parentGroup: String + if obj.type == .MOON { + parentGroup = localizedString("astro_type_earth") + } else if obj.type.isSunSystem() { + parentGroup = localizedString("astro_solar_system") + } else if obj.type == .STAR, + let constellation = dependencies.constellations().first(where: { constellation in + constellation.lines.contains { segment in segment.0 == obj.hip || segment.1 == obj.hip } + }) { + parentGroup = constellation.localizedName?.isEmpty == false ? constellation.localizedName ?? constellation.name : constellation.name + } else { + parentGroup = localizedString("astro_deep_sky") + } + return "\(typeName) • \(parentGroup)" + } + + private func updateButtons(_ obj: SkyObject) { + bindActionButton(saveButton, + title: localizedString("shared_string_save"), + image: obj.isFavorite ? "ic_custom_bookmark" : "ic_custom_bookmark_outlined") { [weak self] in + guard let self else { return } + obj.isFavorite.toggle() + dependencies.onFavoriteChanged(obj, obj.isFavorite) + bindActionButtonsForCurrentObject() + } + bindActionButton(locationButton, + title: localizedString("astro_locate"), + image: "ic_custom_location_marker") { [weak self] in + guard let self else { return } + dependencies.onCenterObject(obj) + bindActionButtonsForCurrentObject() + } + bindActionButton(directionButton, + title: localizedString("astro_direction"), + image: obj.showDirection ? "ic_custom_target_direction_on" : "ic_custom_target_direction_off") { [weak self] in + guard let self else { return } + obj.showDirection.toggle() + if obj.showDirection { + obj.colorIndex = dependencies.onDirectionChanged(obj, true) + } else { + _ = dependencies.onDirectionChanged(obj, false) + } + bindActionButtonsForCurrentObject() + } + bindActionButton(pathButton, + title: localizedString("astro_path"), + image: obj.showCelestialPath ? "ic_custom_target_path_on" : "ic_custom_target_path_off") { [weak self] in + guard let self else { return } + obj.showCelestialPath.toggle() + dependencies.onCelestialPathChanged(obj, obj.showCelestialPath) + dependencies.onSetObjectPinned(obj, obj.showCelestialPath, true) + bindActionButtonsForCurrentObject() + } + } + + private func bindActionButtonsForCurrentObject() { + guard let skyObject else { + return + } + updateButtons(skyObject) + } + + private func bindActionButton(_ button: UIButton, title: String, image: String, action: @escaping () -> Void) { + var config = button.configuration ?? UIButton.Configuration.filled() + config.title = title + config.image = actionButtonImage(named: image) + button.configuration = config + let identifier = UIAction.Identifier("astro.context.action") + button.removeAction(identifiedBy: identifier, for: .touchUpInside) + button.addAction(UIAction(identifier: identifier) { _ in action() }, for: .touchUpInside) + } + + private func actionButtonImage(named imageName: String) -> UIImage? { + if imageName == "ic_custom_location_marker" { + return AstroIcon.template(imageName, size: CGSize(width: 24, height: 24)) + } + return AstroIcon.template(imageName) + } + + private func updateMetrics(_ obj: SkyObject, useTargetCoordinates: Bool = false) { + if !useTargetCoordinates { + _ = AstroUtils.horizontalPosition(for: obj, + time: AstroUtils.astronomyTime(from: dependencies.currentDate()), + observer: dependencies.observer()) + } + let azimuth = useTargetCoordinates ? obj.targetAzimuth : obj.azimuth + let altitude = useTargetCoordinates ? obj.targetAltitude : obj.altitude + var metrics: [MetricsAdapter.MetricUi] = [ + MetricsAdapter.MetricUi(value: String(format: "%.1f°", azimuth), + label: localizedString("shared_string_azimuth")), + MetricsAdapter.MetricUi(value: String(format: "%.1f°", altitude), + label: localizedString("altitude")), + MetricsAdapter.MetricUi(value: String(format: "%.2f", obj.magnitude), + label: localizedString("shared_string_magnitude")) + ] + + let currentDate = dependencies.currentDate() + let startLocal = noon(on: normalizedDay(currentDate)) + let endLocal = startLocal.addingTimeInterval(24 * 60 * 60) + let riseSet = AstroUtils.nextRiseSet(object: obj, + startSearch: startLocal, + observer: dependencies.observer(), + windowStart: startLocal, + windowEnd: endLocal) + let formatter = createUiTimeFormatter() + if let rise = riseSet.rise { + metrics.append(MetricsAdapter.MetricUi(value: formatter.string(from: rise), + label: localizedString("astro_rise"))) + } + if let set = riseSet.set { + metrics.append(MetricsAdapter.MetricUi(value: formatter.string(from: set), + label: localizedString("astro_set"))) + } + + metricsAdapter.submit(metrics) + metricsContainer.subviews.forEach { $0.removeFromSuperview() } + let metricsView = metricsAdapter.makeMetricsView() + metricsView.translatesAutoresizingMaskIntoConstraints = false + metricsContainer.addSubview(metricsView) + NSLayoutConstraint.activate([ + metricsView.leadingAnchor.constraint(equalTo: metricsContainer.leadingAnchor), + metricsView.trailingAnchor.constraint(equalTo: metricsContainer.trailingAnchor), + metricsView.topAnchor.constraint(equalTo: metricsContainer.topAnchor), + metricsView.bottomAnchor.constraint(equalTo: metricsContainer.bottomAnchor) + ]) + } + + private func updateVisibilityCard(_ obj: SkyObject) { + let currentTime = dependencies.currentDate() + let graphDate = uiState.selectedVisibilityDateOverride ?? normalizedDay(currentTime) + let isTodayVisibility = normalizedDay(graphDate) == normalizedDay(currentTime) + let cursorReferenceTimeMillis = uiState.visibilityCursorReferenceTimeMillis ?? millis(currentTime) + visibilityController.update(skyObject: obj, + observer: dependencies.observer(), + date: graphDate, + timeZone: .current, + cursorReferenceTimeMillis: cursorReferenceTimeMillis, + isTodayVisibility: isTodayVisibility) + } + + private func onVisibilityCursorChanged(_ referenceTimeMillis: Int64) { + uiState.visibilityCursorReferenceTimeMillis = referenceTimeMillis + } + + private func updateScheduleCard(_ obj: SkyObject, periodStartOverride: Date? = nil) { + let currentTime = dependencies.currentDate() + let defaultStartDate = normalizedDay(currentTime) + let periodStart = periodStartOverride ?? uiState.schedulePeriodStart ?? scheduleController.periodStart + uiState.schedulePeriodStart = periodStart + uiState.currentLocalDate = defaultStartDate + scheduleController.update(skyObject: obj, + observer: dependencies.observer(), + periodStart: periodStart, + timeZone: .current, + showResetPeriodButton: normalizedDay(periodStart) != defaultStartDate) + } + + private func shiftSchedulePeriod(daysDelta: Int) { + guard let obj = skyObject else { + return + } + let currentStart = uiState.schedulePeriodStart ?? scheduleController.periodStart + let nextStart = Calendar.current.date(byAdding: .day, value: daysDelta, to: currentStart) ?? currentStart + updateScheduleCard(obj, periodStartOverride: nextStart) + submitCards() + } + + private func resetScheduleToCurrentPeriod() { + guard let obj = skyObject else { + return + } + updateScheduleCard(obj, periodStartOverride: normalizedDay(dependencies.currentDate())) + submitCards() + } + + private func selectVisibilityDate(_ date: Date) { + let currentDate = normalizedDay(dependencies.currentDate()) + uiState.selectedVisibilityDateOverride = normalizedDay(date) == currentDate ? nil : normalizedDay(date) + if let skyObject { + updateVisibilityCard(skyObject) + } + submitCards() + selectTab(.visibility, scrollToSection: true) + } + + private func resetVisibilityToToday() { + guard uiState.selectedVisibilityDateOverride != nil else { + return + } + uiState.selectedVisibilityDateOverride = nil + if let skyObject { + updateVisibilityCard(skyObject) + } + submitCards() + } + + private func submitCards() { + let items = cardFactory.buildCards(skyObject: skyObject, + article: article, + uiState: uiState, + knowledgeItem: buildKnowledgeCardItem(), + visibilityItem: visibilityController.buildItem(), + scheduleItem: scheduleController.buildItem()) + adapter.submitItems(items) + rebuildCardsStack() + } + + private func rebuildCardsStack() { + cardsStack.arrangedSubviews.forEach { + cardsStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + cardViewsByKey.removeAll() + let views = adapter.makeCardViews() + for (index, view) in views.enumerated() { + if adapter.currentList.indices.contains(index) { + cardViewsByKey[adapter.currentList[index].key] = view + } + cardsStack.addArrangedSubview(view) + } + syncDisplayedKnowledgeCardState() + updateSelectedTabControls() + } + + private func syncDisplayedKnowledgeCardState() { + let item = adapter.currentList.compactMap { $0 as? AstroKnowledgeCardItem }.first + displayedKnowledgeDownloadActive = item?.isDownloading == true + lastRenderedKnowledgeDownloadButtonTitle = item?.buttonTitle + } + + private func toggleCatalogsExpanded() { + uiState.catalogsExpanded.toggle() + submitCards() + } + + private func onGalleryToggle(_ wid: String) { + switch uiState.galleryState { + case .collapsed: + loadGallery(wid) + case .ready: + uiState.galleryState = .collapsed + submitCards() + case .loading: + break + } + } + + private func loadGallery(_ wid: String) { + guard !wid.isEmpty else { + uiState.galleryState = .ready([]) + submitCards() + return + } + uiState.galleryState = .loading + submitCards() + galleryLoader.startLoading(wid) + } + + private func onGalleryStateChanged(wid: String, state: AstroGalleryState) { + guard skyObject?.wid == wid else { + return + } + uiState.galleryState = state + submitCards() + } + + private func openDescriptionCard(_ item: AstroDescriptionCardItem) { + if item.hasOfflineArticle, showOfflineArticle() { + return + } + if let uri = item.readMoreUri { + UIApplication.shared.open(uri) + } + } + + private func showOfflineArticle() -> Bool { + guard let article, + article.hasOfflineContent() else { + return false + } + return AstroArticleViewController.showInstance(from: self, article: article) + } + + private func buildKnowledgeCardItem() -> AstroKnowledgeCardItem? { + knowledgeBaseController.buildCardItem() + } + + private func ensureKnowledgeCardPrerequisites() { + if knowledgeBaseController.currentState() == .download { + knowledgeBaseController.ensureIndexesLoaded() + } + } + + private func onKnowledgeCardAction() { + switch knowledgeBaseController.currentState() { + case .upsell: + if let navigation = navigationController ?? OARootViewController.instance().navigationController { + OAChoosePlanHelper.showChoosePlanScreen(with: OAFeature.astronomy(), navController: navigation) + } + case .download: + guard let item = knowledgeBaseController.findDownloadItem() else { + knowledgeBaseController.ensureIndexesLoaded() + OAResourcesUISwiftHelper.prepareResourcesData() + guard let item = knowledgeBaseController.findDownloadItem() else { + OAUtilities.showToast(localizedString("no_index_file_to_download"), + details: nil, + duration: 4, + in: view) + submitCards() + return + } + startKnowledgeBaseDownload(item) + return + } + if knowledgeBaseController.findActiveDownload(resourceItem: item) != nil { + cancelKnowledgeBaseDownload(item) + } else { + startKnowledgeBaseDownload(item) + } + case nil: + break + } + } + + private func startKnowledgeBaseDownload(_ item: OAResourceSwiftItem) { + item.refreshDownloadTask() + if item.isOutdatedItem() { + OAResourcesUISwiftHelper.offerDownloadAndUpdate(of: item, onTaskCreated: { [weak self] task in + self?.onKnowledgeDownloadTaskStarted(task) + }, onTaskResumed: { [weak self] task in + self?.onKnowledgeDownloadTaskStarted(task) + }) + } else { + OAResourcesUISwiftHelper.offerDownloadAndInstall(of: item, onTaskCreated: { [weak self] task in + self?.onKnowledgeDownloadTaskStarted(task) + }, onTaskResumed: { [weak self] task in + self?.onKnowledgeDownloadTaskStarted(task) + }, completionHandler: { [weak self] alert in + self?.presentKnowledgeDownloadAlert(alert) + }) + } + } + + private func onKnowledgeDownloadTaskStarted(_ task: OADownloadTask?) { + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + latestKnowledgeDownloadProgress = task?.progressCompleted + guard !displayedKnowledgeDownloadActive else { + scheduleKnowledgeDownloadProgressRender() + return + } + submitCards() + } + } + + private func cancelKnowledgeBaseDownload(_ item: OAResourceSwiftItem) { + guard let task = knowledgeBaseController.findActiveDownload(resourceItem: item) else { + submitCards() + return + } + let rawTitle = item.title() ?? "" + let itemTitle = rawTitle.isEmpty ? localizedString("astronomy_map") : rawTitle + let message = [ + String(format: localizedString("res_cancel_inst_q"), itemTitle), + localizedString("data_will_be_lost"), + localizedString("proceed_q") + ].joined(separator: " ") + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: localizedString("shared_string_no"), style: .cancel)) + alert.addAction(UIAlertAction(title: localizedString("shared_string_yes"), style: .default) { [weak self] _ in + task.stop() + DispatchQueue.main.async { [weak self] in + self?.latestKnowledgeDownloadProgress = nil + self?.knowledgeDownloadProgressRenderScheduled = false + self?.submitCards() + } + }) + presentKnowledgeDownloadAlert(alert) + } + + private func presentKnowledgeDownloadAlert(_ alert: UIAlertController?) { + guard let alert else { + return + } + DispatchQueue.main.async { [weak self] in + guard let self, + self.isViewLoaded, + self.view.window != nil else { + return + } + self.present(alert, animated: true) + } + } + + private func setupDownloadObservers() { + guard let app = OsmAndApp.swiftInstance() else { + return + } + downloadTaskProgressObserver = OAAutoObserverProxy(self, + withHandler: #selector(onKnowledgeDownloadTaskProgressChanged), + andObserve: app.downloadsManager.progressCompletedObservable) + downloadTaskCompletedObserver = OAAutoObserverProxy(self, + withHandler: #selector(onKnowledgeDownloadTaskFinished), + andObserve: app.downloadsManager.completedObservable) + localResourcesChangedObserver = OAAutoObserverProxy(self, + withHandler: #selector(onLocalResourcesChanged), + andObserve: app.localResourcesChangedObservable) + } + + private func detachDownloadObservers() { + downloadTaskProgressObserver?.detach() + downloadTaskProgressObserver = nil + downloadTaskCompletedObserver?.detach() + downloadTaskCompletedObserver = nil + localResourcesChangedObserver?.detach() + localResourcesChangedObserver = nil + } + + @objc private func onKnowledgeDownloadTaskProgressChanged(observer: Any, key: Any, value: Any) { + guard isKnowledgeDownloadNotification(key: key), + let progress = knowledgeDownloadProgressValue(from: value) else { + return + } + DispatchQueue.main.async { [weak self] in + self?.latestKnowledgeDownloadProgress = progress + self?.scheduleKnowledgeDownloadProgressRender() + } + } + + @objc private func onKnowledgeDownloadTaskFinished(observer: Any, key: Any, value: Any) { + guard isKnowledgeDownloadNotification(key: key) else { + return + } + DispatchQueue.main.async { [weak self] in + self?.onKnowledgeBaseResourceChanged() + } + } + + @objc private func onLocalResourcesChanged(observer: Any, key: Any, value: Any) { + DispatchQueue.main.async { [weak self] in + guard let self, + self.isViewLoaded, + self.view.window != nil else { + return + } + self.onKnowledgeBaseResourceChanged() + } + } + + private func isKnowledgeDownloadNotification(key: Any) -> Bool { + guard let task = key as? OADownloadTask, + let taskKey = task.key else { + return false + } + return taskKey == AstroKnowledgeBaseController.resourceTaskKey + } + + private func onKnowledgeBaseResourceChanged() { + latestKnowledgeDownloadProgress = nil + knowledgeDownloadProgressRenderScheduled = false + OAResourcesUISwiftHelper.onDownldedResourceInstalled() + if knowledgeBaseController.isDownloaded() { + knowledgeBaseController.resetIndexesReloadFlag() + (OAPluginsHelper.getPlugin(AstronomyPlugin.self) as? AstronomyPlugin)?.clearCachedData() + dependencies.onRefreshObjects() + if let skyObject { + article = dependencies.dataProvider?.getAstroArticle(wikidataId: skyObject.wid, + lang: dependencies.preferredLocale()) + } + } + submitCards() + } + + private func knowledgeDownloadProgressValue(from value: Any) -> Float? { + if let number = value as? NSNumber { + return number.floatValue + } + if let progress = value as? Float { + return progress + } + if let progress = value as? Double { + return Float(progress) + } + return nil + } + + private func scheduleKnowledgeDownloadProgressRender() { + guard isViewLoaded, + view.window != nil else { + return + } + guard !knowledgeDownloadProgressRenderScheduled else { + return + } + knowledgeDownloadProgressRenderScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + knowledgeDownloadButtonRefreshInterval) { [weak self] in + guard let self else { + return + } + knowledgeDownloadProgressRenderScheduled = false + updateKnowledgeDownloadButton(progressOverride: latestKnowledgeDownloadProgress) + } + } + + private func updateKnowledgeDownloadButton(progressOverride: Float? = nil) { + guard let knowledgeView = cardViewsByKey[.knowledge] as? AstroKnowledgeCardView, + let item = knowledgeBaseController.buildCardItem(progressOverride: progressOverride), + lastRenderedKnowledgeDownloadButtonTitle != item.buttonTitle else { + return + } + lastRenderedKnowledgeDownloadButtonTitle = item.buttonTitle + knowledgeView.update(item: item) + } + + private func openCatalogSearch(_ catalog: Catalog) { + dependencies.onCatalogClick(catalog) + } + + private func selectTab(_ tab: Tab, scrollToSection: Bool) { + selectedTab = tab + updateSelectedTabControls() + if scrollToSection { + scrollToTab(tab, animated: true) + } + } + + private func scrollToTab(_ tab: Tab, animated: Bool = true) { + isProgrammaticTabScroll = animated + guard tab != .overview else { + setScrollViewContentOffset(.zero, animated: animated) + return + } + let key: AstroContextCardKey? + switch tab { + case .overview: + key = nil + case .visibility: + key = .visibility + case .schedule: + key = .schedule + } + guard let key, + let target = cardViewsByKey[key] else { + isProgrammaticTabScroll = false + return + } + let targetFrame = target.convert(target.bounds, to: scrollView) + let maxOffsetY = max(0, scrollView.contentSize.height + + scrollView.adjustedContentInset.bottom + - scrollView.bounds.height) + let targetY = min(maxOffsetY, max(0, targetFrame.minY - 8)) + setScrollViewContentOffset(CGPoint(x: 0, y: targetY), animated: animated) + } + + private func setScrollViewContentOffset(_ offset: CGPoint, animated: Bool) { + guard abs(scrollView.contentOffset.y - offset.y) > 0.5 else { + isProgrammaticTabScroll = false + return + } + scrollView.setContentOffset(offset, animated: animated) + if !animated { + isProgrammaticTabScroll = false + } + } + + private func updateSelectedTabControls() { + let item = tabItem(for: selectedTab) + guard tabBar.selectedItem?.tag != item.tag else { + return + } + UIView.performWithoutAnimation { + tabBar.selectedItem = item + } + } + + func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { + guard let tab = Tab(rawValue: item.tag) else { + return + } + selectTab(tab, scrollToSection: true) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard !isProgrammaticTabScroll, + scrollView.isDragging || scrollView.isDecelerating else { + return + } + let y = scrollView.contentOffset.y + 16 + let visibilityY = cardViewsByKey[.visibility].map { $0.convert($0.bounds, to: scrollView).minY } + let scheduleY = cardViewsByKey[.schedule].map { $0.convert($0.bounds, to: scrollView).minY } + let nextTab: Tab + if let scheduleY, y >= scheduleY - 24 { + nextTab = .schedule + } else if let visibilityY, y >= visibilityY - 24 { + nextTab = .visibility + } else { + nextTab = .overview + } + if nextTab != selectedTab { + selectedTab = nextTab + updateSelectedTabControls() + } + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + isProgrammaticTabScroll = false + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + isProgrammaticTabScroll = false + updateSelectedTabControls() + } + + func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) { + guard sheetPresentationController === navigationController?.sheetPresentationController else { + return + } + setTabBarVisible(sheetPresentationController.selectedDetentIdentifier == .large, animated: true) + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + dependencies.onDismissed() + } + + private func tabItem(for tab: Tab) -> UITabBarItem { + switch tab { + case .overview: + return overviewTabItem + case .visibility: + return visibilityTabItem + case .schedule: + return scheduleTabItem + } + } + + private func resetOverviewStateForNewObject() { + selectedTab = .overview + updateSelectedTabControls() + scrollView.setContentOffset(.zero, animated: false) + uiState.catalogsExpanded = false + uiState.galleryState = .collapsed + } + + private func setTitle(_ name: String) { + title = nil + navigationItem.title = nil + titleLabel.text = name + } + + private func createUiTimeFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter + } + + private func normalizedDay(_ date: Date) -> Date { + Calendar.current.startOfDay(for: date) + } + + private func noon(on date: Date) -> Date { + Calendar.current.date(bySettingHour: 12, minute: 0, second: 0, of: date) ?? date.addingTimeInterval(12 * 60 * 60) + } + + private func millis(_ date: Date) -> Int64 { + Int64((date.timeIntervalSince1970 * 1000.0).rounded()) + } +} diff --git a/Sources/Plugins/Astronomy/StarCompassButton.swift b/Sources/Plugins/Astronomy/StarCompassButton.swift new file mode 100644 index 0000000000..8985698a31 --- /dev/null +++ b/Sources/Plugins/Astronomy/StarCompassButton.swift @@ -0,0 +1,65 @@ +// +// StarCompassButton.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class StarCompassButton: StarMapButton { + var onSingleTap: (() -> Void)? + private let arrowView = UIImageView(image: AstroIcon.original("ic_custom_direction_compass")) + private var currentRotation: CGFloat = 0 + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + func update(mapRotation: CGFloat, animated: Bool = false) { + currentRotation = mapRotation + let transform = CGAffineTransform(rotationAngle: mapRotation * .pi / 180.0) + if animated { + UIView.animate(withDuration: 0.25) { + self.arrowView.transform = transform + } + } else { + arrowView.transform = transform + } + } + + override func updateTheme() { + super.updateTheme() + setImage(nil, for: .normal) + arrowView.transform = CGAffineTransform(rotationAngle: currentRotation * .pi / 180.0) + } + + private func commonInit() { + setImage(nil, for: .normal) + arrowView.translatesAutoresizingMaskIntoConstraints = false + arrowView.contentMode = .center + arrowView.isUserInteractionEnabled = false + addSubview(arrowView) + NSLayoutConstraint.activate([ + arrowView.centerXAnchor.constraint(equalTo: centerXAnchor), + arrowView.centerYAnchor.constraint(equalTo: centerYAnchor), + arrowView.widthAnchor.constraint(equalToConstant: 28), + arrowView.heightAnchor.constraint(equalToConstant: 28) + ]) + addAction(UIAction { [weak self] _ in + self?.onSingleTap?() + }, for: .touchUpInside) + updateTheme() + } + + func shouldShow() -> Bool { + true + } +} diff --git a/Sources/Plugins/Astronomy/StarMapARModeHelper.swift b/Sources/Plugins/Astronomy/StarMapARModeHelper.swift new file mode 100644 index 0000000000..853531b141 --- /dev/null +++ b/Sources/Plugins/Astronomy/StarMapARModeHelper.swift @@ -0,0 +1,301 @@ +// +// StarMapARModeHelper.swift +// OsmAnd Maps +// +// Created by Codex on 19.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import CoreMotion +import CoreLocation +import Foundation +import UIKit + +final class StarMapARModeHelper { + private enum MotionUnavailableReason: String { + case deviceMotionUnavailable + case referenceFrameUnavailable + case motionStartFailed + } + + private enum ReferenceFrame: Equatable { + case magneticNorth + case trueNorth + + var coreMotionFrame: CMAttitudeReferenceFrame { + switch self { + case .magneticNorth: + return .xMagneticNorthZVertical + case .trueNorth: + return .xTrueNorthZVertical + } + } + + var appliesGeomagneticDeclination: Bool { + self == .magneticNorth + } + } + + private struct Vector3 { + let x: Double + let y: Double + let z: Double + } + + private enum CoreMotionErrorCode { + // CMErrorDeviceRequiresMovement in CoreMotion/CMError.h. + static let deviceRequiresMovement = 101 + // CMErrorTrueNorthNotAvailable in CoreMotion/CMError.h. + static let trueNorthNotAvailable = 102 + } + + private var motionManager: CMMotionManager? + private let queue = OperationQueue() + private let minAlpha = 0.03 + private let maxAlpha = 0.3 + private let jitterThresh = 0.5 + private let moveThresh = 2.0 + private var smoothedAzimuth = 0.0 + private var smoothedAltitude = 45.0 + private var activeReferenceFrame: ReferenceFrame? + private var geomagneticDeclination: Double? + + var onOrientationChanged: ((_ azimuth: Double, _ altitude: Double, _ roll: Double) -> Void)? + var onUnavailable: (() -> Void)? + var onArModeChanged: ((_ enabled: Bool) -> Void)? + private(set) var isArModeEnabled = false + private(set) var isRunning = false + + init() { + queue.maxConcurrentOperationCount = 1 + } + + deinit { + onOrientationChanged = nil + onUnavailable = nil + onArModeChanged = nil + stopMotionUpdates(waitUntilFinished: true) + } + + func onResume() { + if isArModeEnabled { + _ = startMotionUpdates() + } + } + + func onPause() { + stopMotionUpdates() + } + + func updateGeomagneticField(location: CLLocation) { + geomagneticDeclination = GeomagnetismObjCBridge(longitude: location.coordinate.longitude, + latitude: location.coordinate.latitude, + altitude: location.altitude, + date: Date()).declination() + } + + func toggleArMode() { + toggleArMode(enable: !isArModeEnabled) + } + + func toggleArMode(enable: Bool) { + guard isArModeEnabled != enable else { + return + } + + if enable { + isArModeEnabled = true + if startMotionUpdates() { + onArModeChanged?(true) + } + } else { + disableArMode() + } + } + + private func startMotionUpdates(using referenceFrame: ReferenceFrame? = nil) -> Bool { + let motionManager = self.motionManager ?? CMMotionManager() + self.motionManager = motionManager + + guard motionManager.isDeviceMotionAvailable else { + handleUnavailable(.deviceMotionUnavailable) + return false + } + guard let referenceFrame = referenceFrame ?? preferredReferenceFrame() else { + handleUnavailable(.referenceFrameUnavailable) + return false + } + guard !isRunning || activeReferenceFrame != referenceFrame else { + return true + } + + if isRunning { + stopMotionUpdates() + } + activeReferenceFrame = referenceFrame + isRunning = true + motionManager.deviceMotionUpdateInterval = 1.0 / 30.0 + motionManager.showsDeviceMovementDisplay = true + motionManager.startDeviceMotionUpdates(using: referenceFrame.coreMotionFrame, to: queue) { [weak self] motion, error in + guard let self else { + return + } + if let error { + self.handleMotionError(error) + return + } + guard self.isRunning, + self.isArModeEnabled, + let motion, + let activeReferenceFrame = self.activeReferenceFrame else { + return + } + let matrix = motion.attitude.rotationMatrix + let orientation = self.calculateOrientation(rotationMatrix: matrix, referenceFrame: activeReferenceFrame) + let smoothed = self.smoothOrientation(azimuth: orientation.azimuth, altitude: orientation.altitude) + DispatchQueue.main.async { [weak self] in + guard let self, self.isRunning, self.isArModeEnabled else { + return + } + self.onOrientationChanged?(smoothed.azimuth, smoothed.altitude, orientation.roll) + } + } + return true + } + + private func disableArMode() { + isArModeEnabled = false + stopMotionUpdates() + onArModeChanged?(false) + } + + private func stopMotionUpdates(waitUntilFinished: Bool = false) { + isRunning = false + activeReferenceFrame = nil + motionManager?.stopDeviceMotionUpdates() + queue.cancelAllOperations() + if waitUntilFinished, OperationQueue.current !== queue { + queue.waitUntilAllOperationsAreFinished() + } + } + + private func handleUnavailable(_ reason: MotionUnavailableReason) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.handleUnavailable(reason) + } + return + } +#if DEBUG + print("StarMapARModeHelper unavailable: \(reason.rawValue)") +#endif + isArModeEnabled = false + stopMotionUpdates() + onUnavailable?() + onArModeChanged?(false) + } + + private func handleMotionError(_ error: Error) { + let nsError = error as NSError +#if DEBUG + print("StarMapARModeHelper motion error: \(nsError.domain) \(nsError.code)") +#endif + guard isRunning, isArModeEnabled else { + return + } + guard nsError.domain == CMErrorDomain else { + handleUnavailable(.motionStartFailed) + return + } + + switch nsError.code { + case CoreMotionErrorCode.deviceRequiresMovement: + return + case CoreMotionErrorCode.trueNorthNotAvailable: + handleUnavailable(.referenceFrameUnavailable) + default: + handleUnavailable(.motionStartFailed) + } + } + + private func preferredReferenceFrame() -> ReferenceFrame? { + let available = CMMotionManager.availableAttitudeReferenceFrames() + if available.contains(.xMagneticNorthZVertical) { + return .magneticNorth + } + if available.contains(.xTrueNorthZVertical) { + return .trueNorth + } + return nil + } + + private func calculateOrientation(rotationMatrix matrix: CMRotationMatrix, + referenceFrame: ReferenceFrame) -> (azimuth: Double, altitude: Double, roll: Double) { + let forwardReference = referenceVector(forDeviceVector: Vector3(x: 0, y: 0, z: -1), rotationMatrix: matrix) + let east = -forwardReference.y + let north = forwardReference.x + let up = max(-1.0, min(1.0, forwardReference.z)) + + var azimuth = AstroUtils.normalizedDegrees(atan2(east, north) * 180.0 / .pi) + if referenceFrame.appliesGeomagneticDeclination, let declination = geomagneticDeclination { + azimuth = AstroUtils.normalizedDegrees(azimuth + declination) + } + let altitude = asin(up) * 180.0 / .pi + let roll = calculateRoll(rotationMatrix: matrix) + return (azimuth, altitude, roll) + } + + private func referenceVector(forDeviceVector vector: Vector3, rotationMatrix matrix: CMRotationMatrix) -> Vector3 { + Vector3(x: matrix.m11 * vector.x + matrix.m21 * vector.y + matrix.m31 * vector.z, + y: matrix.m12 * vector.x + matrix.m22 * vector.y + matrix.m32 * vector.z, + z: matrix.m13 * vector.x + matrix.m23 * vector.y + matrix.m33 * vector.z) + } + + private func deviceVector(forReferenceVector vector: Vector3, rotationMatrix matrix: CMRotationMatrix) -> Vector3 { + Vector3(x: matrix.m11 * vector.x + matrix.m12 * vector.y + matrix.m13 * vector.z, + y: matrix.m21 * vector.x + matrix.m22 * vector.y + matrix.m23 * vector.z, + z: matrix.m31 * vector.x + matrix.m32 * vector.y + matrix.m33 * vector.z) + } + + private func calculateRoll(rotationMatrix matrix: CMRotationMatrix) -> Double { + let zenithDevice = deviceVector(forReferenceVector: Vector3(x: 0, y: 0, z: 1), rotationMatrix: matrix) + let screenZenith = screenVector(forDeviceVector: zenithDevice) + return atan2(screenZenith.x, screenZenith.y) * 180.0 / .pi + } + + private func screenVector(forDeviceVector vector: Vector3) -> (x: Double, y: Double) { + switch ScreenOrientationHelper.sharedInstance.getCurrentInterfaceOrientation() { + case .landscapeLeft: + return (x: -vector.y, y: vector.x) + case .portraitUpsideDown: + return (x: -vector.x, y: -vector.y) + case .landscapeRight: + return (x: vector.y, y: -vector.x) + default: + return (x: vector.x, y: vector.y) + } + } + + private func smoothOrientation(azimuth: Double, altitude: Double) -> (azimuth: Double, altitude: Double) { + let azimuthDelta = AstroUtils.shortestAngleDelta(from: smoothedAzimuth, to: azimuth) + let alphaAz = calculateAdaptiveAlpha(azimuthDelta) + smoothedAzimuth = AstroUtils.normalizedDegrees(smoothedAzimuth + azimuthDelta * alphaAz) + + let altitudeDelta = altitude - smoothedAltitude + let alphaAlt = calculateAdaptiveAlpha(altitudeDelta) + smoothedAltitude += altitudeDelta * alphaAlt + + return (smoothedAzimuth, smoothedAltitude) + } + + private func calculateAdaptiveAlpha(_ delta: Double) -> Double { + let absDelta = abs(delta) + if absDelta < jitterThresh { + return minAlpha + } + if absDelta > moveThresh { + return maxAlpha + } + return minAlpha + (absDelta - jitterThresh) * (maxAlpha - minAlpha) / (moveThresh - jitterThresh) + } +} diff --git a/Sources/Plugins/Astronomy/StarMapButton.swift b/Sources/Plugins/Astronomy/StarMapButton.swift new file mode 100644 index 0000000000..c3b959b0e9 --- /dev/null +++ b/Sources/Plugins/Astronomy/StarMapButton.swift @@ -0,0 +1,88 @@ +// +// StarMapButton.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum StarMapControlTheme { + static func resolved(_ color: UIColor, nightMode: Bool) -> UIColor { + nightMode ? color.dark : color.light + } + + static func defaultBackground(nightMode: Bool, alpha: CGFloat) -> UIColor { + resolved(.mapButtonBgColorDefault, nightMode: nightMode).withAlphaComponent(alpha) + } + + static func activeBackground(alpha: CGFloat = 1) -> UIColor { + UIColor.mapButtonBgColorActive.withAlphaComponent(alpha) + } + + static func foreground(active: Bool, nightMode: Bool) -> UIColor { + active ? .white : resolved(.mapButtonIconColorDefault, nightMode: nightMode) + } + + static func activeForeground(nightMode: Bool) -> UIColor { + resolved(.mapButtonIconColorActive, nightMode: nightMode) + } + + static func textColor(nightMode: Bool) -> UIColor { + resolved(.textColorPrimary, nightMode: nightMode) + } + + static func borderWidth(active: Bool, nightMode: Bool) -> CGFloat { + active ? 0 : (nightMode ? 2 : 0) + } + + static var borderColor: UIColor { + UIColor(rgb: color_on_map_icon_border_color) + } +} + +class StarMapButton: UIButton { + var active = false { + didSet { updateTheme() } + } + var nightMode = false { + didSet { updateTheme() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + imageView?.contentMode = .scaleAspectFit + updateTheme() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + imageView?.contentMode = .scaleAspectFit + updateTheme() + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = min(bounds.width, bounds.height) / 2.0 + } + + func updateTheme() { + tintColor = StarMapControlTheme.foreground(active: active, nightMode: nightMode) + backgroundColor = active + ? StarMapControlTheme.activeBackground() + : StarMapControlTheme.defaultBackground(nightMode: nightMode, alpha: 0.86) + layer.cornerRadius = min(bounds.width, bounds.height) / 2.0 + layer.borderWidth = StarMapControlTheme.borderWidth(active: active, nightMode: nightMode) + layer.borderColor = StarMapControlTheme.borderColor.cgColor + } + + func setColorFilter(_ color: UIColor) { + tintColor = color + } + + func setIcon(iconName: String, accessibilityLabel: String? = nil) { + setImage(AstroIcon.template(iconName), for: .normal) + self.accessibilityLabel = accessibilityLabel + } +} diff --git a/Sources/Plugins/Astronomy/StarMapCameraHelper.swift b/Sources/Plugins/Astronomy/StarMapCameraHelper.swift new file mode 100644 index 0000000000..d5caab912b --- /dev/null +++ b/Sources/Plugins/Astronomy/StarMapCameraHelper.swift @@ -0,0 +1,277 @@ +// +// StarMapCameraHelper.swift +// OsmAnd Maps +// +// Created by Codex on 19.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import AVFoundation +import CoreMedia +import UIKit + +final class StarMapCameraHelper { + private var captureSession: AVCaptureSession? + private var previewLayer: AVCaptureVideoPreviewLayer? + private weak var hostView: UIView? + private weak var siblingView: UIView? + private weak var starView: StarView? + private var requestedTransparency = 64 + private var rawCameraFov = 60.0 + private var cameraAspectRatio = 4.0 / 3.0 + private var lastPreviewBounds = CGRect.null + private var lastVideoOrientation: AVCaptureVideoOrientation? + + var onUnavailable: ((_ message: String) -> Void)? + var onCameraStateChanged: ((_ enabled: Bool) -> Void)? + private(set) var isCameraOverlayEnabled = false + private(set) var calculatedFov = 60.0 + + func bind(starView: StarView) { + self.starView = starView + } + + func onResume() { + if isCameraOverlayEnabled, let hostView, let siblingView { + if !startPreview(in: hostView, below: siblingView) { + disableCameraOverlay(notify: true) + onUnavailable?(localizedString("recording_camera_not_available")) + } else { + starView?.isCameraMode = true + starView?.setViewAngle(calculatedFov) + updateCameraZoom(fov: calculatedFov) + } + } + } + + func onPause() { + closeCamera() + } + + func toggleCameraOverlay(in hostView: UIView? = nil, below siblingView: UIView? = nil) { + if let hostView, let siblingView { + self.hostView = hostView + self.siblingView = siblingView + } + if isCameraOverlayEnabled { + disableCameraOverlay(notify: true) + } else { + requestCameraOverlay() + } + } + + func stopPreview() { + disableCameraOverlay(notify: true) + } + + func layoutPreview() { + let bounds = hostView?.bounds ?? .zero + let orientation = videoOrientation() + let geometryChanged = lastPreviewBounds.size != bounds.size || lastVideoOrientation != orientation + previewLayer?.frame = bounds + updatePreviewOrientation(orientation) + guard isCameraOverlayEnabled else { + lastPreviewBounds = bounds + lastVideoOrientation = orientation + return + } + let currentViewAngle = starView?.getViewAngle() + updateEffectiveFov() + if geometryChanged { + starView?.setViewAngle(calculatedFov) + updateCameraZoom(fov: calculatedFov) + } else if let currentViewAngle { + updateCameraZoom(fov: currentViewAngle) + } + lastPreviewBounds = bounds + lastVideoOrientation = orientation + } + + func setTransparency(progress: Int) { + requestedTransparency = max(0, min(100, progress)) + previewLayer?.opacity = Float(requestedTransparency) / 100.0 + } + + func resetFov() { + starView?.setViewAngle(calculatedFov) + } + + func updateCameraZoom(fov: Double) { + guard isCameraOverlayEnabled, let previewLayer else { + return + } + let baseRad = calculatedFov * .pi / 180.0 / 2.0 + let targetRad = max(1, min(160, fov)) * .pi / 180.0 / 2.0 + let scale = CGFloat(tan(baseRad) / tan(targetRad)) + CATransaction.begin() + CATransaction.setDisableActions(true) + previewLayer.setAffineTransform(CGAffineTransform(scaleX: scale, y: scale)) + CATransaction.commit() + } + + func calculateCameraFov() -> Double { + guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { + return 60.0 + } + return cameraFovInfo(camera).width + } + + private func requestCameraOverlay() { + guard let hostView, let siblingView else { + onUnavailable?(localizedString("recording_camera_not_available")) + return + } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + enableCameraOverlay(in: hostView, below: siblingView) + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + guard let self else { + return + } + if granted, !self.isCameraOverlayEnabled { + self.enableCameraOverlay(in: hostView, below: siblingView) + } else if !granted { + self.onUnavailable?(localizedString("no_camera_permission")) + } + } + } + default: + onUnavailable?(localizedString("no_camera_permission")) + } + } + + private func enableCameraOverlay(in hostView: UIView, below siblingView: UIView) { + guard !isCameraOverlayEnabled else { + return + } + guard startPreview(in: hostView, below: siblingView) else { + onUnavailable?(localizedString("recording_camera_not_available")) + return + } + isCameraOverlayEnabled = true + starView?.isCameraMode = true + starView?.setViewAngle(calculatedFov) + onCameraStateChanged?(true) + } + + private func disableCameraOverlay(notify: Bool) { + let wasEnabled = isCameraOverlayEnabled + isCameraOverlayEnabled = false + closeCamera() + starView?.isCameraMode = false + if notify && wasEnabled { + onCameraStateChanged?(false) + } + } + + private func startPreview(in hostView: UIView, below siblingView: UIView) -> Bool { + self.hostView = hostView + self.siblingView = siblingView + closeCamera() + + do { + guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { + return false + } + let input = try AVCaptureDeviceInput(device: camera) + let session = AVCaptureSession() + session.sessionPreset = .high + guard session.canAddInput(input) else { + return false + } + session.addInput(input) + + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + layer.opacity = Float(requestedTransparency) / 100.0 + layer.frame = hostView.bounds + hostView.layer.insertSublayer(layer, below: siblingView.layer) + + let fovInfo = cameraFovInfo(camera) + rawCameraFov = fovInfo.width + cameraAspectRatio = fovInfo.aspectRatio + captureSession = session + previewLayer = layer + lastPreviewBounds = hostView.bounds + lastVideoOrientation = videoOrientation() + updatePreviewOrientation(lastVideoOrientation) + updateEffectiveFov() + DispatchQueue.global(qos: .userInitiated).async { + session.startRunning() + } + return true + } catch { + return false + } + } + + private func closeCamera() { + captureSession?.stopRunning() + captureSession = nil + previewLayer?.removeFromSuperlayer() + previewLayer = nil + lastPreviewBounds = .null + lastVideoOrientation = nil + } + + private func updateEffectiveFov() { + calculatedFov = calculateEffectiveFov() + } + + private func calculateEffectiveFov() -> Double { + guard let hostView else { + return rawCameraFov + } + let bounds = hostView.bounds + guard bounds.width > 0, bounds.height > 0 else { + return rawCameraFov + } + let isLandscape = bounds.width >= bounds.height + let baseFovForX = isLandscape ? rawCameraFov : cameraVerticalFov() + let imageAspect = CGFloat(isLandscape ? cameraAspectRatio : 1.0 / cameraAspectRatio) + let aspectFillWidth = max(bounds.width, bounds.height * imageAspect) + let scaleX = max(1.0, Double(aspectFillWidth / bounds.width)) + let halfFovRad = baseFovForX * .pi / 180.0 / 2.0 + let effectiveRad = 2.0 * atan(tan(halfFovRad) / scaleX) + return effectiveRad * 180.0 / .pi + } + + private func cameraVerticalFov() -> Double { + let halfFovRad = rawCameraFov * .pi / 180.0 / 2.0 + return 2.0 * atan(tan(halfFovRad) / cameraAspectRatio) * 180.0 / .pi + } + + private func cameraFovInfo(_ camera: AVCaptureDevice) -> (width: Double, aspectRatio: Double) { + let fieldOfView = camera.activeFormat.videoFieldOfView > 0 ? Double(camera.activeFormat.videoFieldOfView) : 60.0 + let dimensions = CMVideoFormatDescriptionGetDimensions(camera.activeFormat.formatDescription) + let width = Double(max(dimensions.width, dimensions.height)) + let height = Double(min(dimensions.width, dimensions.height)) + guard width > 0, height > 0 else { + return (fieldOfView, 4.0 / 3.0) + } + return (fieldOfView, width / height) + } + + private func updatePreviewOrientation(_ orientation: AVCaptureVideoOrientation? = nil) { + guard let connection = previewLayer?.connection, connection.isVideoOrientationSupported else { + return + } + connection.videoOrientation = orientation ?? videoOrientation() + } + + private func videoOrientation() -> AVCaptureVideoOrientation { + switch ScreenOrientationHelper.sharedInstance.getCurrentInterfaceOrientation() { + case .portraitUpsideDown: + return .portraitUpsideDown + case .landscapeLeft: + return .landscapeRight + case .landscapeRight: + return .landscapeLeft + default: + return .portrait + } + } +} diff --git a/Sources/Plugins/Astronomy/StarMapResetButton.swift b/Sources/Plugins/Astronomy/StarMapResetButton.swift new file mode 100644 index 0000000000..0adb1a0aea --- /dev/null +++ b/Sources/Plugins/Astronomy/StarMapResetButton.swift @@ -0,0 +1,17 @@ +// +// StarMapResetButton.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class StarMapResetButton: StarMapButton { + override func updateTheme() { + super.updateTheme() + setImage(.icCustomRefresh, for: .normal) + accessibilityLabel = localizedString("shared_string_reset") + } +} diff --git a/Sources/Plugins/Astronomy/StarMapTimeControlButton.swift b/Sources/Plugins/Astronomy/StarMapTimeControlButton.swift new file mode 100644 index 0000000000..3e2c085098 --- /dev/null +++ b/Sources/Plugins/Astronomy/StarMapTimeControlButton.swift @@ -0,0 +1,20 @@ +// +// StarMapTimeControlButton.swift +// OsmAnd Maps +// +// Created by Codex on 20.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class StarMapTimeControlButton: StarMapButton { + override func updateTheme() { + backgroundColor = .clear + layer.borderWidth = 0 + layer.cornerRadius = 0 + let foregroundColor: UIColor = active ? .white : StarMapControlTheme.activeForeground(nightMode: nightMode) + tintColor = foregroundColor + setTitleColor(foregroundColor, for: .normal) + } +} diff --git a/Sources/Plugins/Astronomy/StarMapViewController.swift b/Sources/Plugins/Astronomy/StarMapViewController.swift new file mode 100644 index 0000000000..5de25b8a46 --- /dev/null +++ b/Sources/Plugins/Astronomy/StarMapViewController.swift @@ -0,0 +1,1423 @@ +// +// StarMapViewController.swift +// OsmAnd Maps +// +// Created by Codex on 19.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import CoreLocation +import OsmAndShared +import UIKit + +final class StarMapViewController: UIViewController, StarViewDelegate { + private enum Layout { + static let contentPadding: CGFloat = 16 + static let buttonSize: CGFloat = 52 + static let smallButtonSize: CGFloat = 40 + static let magnitudeButtonHeight: CGFloat = 76 + static let magnitudeSliderWidth: CGFloat = 240 + static let transparencySliderHeight: CGFloat = 150 + static let regularMapHeight: CGFloat = 300 + static let regularMapHeightLandscape: CGFloat = 110 + static let maxMagnitude: Double = 7.0 + static let leftPanelWidth: CGFloat = 393 + } + private var settings: AstronomyPluginSettings + private let plugin: AstronomyPlugin + private let dataProvider: AstroDataProvider + private let viewModel: StarObjectsViewModel + + private let mainLayout = UIView() + private let starView = StarView() + private let regularMapContainer = UIView() + private let mapControlsContainer = StarMapControlsContainer() + private let timeSelectionView = DateTimeSelectionView() + private let timeControlCard = UIView() + private let timeControlButton = StarMapTimeControlButton() + private let resetTimeButton = StarMapResetButton() + private let arModeButton = StarMapButton() + private let cameraButton = StarMapButton() + private let transparencySlider = UISlider() + private let sliderContainer = UIView() + private let resetFovButton = StarMapButton() + private let magnitudeFilterButton = UIControl() + private let magnitudeFilterIcon = UIImageView() + private let magnitudeFilterText = UILabel() + private let magnitudeSliderCard = UIView() + private let magnitudeSlider = UISlider() + private let magnitudeSliderTitle = UILabel() + private let magnitudeSliderValue = UILabel() + private let closeButton = StarMapButton() + private let settingsButton = StarMapButton() + private let searchButton = StarMapButton() + private let compassButton = StarCompassButton() + + private let arModeHelper = StarMapARModeHelper() + private let cameraHelper = StarMapCameraHelper() + + private var autoTimeUpdateTimer: Timer? + private var isTimeAutoUpdateEnabled = true + private var currentDate = Date() + private var selectedObject: SkyObject? + private var regularMapVisible = false + private var previousAltitude = 45.0 + private var previousAzimuth = 0.0 + private var previousViewAngle = 150.0 + private var manualAzimuth = true + private var lastUpdatedAzimuth = -1.0 + private var objectInfoController: AstroContextMenuViewController? + private var objectInfoNavigationController: UINavigationController? + private var isDismissingObjectInfoSheet = false + private var configureSheetController: AstroConfigureViewBottomSheet? + private var configureSheetNavigationController: UINavigationController? + private var isDismissingConfigureSheet = false + private var searchViewController: StarMapSearchViewController? + private var regularMapHeightConstraint: NSLayoutConstraint? + private var mapLocationObserver: OAAutoObserverProxy? + private var dayNightModeObserver: OAAutoObserverProxy? + private var screenOrientationObserver: NSObjectProtocol? + private var leftPanelLeadingConstraint: NSLayoutConstraint? + private let mapVisibleAreaGuide = UILayoutGuide() + private var mapVisibleAreaLeadingConstraint: NSLayoutConstraint? + + private var mapControlsLeadingInset: CGFloat { + embeddedLeftPanelNavigationController() != nil && UIDevice.current.userInterfaceIdiom == .pad + ? Layout.contentPadding + Layout.leftPanelWidth + Layout.contentPadding + : Layout.contentPadding + } + + private func embeddedLeftPanelNavigationController() -> UINavigationController? { + if let navigationController = configureSheetNavigationController, navigationController.parent === self { + return navigationController + } + if let navigationController = objectInfoNavigationController, navigationController.parent === self { + return navigationController + } + return nil + } + + init(plugin: AstronomyPlugin) { + let loadedSettings = AstronomyPluginSettings.load() + let provider = plugin.dataProvider + self.plugin = plugin + settings = loadedSettings + dataProvider = provider + viewModel = StarObjectsViewModel(provider: provider, settings: loadedSettings) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + mapLocationObserver?.detach() + dayNightModeObserver?.detach() + if let screenOrientationObserver { + NotificationCenter.default.removeObserver(screenOrientationObserver) + } + restoreRegularMapIfNeeded(refresh: false) + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + setupLayout() + setupControls() + setupHelpers() + setupListeners() + applySettings(settings.starMap) + updateRegularMapVisibility(settings.common.showRegularMap) + updateStarMap(updateAzimuth: true) + + viewModel.onDataChanged = { [weak self] in + self?.syncObjectsToStarView() + self?.updateMagnitudeControls() + self?.updateTimeControls() + } + viewModel.load(preferredLocale: OsmAndApp.swiftInstance()?.getLanguageCode()) + setTimeAutoUpdateEnabled(true) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if regularMapVisible { + attachRegularMapIfNeeded() + } + arModeHelper.onResume() + cameraHelper.onResume() + if arModeHelper.isArModeEnabled { + updateArModeUI(true) + } + if isTimeAutoUpdateEnabled { + syncCurrentTimeForAutoUpdate(animate: true) + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopAutoTimeUpdate() + saveStarMapSettings() + arModeHelper.onPause() + cameraHelper.onPause() + starView.isCameraMode = false + restoreRegularMapIfNeeded(refresh: true) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updateRegularMapLayout() + layoutRegularMapRenderer() + cameraHelper.layoutPreview() + } + + private func setupLayout() { + mainLayout.translatesAutoresizingMaskIntoConstraints = false + starView.translatesAutoresizingMaskIntoConstraints = false + regularMapContainer.translatesAutoresizingMaskIntoConstraints = false + regularMapContainer.clipsToBounds = true + mapControlsContainer.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(mainLayout) + mainLayout.addSubview(starView) + mainLayout.addSubview(regularMapContainer) + mainLayout.addSubview(mapControlsContainer) + + let regularMapHeightConstraint = regularMapContainer.heightAnchor.constraint(equalToConstant: 0) + self.regularMapHeightConstraint = regularMapHeightConstraint + + NSLayoutConstraint.activate([ + mainLayout.leadingAnchor.constraint(equalTo: view.leadingAnchor), + mainLayout.trailingAnchor.constraint(equalTo: view.trailingAnchor), + mainLayout.topAnchor.constraint(equalTo: view.topAnchor), + mainLayout.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + starView.leadingAnchor.constraint(equalTo: mainLayout.leadingAnchor), + starView.trailingAnchor.constraint(equalTo: mainLayout.trailingAnchor), + starView.topAnchor.constraint(equalTo: mainLayout.topAnchor), + starView.bottomAnchor.constraint(equalTo: regularMapContainer.topAnchor), + + regularMapContainer.leadingAnchor.constraint(equalTo: mainLayout.leadingAnchor), + regularMapContainer.trailingAnchor.constraint(equalTo: mainLayout.trailingAnchor), + regularMapContainer.bottomAnchor.constraint(equalTo: mainLayout.bottomAnchor), + regularMapHeightConstraint, + + mapControlsContainer.leadingAnchor.constraint(equalTo: mainLayout.leadingAnchor), + mapControlsContainer.trailingAnchor.constraint(equalTo: mainLayout.trailingAnchor), + mapControlsContainer.topAnchor.constraint(equalTo: mainLayout.topAnchor), + mapControlsContainer.bottomAnchor.constraint(equalTo: mainLayout.bottomAnchor) + ]) + + mapControlsContainer.addLayoutGuide(mapVisibleAreaGuide) + let mapVisibleLeading = mapVisibleAreaGuide.leadingAnchor.constraint(equalTo: mapControlsContainer.leadingAnchor) + mapVisibleAreaLeadingConstraint = mapVisibleLeading + NSLayoutConstraint.activate([ + mapVisibleLeading, + mapVisibleAreaGuide.trailingAnchor.constraint(equalTo: mapControlsContainer.trailingAnchor), + mapVisibleAreaGuide.topAnchor.constraint(equalTo: mapControlsContainer.topAnchor), + mapVisibleAreaGuide.bottomAnchor.constraint(equalTo: mapControlsContainer.bottomAnchor) + ]) + } + + private func setupControls() { + setupStarView() + setupCompassAndLeftControls() + setupRightControls() + setupTimeControls() + setupMagnitudeControls() + setupCameraControls() + updateMapControlThemes() + } + + private func setupStarView() { + starView.delegate = self + starView.viewModel = viewModel + starView.settings = settings + } + + private func setupCompassAndLeftControls() { + addRoundButton(compassButton, accessibilityLabel: localizedString("map_widget_compass")) + compassButton.onSingleTap = { [weak self] in self?.setAzimuth(0, animate: true) } + + addRoundButton(arModeButton, iconName: "ic_custom_view_in_ar", accessibilityLabel: localizedString("astro_ar")) + arModeButton.addTarget(self, action: #selector(toggleARMode), for: .touchUpInside) + + addRoundButton(cameraButton, iconName: "ic_custom_device", accessibilityLabel: localizedString("astro_camera")) + cameraButton.addTarget(self, action: #selector(toggleCamera), for: .touchUpInside) + cameraButton.isHidden = true + + addRoundButton(searchButton, iconName: "ic_custom_search", accessibilityLabel: localizedString("shared_string_search")) + searchButton.addTarget(self, action: #selector(showSearchDialog), for: .touchUpInside) + + NSLayoutConstraint.activate([ + compassButton.leadingAnchor.constraint(equalTo: mapVisibleAreaGuide.leadingAnchor, constant: Layout.contentPadding), + compassButton.topAnchor.constraint(equalTo: mapControlsContainer.safeAreaLayoutGuide.topAnchor, constant: Layout.contentPadding), + + arModeButton.centerXAnchor.constraint(equalTo: compassButton.centerXAnchor), + arModeButton.topAnchor.constraint(equalTo: compassButton.bottomAnchor, constant: Layout.contentPadding), + + cameraButton.centerXAnchor.constraint(equalTo: arModeButton.centerXAnchor), + cameraButton.topAnchor.constraint(equalTo: arModeButton.bottomAnchor, constant: Layout.contentPadding), + + searchButton.centerXAnchor.constraint(equalTo: compassButton.centerXAnchor), + searchButton.bottomAnchor.constraint(equalTo: mapControlsContainer.safeAreaLayoutGuide.bottomAnchor, constant: -Layout.contentPadding) + ]) + } + + private func setupRightControls() { + addRoundButton(closeButton, iconName: "ic_navbar_close", accessibilityLabel: localizedString("shared_string_close")) + closeButton.addTarget(self, action: #selector(close), for: .touchUpInside) + + addRoundButton(settingsButton, iconName: "ic_custom_overlay_map", accessibilityLabel: localizedString("shared_string_settings")) + settingsButton.addTarget(self, action: #selector(showConfigureSheet), for: .touchUpInside) + + NSLayoutConstraint.activate([ + closeButton.trailingAnchor.constraint(equalTo: mapControlsContainer.safeAreaLayoutGuide.trailingAnchor, constant: -Layout.contentPadding), + closeButton.topAnchor.constraint(equalTo: mapControlsContainer.safeAreaLayoutGuide.topAnchor, constant: Layout.contentPadding), + + settingsButton.trailingAnchor.constraint(equalTo: mapControlsContainer.safeAreaLayoutGuide.trailingAnchor, constant: -Layout.contentPadding), + settingsButton.bottomAnchor.constraint(equalTo: mapControlsContainer.safeAreaLayoutGuide.bottomAnchor, constant: -Layout.contentPadding) + ]) + } + + private func setupTimeControls() { + let nightMode = OADayNightHelper.instance().isNightMode() + timeControlCard.translatesAutoresizingMaskIntoConstraints = false + timeControlCard.backgroundColor = StarMapControlTheme.defaultBackground(nightMode: nightMode, alpha: 1) + timeControlCard.layer.cornerRadius = Layout.buttonSize / 2 + timeControlCard.layer.shadowColor = UIColor.black.cgColor + timeControlCard.layer.shadowOpacity = 0.16 + timeControlCard.layer.shadowRadius = 6 + timeControlCard.layer.shadowOffset = CGSize(width: 0, height: 2) + mapControlsContainer.addSubview(timeControlCard) + + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 4 + stack.layoutMargins = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: 6) + stack.isLayoutMarginsRelativeArrangement = true + stack.translatesAutoresizingMaskIntoConstraints = false + timeControlCard.addSubview(stack) + + timeControlButton.setImage(AstroIcon.template("ic_action_time"), for: .normal) + timeControlButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .bold) + timeControlButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + timeControlButton.addTarget(self, action: #selector(toggleTimeSelection), for: .touchUpInside) + stack.addArrangedSubview(timeControlButton) + + resetTimeButton.addTarget(self, action: #selector(resetTimeButtonPressed), for: .touchUpInside) + resetTimeButton.isHidden = true + resetTimeButton.widthAnchor.constraint(equalToConstant: Layout.smallButtonSize).isActive = true + resetTimeButton.heightAnchor.constraint(equalToConstant: Layout.smallButtonSize).isActive = true + stack.addArrangedSubview(resetTimeButton) + + timeSelectionView.translatesAutoresizingMaskIntoConstraints = false + timeSelectionView.isHidden = true + mapControlsContainer.addSubview(timeSelectionView) + + NSLayoutConstraint.activate([ + timeControlCard.centerXAnchor.constraint(equalTo: mapVisibleAreaGuide.centerXAnchor), + timeControlCard.bottomAnchor.constraint(equalTo: mapControlsContainer.safeAreaLayoutGuide.bottomAnchor, constant: -Layout.contentPadding), + timeControlCard.heightAnchor.constraint(equalToConstant: Layout.buttonSize), + + stack.leadingAnchor.constraint(equalTo: timeControlCard.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: timeControlCard.trailingAnchor), + stack.topAnchor.constraint(equalTo: timeControlCard.topAnchor), + stack.bottomAnchor.constraint(equalTo: timeControlCard.bottomAnchor), + + timeSelectionView.centerXAnchor.constraint(equalTo: mapVisibleAreaGuide.centerXAnchor), + timeSelectionView.bottomAnchor.constraint(equalTo: timeControlCard.topAnchor, constant: -Layout.contentPadding) + ]) + } + + private func setupMagnitudeControls() { + let nightMode = OADayNightHelper.instance().isNightMode() + magnitudeFilterButton.translatesAutoresizingMaskIntoConstraints = false + magnitudeFilterButton.backgroundColor = StarMapControlTheme.defaultBackground(nightMode: nightMode, alpha: 0.92) + magnitudeFilterButton.layer.cornerRadius = Layout.buttonSize / 2 + magnitudeFilterButton.layer.shadowColor = UIColor.black.cgColor + magnitudeFilterButton.layer.shadowOpacity = 0.16 + magnitudeFilterButton.layer.shadowRadius = 6 + magnitudeFilterButton.layer.shadowOffset = CGSize(width: 0, height: 2) + magnitudeFilterButton.addAction(UIAction { [weak self] _ in + self?.toggleMagnitudeSlider() + }, for: .touchUpInside) + mapControlsContainer.addSubview(magnitudeFilterButton) + + let filterStack = UIStackView() + filterStack.axis = .vertical + filterStack.alignment = .center + filterStack.spacing = 4 + filterStack.isUserInteractionEnabled = false + filterStack.translatesAutoresizingMaskIntoConstraints = false + magnitudeFilterButton.addSubview(filterStack) + + magnitudeFilterIcon.image = .icCustomMagnitude + magnitudeFilterIcon.tintColor = StarMapControlTheme.foreground(active: false, nightMode: nightMode) + magnitudeFilterIcon.contentMode = .scaleAspectFit + magnitudeFilterIcon.widthAnchor.constraint(equalToConstant: 24).isActive = true + magnitudeFilterIcon.heightAnchor.constraint(equalToConstant: 24).isActive = true + filterStack.addArrangedSubview(magnitudeFilterIcon) + + magnitudeFilterText.textColor = StarMapControlTheme.foreground(active: false, nightMode: nightMode) + magnitudeFilterText.font = UIFont.systemFont(ofSize: 15, weight: .bold) + filterStack.addArrangedSubview(magnitudeFilterText) + + magnitudeSliderCard.translatesAutoresizingMaskIntoConstraints = false + magnitudeSliderCard.backgroundColor = StarMapControlTheme.defaultBackground(nightMode: nightMode, alpha: 0.94) + magnitudeSliderCard.layer.cornerRadius = Layout.contentPadding + magnitudeSliderCard.layer.shadowColor = UIColor.black.cgColor + magnitudeSliderCard.layer.shadowOpacity = 0.16 + magnitudeSliderCard.layer.shadowRadius = 6 + magnitudeSliderCard.layer.shadowOffset = CGSize(width: 0, height: 2) + magnitudeSliderCard.isHidden = true + mapControlsContainer.addSubview(magnitudeSliderCard) + + let sliderStack = UIStackView() + sliderStack.axis = .vertical + sliderStack.spacing = 6 + sliderStack.layoutMargins = UIEdgeInsets(top: 8, left: 10, bottom: 8, right: 10) + sliderStack.isLayoutMarginsRelativeArrangement = true + sliderStack.translatesAutoresizingMaskIntoConstraints = false + magnitudeSliderCard.addSubview(sliderStack) + + let sliderHeader = UIStackView() + sliderHeader.axis = .horizontal + sliderHeader.alignment = .center + sliderHeader.spacing = 8 + magnitudeSliderTitle.text = localizedString("astro_min_magnitude") + magnitudeSliderTitle.textColor = StarMapControlTheme.textColor(nightMode: nightMode) + magnitudeSliderTitle.font = UIFont.systemFont(ofSize: 14) + sliderHeader.addArrangedSubview(magnitudeSliderTitle) + sliderHeader.addArrangedSubview(UIView()) + magnitudeSliderValue.textColor = StarMapControlTheme.activeBackground() + magnitudeSliderValue.font = UIFont.systemFont(ofSize: 14, weight: .semibold) + sliderHeader.addArrangedSubview(magnitudeSliderValue) + sliderStack.addArrangedSubview(sliderHeader) + + magnitudeSlider.minimumValue = -1 + magnitudeSlider.maximumValue = Float(Layout.maxMagnitude) + magnitudeSlider.addTarget(self, action: #selector(magnitudeChanged), for: .valueChanged) + sliderStack.addArrangedSubview(magnitudeSlider) + + NSLayoutConstraint.activate([ + magnitudeFilterButton.widthAnchor.constraint(equalToConstant: Layout.buttonSize), + magnitudeFilterButton.heightAnchor.constraint(equalToConstant: Layout.magnitudeButtonHeight), + magnitudeFilterButton.centerXAnchor.constraint(equalTo: settingsButton.centerXAnchor), + magnitudeFilterButton.bottomAnchor.constraint(equalTo: settingsButton.topAnchor, constant: -Layout.contentPadding), + + filterStack.centerXAnchor.constraint(equalTo: magnitudeFilterButton.centerXAnchor), + filterStack.centerYAnchor.constraint(equalTo: magnitudeFilterButton.centerYAnchor), + + magnitudeSliderCard.widthAnchor.constraint(equalToConstant: Layout.magnitudeSliderWidth), + magnitudeSliderCard.heightAnchor.constraint(equalTo: magnitudeFilterButton.heightAnchor), + magnitudeSliderCard.trailingAnchor.constraint(equalTo: magnitudeFilterButton.leadingAnchor, constant: -Layout.contentPadding), + magnitudeSliderCard.topAnchor.constraint(equalTo: magnitudeFilterButton.topAnchor), + + sliderStack.leadingAnchor.constraint(equalTo: magnitudeSliderCard.leadingAnchor), + sliderStack.trailingAnchor.constraint(equalTo: magnitudeSliderCard.trailingAnchor), + sliderStack.topAnchor.constraint(equalTo: magnitudeSliderCard.topAnchor), + sliderStack.bottomAnchor.constraint(equalTo: magnitudeSliderCard.bottomAnchor) + ]) + } + + private func setupCameraControls() { + sliderContainer.translatesAutoresizingMaskIntoConstraints = false + sliderContainer.isHidden = true + mapControlsContainer.addSubview(sliderContainer) + + transparencySlider.minimumValue = 0 + transparencySlider.maximumValue = 100 + transparencySlider.value = 50 + transparencySlider.transform = CGAffineTransform(rotationAngle: -.pi / 2) + transparencySlider.translatesAutoresizingMaskIntoConstraints = false + transparencySlider.addTarget(self, action: #selector(transparencyChanged), for: .valueChanged) + sliderContainer.addSubview(transparencySlider) + + addRoundButton(resetFovButton, iconName: "ic_custom_reset", accessibilityLabel: localizedString("shared_string_reset")) + resetFovButton.addTarget(self, action: #selector(resetFov), for: .touchUpInside) + resetFovButton.isHidden = true + + let cameraSliderTopConstraint = sliderContainer.topAnchor.constraint(equalTo: cameraButton.bottomAnchor, constant: Layout.contentPadding) + cameraSliderTopConstraint.priority = .defaultHigh + + NSLayoutConstraint.activate([ + sliderContainer.widthAnchor.constraint(equalToConstant: Layout.buttonSize), + sliderContainer.heightAnchor.constraint(equalToConstant: Layout.transparencySliderHeight), + sliderContainer.centerXAnchor.constraint(equalTo: cameraButton.centerXAnchor), + cameraSliderTopConstraint, + sliderContainer.topAnchor.constraint(greaterThanOrEqualTo: mapControlsContainer.safeAreaLayoutGuide.topAnchor, constant: Layout.contentPadding), + + transparencySlider.centerXAnchor.constraint(equalTo: sliderContainer.centerXAnchor), + transparencySlider.centerYAnchor.constraint(equalTo: sliderContainer.centerYAnchor), + transparencySlider.widthAnchor.constraint(equalToConstant: Layout.transparencySliderHeight), + transparencySlider.heightAnchor.constraint(equalToConstant: 40), + + resetFovButton.centerXAnchor.constraint(equalTo: sliderContainer.centerXAnchor), + resetFovButton.topAnchor.constraint(equalTo: sliderContainer.bottomAnchor, constant: 8), + resetFovButton.bottomAnchor.constraint(lessThanOrEqualTo: mapControlsContainer.safeAreaLayoutGuide.bottomAnchor, constant: -Layout.contentPadding) + ]) + } + + private func addRoundButton(_ button: StarMapButton, iconName: String? = nil, accessibilityLabel: String) { + button.translatesAutoresizingMaskIntoConstraints = false + if let iconName { + button.setIcon(iconName: iconName, accessibilityLabel: accessibilityLabel) + } else { + button.accessibilityLabel = accessibilityLabel + } + mapControlsContainer.addSubview(button) + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: Layout.buttonSize), + button.heightAnchor.constraint(equalToConstant: Layout.buttonSize) + ]) + } + + private func setupHelpers() { + cameraHelper.bind(starView: starView) + arModeHelper.onOrientationChanged = { [weak self] azimuth, altitude, roll in + self?.starView.setCameraOrientation(azimuth: azimuth, altitude: altitude, roll: roll) + } + arModeHelper.onArModeChanged = { [weak self] enabled in + guard let self else { + return + } + updateArModeUI(enabled) + if !enabled { + manualAzimuth = true + } + } + arModeHelper.onUnavailable = { [weak self] in + self?.showMessage(localizedString("astro_ar_unavailable")) + } + cameraHelper.onUnavailable = { [weak self] message in + self?.updateCameraUI(false) + self?.showMessage(message) + } + cameraHelper.onCameraStateChanged = { [weak self] enabled in + guard let self else { + return + } + updateCameraUI(enabled) + if enabled && !arModeHelper.isArModeEnabled { + arModeHelper.toggleArMode(enable: true) + } + } + } + + private func setupListeners() { + timeSelectionView.setOnDateTimeChangeListener { [weak self] date in + self?.setTimeAutoUpdateEnabled(false) + self?.updateTime(date, animate: true) + } + starView.setOnObjectClickListener { [weak self] object in + self?.selectedObject = object + if let object { + self?.showObjectInfo(object) + } else if self?.starView.getSelectedConstellationItem() == nil { + self?.hideBottomSheet() + } + } + starView.setOnConstellationClickListener { [weak self] constellation in + if let constellation { + self?.selectedObject = constellation + self?.showObjectInfo(constellation) + } else if self?.selectedObject == nil { + self?.hideBottomSheet() + } + } + starView.onAzimuthManualChangeListener = { [weak self] azimuth in + guard let self, !cameraHelper.isCameraOverlayEnabled else { + return + } + if arModeHelper.isArModeEnabled { + arModeHelper.toggleArMode(enable: false) + } + manualAzimuth = true + compassButton.update(mapRotation: CGFloat(-azimuth)) + } + starView.onViewAngleChangeListener = { [weak self] fov in + self?.cameraHelper.updateCameraZoom(fov: fov) + } + + if let mapObservable = currentMapViewController()?.mapObservable { + mapLocationObserver = OAAutoObserverProxy(self, + withHandler: #selector(onMapLocationChanged), + andObserve: mapObservable) + } + dayNightModeObserver = OAAutoObserverProxy(self, + withHandler: #selector(onDayNightModeChanged), + andObserve: OsmAndApp.swiftInstance().dayNightModeObservable) + screenOrientationObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name(ScreenOrientationHelper.screenOrientationChangedKey), + object: nil, + queue: .main) { [weak self] _ in + self?.cameraHelper.layoutPreview() + } + } + + private func syncObjectsToStarView() { + starView.setSkyObjects(viewModel.skyObjects) + starView.setConstellations(viewModel.constellations) + starView.setNeedsDisplay() + } + + private func applySettings(_ config: AstronomyPluginSettings.StarMapConfig) { + let shouldApply2DMode = starView.is2DMode != config.is2DMode + starView.showAzimuthalGrid = config.showAzimuthalGrid + starView.showEquatorialGrid = config.showEquatorialGrid + starView.showEclipticLine = config.showEclipticLine + starView.showMeridianLine = config.showMeridianLine + starView.showEquatorLine = config.showEquatorLine + starView.showGalacticLine = config.showGalacticLine + starView.showFavorites = config.showFavorites + starView.showDirections = config.showDirections + starView.showCelestialPaths = config.showCelestialPaths + starView.showRedFilter = config.showRedFilter + starView.showConstellations = config.showConstellations + starView.showStars = config.showStars + starView.showGalaxies = config.showGalaxies + starView.showBlackHoles = config.showBlackHoles + starView.showNebulae = config.showNebulae + starView.showOpenClusters = config.showOpenClusters + starView.showGlobularClusters = config.showGlobularClusters + starView.showGalaxyClusters = config.showGalaxyClusters + starView.showSun = config.showSun + starView.showMoon = config.showMoon + starView.showPlanets = config.showPlanets + starView.showMagnitudeFilter = config.showMagnitudeFilter || config.magnitudeFilter != nil + starView.magnitudeFilter = config.magnitudeFilter + if shouldApply2DMode { + apply2DMode(config.is2DMode) + } + updateRedMode(config.showRedFilter) + updateMagnitudeControls() + } + + private func saveCommonSettings() { + settings.setCommonConfig(AstronomyPluginSettings.CommonConfig(showRegularMap: regularMapVisible)) + } + + private func saveStarMapSettings() { + settings.updateStarMapConfig { current in + var config = current + config.showAzimuthalGrid = starView.showAzimuthalGrid + config.showEquatorialGrid = starView.showEquatorialGrid + config.showEclipticLine = starView.showEclipticLine + config.showMeridianLine = starView.showMeridianLine + config.showEquatorLine = starView.showEquatorLine + config.showGalacticLine = starView.showGalacticLine + config.showFavorites = starView.showFavorites + config.showDirections = starView.showDirections + config.showCelestialPaths = starView.showCelestialPaths + config.showRedFilter = starView.showRedFilter + config.showSun = starView.showSun + config.showMoon = starView.showMoon + config.showPlanets = starView.showPlanets + config.showConstellations = starView.showConstellations + config.showStars = starView.showStars + config.showGalaxies = starView.showGalaxies + config.showBlackHoles = starView.showBlackHoles + config.showNebulae = starView.showNebulae + config.showOpenClusters = starView.showOpenClusters + config.showGlobularClusters = starView.showGlobularClusters + config.showGalaxyClusters = starView.showGalaxyClusters + config.is2DMode = starView.is2DMode + config.showMagnitudeFilter = starView.showMagnitudeFilter || starView.magnitudeFilter != nil + config.magnitudeFilter = starView.magnitudeFilter + return config + } + viewModel.updateSettings(settings) + } + + private func setStarMapSettings(_ config: AstronomyPluginSettings.StarMapConfig) { + let updatedConfig = settings.updateStarMapConfig { current in + var updated = config + updated.favorites = current.favorites + updated.directions = current.directions + updated.celestialPaths = current.celestialPaths + return updated + } + applySettings(updatedConfig) + viewModel.updateSettings(settings) + } + + private func updateRegularMapVisibility(_ visible: Bool) { + regularMapVisible = visible + starView.settings.common.showRegularMap = visible + updateRegularMapLayout() + if visible { + attachRegularMapIfNeeded() + } else { + restoreRegularMapIfNeeded(refresh: true) + } + starView.setNeedsDisplay() + } + + func applyRedFilter(enabled: Bool) { + starView.showRedFilter = enabled + updateRedMode(enabled) + saveStarMapSettings() + } + + func setRegularMapVisibility(enabled: Bool) { + updateRegularMapVisibility(enabled) + saveCommonSettings() + } + + private func apply2DMode(_ is2D: Bool) { + if is2D { + previousAltitude = starView.getAltitude() + previousAzimuth = starView.getAzimuth() + previousViewAngle = starView.getViewAngle() + starView.is2DMode = true + starView.setCenter(azimuth: 180, altitude: 90) + if cameraHelper.isCameraOverlayEnabled { + cameraHelper.toggleCameraOverlay() + } + cameraButton.isHidden = true + if arModeHelper.isArModeEnabled { + arModeHelper.toggleArMode(enable: false) + } + arModeButton.isHidden = true + manualAzimuth = true + } else { + starView.is2DMode = false + starView.setCenter(azimuth: previousAzimuth, altitude: previousAltitude) + starView.setViewAngle(previousViewAngle) + arModeButton.isHidden = false + cameraButton.isHidden = !arModeHelper.isArModeEnabled + } + starView.setNeedsDisplay() + } + + private func updateTime(_ date: Date, animate: Bool) { + currentDate = date + timeSelectionView.setDateTime(date) + starView.setDateTime(AstroUtils.astronomyTime(from: date), animate: animate) + updateTimeControls() + objectInfoController?.onTimeChanged() + } + + private func syncCurrentTimeForAutoUpdate(animate: Bool) { + guard isTimeAutoUpdateEnabled else { + return + } + updateTime(Date(), animate: animate) + scheduleAutoTimeUpdate() + } + + private func setTimeAutoUpdateEnabled(_ enabled: Bool) { + isTimeAutoUpdateEnabled = enabled + resetTimeButton.isHidden = enabled + if enabled { + syncCurrentTimeForAutoUpdate(animate: true) + } else { + stopAutoTimeUpdate() + } + updateTimeControlTheme() + } + + private func scheduleAutoTimeUpdate() { + stopAutoTimeUpdate() + guard isTimeAutoUpdateEnabled else { + return + } + let now = Date() + let nextMinute = Calendar.current.dateInterval(of: .minute, for: now)?.end ?? now.addingTimeInterval(60) + let delay = max(0.1, nextMinute.timeIntervalSince(now)) + let timer = Timer(timeInterval: delay, repeats: false) { [weak self] _ in + self?.syncCurrentTimeForAutoUpdate(animate: true) + } + autoTimeUpdateTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + private func stopAutoTimeUpdate() { + autoTimeUpdateTimer?.invalidate() + autoTimeUpdateTimer = nil + } + + private func updateTimeControls() { + let date = currentDate + timeSelectionView.setDateTime(date) + let calendar = Calendar.current + let now = Date() + let formatter = DateFormatter() + if calendar.isDate(date, inSameDayAs: now) { + formatter.dateFormat = "HH:mm" + } else { + formatter.dateStyle = .short + formatter.timeStyle = .short + } + timeControlButton.setTitle(formatter.string(from: date), for: .normal) + + updateTimeControlTheme() + } + + private func updateTimeControlTheme() { + let nightMode = OADayNightHelper.instance().isNightMode() + let active = !timeSelectionView.isHidden + timeControlCard.backgroundColor = active + ? StarMapControlTheme.activeBackground() + : StarMapControlTheme.defaultBackground(nightMode: nightMode, alpha: 1) + timeControlButton.nightMode = nightMode + resetTimeButton.nightMode = nightMode + timeControlButton.active = active + resetTimeButton.active = active + } + + private func updateMagnitudeControls() { + let filterToUse = min(starView.magnitudeFilter ?? Layout.maxMagnitude, Layout.maxMagnitude) + if starView.magnitudeFilter == nil || (starView.magnitudeFilter ?? 0) > Layout.maxMagnitude { + starView.magnitudeFilter = Layout.maxMagnitude + } + magnitudeSlider.value = Float(filterToUse) + let text = String(format: "%.1f", filterToUse) + magnitudeFilterText.text = text + magnitudeSliderValue.text = text + updateMagnitudeFilterTheme() + } + + private func updateMagnitudeFilterTheme() { + let nightMode = OADayNightHelper.instance().isNightMode() + let active = !magnitudeSliderCard.isHidden + magnitudeFilterButton.backgroundColor = active + ? StarMapControlTheme.activeBackground() + : StarMapControlTheme.defaultBackground(nightMode: nightMode, alpha: 0.92) + magnitudeFilterIcon.tintColor = StarMapControlTheme.foreground(active: active, nightMode: nightMode) + magnitudeFilterText.textColor = StarMapControlTheme.foreground(active: active, nightMode: nightMode) + magnitudeSliderCard.backgroundColor = StarMapControlTheme.defaultBackground(nightMode: nightMode, alpha: 0.94) + magnitudeSliderTitle.textColor = StarMapControlTheme.textColor(nightMode: nightMode) + magnitudeSliderValue.textColor = StarMapControlTheme.activeBackground() + } + + private func updateArModeUI(_ enabled: Bool) { + arModeButton.active = enabled + cameraButton.isHidden = !enabled || starView.is2DMode + if enabled { + starView.isCameraMode = true + starView.setNeedsDisplay() + } else { + starView.roll = 0 + starView.setNeedsDisplay() + if cameraHelper.isCameraOverlayEnabled { + cameraHelper.toggleCameraOverlay() + } + updateCameraUI(false) + } + } + + private func updateCameraUI(_ enabled: Bool) { + cameraButton.active = enabled + sliderContainer.isHidden = !enabled + resetFovButton.isHidden = !enabled + starView.isCameraMode = enabled || arModeHelper.isRunning + } + + private func updateMapControlThemes() { + updateButtonsNightMode() + updateTimeControlTheme() + updateMagnitudeFilterTheme() + } + + private func updateButtonsNightMode() { + let nightMode = OADayNightHelper.instance().isNightMode() + arModeButton.nightMode = nightMode + cameraButton.nightMode = nightMode + resetFovButton.nightMode = nightMode + closeButton.nightMode = nightMode + settingsButton.nightMode = nightMode + searchButton.nightMode = nightMode + compassButton.nightMode = nightMode + resetTimeButton.nightMode = nightMode + timeControlButton.nightMode = nightMode + } + + private func updateRedMode(_ enabled: Bool) { + starView.showRedFilter = enabled + AstroRedFilter.apply(enabled, + to: timeControlCard, + timeSelectionView, + arModeButton, + cameraButton, + resetFovButton, + magnitudeFilterButton, + magnitudeSliderCard, + compassButton, + searchButton, + closeButton, + settingsButton, + sliderContainer) + objectInfoController?.applyRedFilter(enabled: enabled) + configureSheetController?.applyRedFilter(enabled: enabled) + } + + private func regularMapHeight() -> CGFloat { + view.bounds.width > view.bounds.height ? Layout.regularMapHeightLandscape : Layout.regularMapHeight + } + + private func updateRegularMapLayout() { + let height = regularMapVisible ? regularMapHeight() : 0 + if regularMapHeightConstraint?.constant != height { + regularMapHeightConstraint?.constant = height + } + if additionalSafeAreaInsets.bottom != height { + additionalSafeAreaInsets.bottom = height + } + } + + private func attachRegularMapIfNeeded() { + guard regularMapVisible, + let mapPanel = OARootViewController.instance()?.mapPanel else { + return + } + let mapViewController = mapPanel.mapViewController + updateRegularMapLayout() + view.layoutIfNeeded() + mapViewController.setSingleTapContextMenuGestureEnabled(false) + if mapViewController.parent !== self { + mapPanel.doMapReuse(self, destinationView: regularMapContainer) + } + layoutRegularMapRenderer() + mapPanel.refreshMap(true) + } + + private func restoreRegularMapIfNeeded(refresh: Bool) { + guard let mapPanel = OARootViewController.instance()?.mapPanel else { + return + } + mapPanel.mapViewController.setSingleTapContextMenuGestureEnabled(true) + mapPanel.restoreMapAfterReuseIfNeeded() + if refresh { + mapPanel.refreshMap(true) + } + } + + private func layoutRegularMapRenderer() { + guard regularMapVisible, + let mapView = currentMapViewController()?.view, + mapView.superview === regularMapContainer else { + return + } + mapView.frame = regularMapContainer.bounds + mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + } + + private func currentMapViewController() -> OAMapViewController? { + OARootViewController.instance()?.mapPanel?.mapViewController + } + + private func currentMapCenterLocation() -> CLLocation? { + currentMapViewController()?.getMapLocation() + } + + @objc private func onMapLocationChanged() { + DispatchQueue.main.async { [weak self] in + self?.updateStarMap() + } + } + + @objc private func onDayNightModeChanged() { + DispatchQueue.main.async { [weak self] in + self?.updateMapControlThemes() + } + } + + private func updateStarMap(updateAzimuth: Bool = false) { + let deviceLocation = OsmAndApp.swiftInstance()?.locationServices?.lastKnownLocation + if let deviceLocation { + arModeHelper.updateGeomagneticField(location: deviceLocation) + } + + let mapLocation = currentMapCenterLocation() + let observerLocation = mapLocation ?? deviceLocation + let coordinate = observerLocation?.coordinate ?? CLLocationCoordinate2D(latitude: 0, longitude: 0) + let altitude = mapLocation == nil ? observerLocation?.altitude ?? 0 : 0 + starView.setObserverLocation(lat: coordinate.latitude, lon: coordinate.longitude, alt: altitude) + if selectedObject != nil { + updateTimeControls() + } + objectInfoController?.onLocationChanged() + if updateAzimuth && !arModeHelper.isArModeEnabled && !starView.is2DMode { + setAzimuth(lastUpdatedAzimuth >= 0 ? lastUpdatedAzimuth : 0) + } + } + + private func setAzimuth(_ azimuth: Double, animate: Bool = false) { + starView.setAzimuth(azimuth, animate: animate) + compassButton.update(mapRotation: CGFloat(-azimuth), animated: animate) + lastUpdatedAzimuth = azimuth + } + + private func updateMapControlsVisibility() { + let isPhoneSheet = UIDevice.current.userInterfaceIdiom == .phone + && (objectInfoController != nil || configureSheetController != nil) + mapControlsContainer.isHidden = isPhoneSheet + mapVisibleAreaLeadingConstraint?.constant = mapControlsLeadingInset + } + + private func clearSelectedObject() { + selectedObject = nil + starView.setSelectedObject(nil) + starView.setSelectedConstellation(nil) + starView.setNeedsDisplay() + } + + func getTrackableObjects() -> [SkyObject] { + viewModel.skyObjects + viewModel.constellations.map { $0 as SkyObject } + } + + func findTrackableObjectById(_ id: String) -> SkyObject? { + getTrackableObjects().first { $0.id == id } + } + + private func hideBottomSheet() { + hideBottomSheet(clearSelection: true) + } + + private func hideBottomSheet(clearSelection: Bool) { + dismissObjectInfoSheet(clearSelection: clearSelection, animated: false) + dismissConfigureSheet(animated: false) + if clearSelection { + clearSelectedObject() + } + updateMapControlsVisibility() + } + + private func dismissObjectInfoSheet(clearSelection: Bool, animated: Bool) { + guard let navigationController = objectInfoNavigationController else { + if objectInfoController != nil { + finishObjectInfoDismiss(clearSelection: clearSelection) + } + return + } + if navigationController.parent === self { + isDismissingObjectInfoSheet = true + dismissLeftPanel(navigationController: navigationController, animated: animated) { [weak self] in + guard let self else { return } + isDismissingObjectInfoSheet = false + finishObjectInfoDismiss(clearSelection: clearSelection) + } + return + } + isDismissingObjectInfoSheet = true + navigationController.dismiss(animated: animated) { [weak self] in + guard let self else { return } + isDismissingObjectInfoSheet = false + finishObjectInfoDismiss(clearSelection: clearSelection) + } + } + + private func finishObjectInfoDismiss(clearSelection: Bool) { + objectInfoController = nil + objectInfoNavigationController = nil + if clearSelection { + clearSelectedObject() + } + updateMapControlsVisibility() + } + + private func dismissConfigureSheet(animated: Bool) { + guard let navigationController = configureSheetNavigationController else { + if configureSheetController != nil { + finishConfigureSheetDismiss() + } + return + } + if navigationController.parent === self { + isDismissingConfigureSheet = true + dismissLeftPanel(navigationController: navigationController, animated: animated) { [weak self] in + guard let self else { return } + isDismissingConfigureSheet = false + finishConfigureSheetDismiss() + } + return + } + isDismissingConfigureSheet = true + navigationController.dismiss(animated: animated) { [weak self] in + guard let self else { return } + isDismissingConfigureSheet = false + finishConfigureSheetDismiss() + } + } + + private func finishConfigureSheetDismiss() { + configureSheetController = nil + configureSheetNavigationController = nil + updateMapControlsVisibility() + } + + private func showObjectInfo(_ object: SkyObject, centerInVisibleMapOnPresentation: Bool = false) { + if let objectInfoController { + objectInfoController.updateObjectInfo(object) + objectInfoNavigationController?.sheetPresentationController?.animateChanges { [weak self] in + self?.objectInfoNavigationController?.sheetPresentationController?.selectedDetentIdentifier = .medium + } + updateMapControlsVisibility() + if centerInVisibleMapOnPresentation { + DispatchQueue.main.async { [weak self] in + self?.centerObjectInVisibleStarMap(object, animate: true) + } + } + return + } + hideBottomSheet(clearSelection: false) + + let dependencies = AstroContextMenuDependencies( + currentDate: { [weak self] in self?.currentDate ?? Date() }, + observer: { [weak self] in self?.starView.observer ?? AstroUtils.observer(from: nil) }, + dataProvider: dataProvider, + preferredLocale: { OsmAndApp.swiftInstance()?.getLanguageCode() }, + trackableObjects: { [weak self] in self?.getTrackableObjects() ?? [] }, + constellations: { [weak self] in self?.viewModel.constellations ?? [] }, + onClose: { [weak self] in self?.dismissObjectInfoSheet(clearSelection: true, animated: true) }, + onDismissed: { [weak self] in + guard let self, !isDismissingObjectInfoSheet else { + return + } + finishObjectInfoDismiss(clearSelection: true) + }, + onCenterObject: { [weak self] object in + self?.centerObjectInVisibleStarMap(object, animate: true) + }, + onFavoriteChanged: { [weak self] object, enabled in + guard let self else { return } + if enabled { + self.settings.addFavorite(id: object.id) + } else { + self.settings.removeFavorite(id: object.id) + } + self.viewModel.updateSettings(self.settings) + self.starView.refreshObjects() + }, + onDirectionChanged: { [weak self] object, enabled in + guard let self else { return object.colorIndex } + let colorIndex: Int + if enabled { + colorIndex = self.settings.addDirection(id: object.id) + } else { + self.settings.removeDirection(id: object.id) + colorIndex = object.colorIndex + } + self.viewModel.updateSettings(self.settings) + self.starView.refreshObjects() + return colorIndex + }, + onCelestialPathChanged: { [weak self] object, enabled in + guard let self else { return } + if enabled { + self.settings.addCelestialPath(id: object.id) + } else { + self.settings.removeCelestialPath(id: object.id) + } + self.viewModel.updateSettings(self.settings) + self.starView.refreshObjects() + }, + onSetObjectPinned: { [weak self] object, pinned, forceUpdate in + self?.starView.setObjectPinned(object, pinned: pinned, forceUpdate: forceUpdate) + }, + onRefreshObjects: { [weak self] in + guard let self else { return } + self.viewModel.updateSettings(self.settings) + self.starView.refreshObjects() + }, + onCatalogClick: { [weak self] catalog in + self?.showSearchDialog(initialCatalogWid: catalog.wid) + } + ) + let controller = AstroContextMenuViewController(object: object, dependencies: dependencies) + controller.applyRedFilter(enabled: starView.showRedFilter) + let navigationController = UINavigationController(rootViewController: controller) + navigationController.modalPresentationStyle = .pageSheet + navigationController.navigationBar.prefersLargeTitles = false + if let sheet = navigationController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.selectedDetentIdentifier = .medium + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = true + sheet.preferredCornerRadius = 24 + sheet.largestUndimmedDetentIdentifier = .medium + } + objectInfoController = controller + objectInfoNavigationController = navigationController + present(navigationController, animated: true) { [weak self] in + guard centerInVisibleMapOnPresentation else { + return + } + self?.centerObjectInVisibleStarMap(object, animate: true) + } + updateMapControlsVisibility() + } + + private func visibleStarMapTargetPoint() -> CGPoint { + view.layoutIfNeeded() + let starFrame = starView.convert(starView.bounds, to: view) + var visibleFrame = starFrame + if let sheetView = objectInfoNavigationController?.view, !sheetView.isHidden { + let sheetFrame = sheetView.convert(sheetView.bounds, to: view) + let visibleBottom = min(visibleFrame.maxY, max(visibleFrame.minY, sheetFrame.minY)) + visibleFrame.size.height = max(0, visibleBottom - visibleFrame.minY) + } + guard visibleFrame.width > 0, visibleFrame.height > 0 else { + return CGPoint(x: starView.bounds.midX, y: starView.bounds.midY) + } + let targetInView = CGPoint(x: visibleFrame.midX, y: visibleFrame.midY) + return view.convert(targetInView, to: starView) + } + + private func centerObjectInVisibleStarMap(_ object: SkyObject, animate: Bool) { + starView.setSelectedObject(object, centerAt: visibleStarMapTargetPoint(), animate: animate) + } + + private func showMessage(_ message: String) { + let alert = UIAlertController(title: localizedString("astronomy_plugin_name"), message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: localizedString("shared_string_ok"), style: .default)) + present(alert, animated: true) + } + + func starView(_ starView: StarView, didSelect object: SkyObject?) { + selectedObject = object + updateTimeControls() + } + + private func handleSearchObjectSelected(_ obj: SkyObject) { + manualAzimuth = true + selectedObject = obj + starView.setSelectedObject(obj) + showObjectInfo(obj, centerInVisibleMapOnPresentation: true) + } + + @objc func showSearchDialog() { + showSearchDialog(initialCatalogWid: nil) + } + + func showSearchDialog(initialCatalogWid: String? = nil) { + clearPreviousSearchDialog() + let controller = StarMapSearchViewController.newInstance(initialCatalogWid: initialCatalogWid, + parent: self, + plugin: plugin) + controller.onObjectSelected = { [weak self] obj in + self?.handleSearchObjectSelected(obj) + } + controller.applyRedFilter(enabled: starView.showRedFilter) + searchViewController = controller + let presentingController = presentedViewController ?? self + presentingController.present(controller, animated: true) + } + + private func clearPreviousSearchDialog() { + searchViewController?.dismiss(animated: false) + searchViewController = nil + } + + func getSearchableObjects() -> [SkyObject] { + getTrackableObjects() + } + + func getSearchObserver() -> Observer { + starView.observer + } + + func getSearchCurrentDate() -> Date { + currentDate + } + + func getSearchStarMapConfig() -> AstronomyPluginSettings.StarMapConfig { + settings.starMap + } + + func isSearchRedFilterEnabled() -> Bool { + starView.showRedFilter + } + + @objc private func close() { + dismiss(animated: true) + } + + @objc private func showConfigureSheet() { + if configureSheetController != nil { + configureSheetNavigationController?.sheetPresentationController?.animateChanges { [weak self] in + self?.configureSheetNavigationController?.sheetPresentationController?.selectedDetentIdentifier = .medium + } + updateMapControlsVisibility() + return + } + hideBottomSheet(clearSelection: false) + + let sheet = AstroConfigureViewBottomSheet() + sheet.config = settings.starMap + sheet.commonConfig = settings.common + sheet.onConfigChanged = { [weak self] config in + self?.setStarMapSettings(config) + } + sheet.onCommonConfigChanged = { [weak self] config in + self?.settings.common = config + self?.updateRegularMapVisibility(config.showRegularMap) + self?.saveCommonSettings() + } + sheet.onRedFilterChanged = { [weak self] enabled in + self?.applyRedFilter(enabled: enabled) + } + sheet.onClose = { [weak self] in + self?.dismissConfigureSheet(animated: true) + } + sheet.onDismissed = { [weak self] in + guard let self, !isDismissingConfigureSheet else { + return + } + finishConfigureSheetDismiss() + } + + let navigationController = UINavigationController(rootViewController: sheet) + navigationController.modalPresentationStyle = .pageSheet + navigationController.navigationBar.prefersLargeTitles = false + + configureSheetController = sheet + configureSheetNavigationController = navigationController + + if UIDevice.current.userInterfaceIdiom == .pad { + showLeftPanel(navigationController) + } else { + if let sheetPresentationController = navigationController.sheetPresentationController { + sheetPresentationController.detents = [.medium(), .large()] + sheetPresentationController.selectedDetentIdentifier = .medium + sheetPresentationController.prefersGrabberVisible = true + sheetPresentationController.prefersScrollingExpandsWhenScrolledToEdge = true + sheetPresentationController.preferredCornerRadius = 24 + sheetPresentationController.largestUndimmedDetentIdentifier = .large + } + present(navigationController, animated: true) + updateMapControlsVisibility() + } + } + + private func showLeftPanel(_ navigationController: UINavigationController, animated: Bool = true, completion: (() -> Void)? = nil) { + if let existingPanel = embeddedLeftPanelNavigationController(), existingPanel !== navigationController { + dismissLeftPanel(navigationController: existingPanel, animated: false) + if existingPanel === configureSheetNavigationController { + finishConfigureSheetDismiss() + } else if existingPanel === objectInfoNavigationController { + finishObjectInfoDismiss(clearSelection: false) + } + } + + addChild(navigationController) + view.insertSubview(navigationController.view, belowSubview: mapControlsContainer) + navigationController.view.translatesAutoresizingMaskIntoConstraints = false + navigationController.view.layer.cornerRadius = 24 + navigationController.view.clipsToBounds = true + + let offscreenLeading = -(Layout.leftPanelWidth + Layout.contentPadding) + let leading = navigationController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: offscreenLeading) + leftPanelLeadingConstraint = leading + + NSLayoutConstraint.activate([ + navigationController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + leading, + navigationController.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -Layout.contentPadding), + navigationController.view.widthAnchor.constraint(equalToConstant: Layout.leftPanelWidth) + ]) + + navigationController.didMove(toParent: self) + + view.layoutIfNeeded() + + guard animated else { + leading.constant = Layout.contentPadding + mapVisibleAreaLeadingConstraint?.constant = mapControlsLeadingInset + view.layoutIfNeeded() + completion?() + return + } + + UIView.animate(withDuration: 0.25, animations: { + leading.constant = Layout.contentPadding + self.mapVisibleAreaLeadingConstraint?.constant = self.mapControlsLeadingInset + self.view.layoutIfNeeded() + }, completion: { _ in + completion?() + }) + } + + private func dismissLeftPanel(navigationController: UINavigationController, animated: Bool, completion: (() -> Void)? = nil) { + guard navigationController.parent === self else { + completion?() + return + } + + let finish = { + navigationController.willMove(toParent: nil) + navigationController.view.removeFromSuperview() + navigationController.removeFromParent() + self.leftPanelLeadingConstraint = nil + completion?() + } + + guard animated, let leading = leftPanelLeadingConstraint else { + finish() + return + } + + UIView.animate(withDuration: 0.25, animations: { + leading.constant = -(Layout.leftPanelWidth + Layout.contentPadding) + if UIDevice.current.userInterfaceIdiom == .pad { + self.mapVisibleAreaLeadingConstraint?.constant = Layout.contentPadding + } + self.view.layoutIfNeeded() + }, completion: { _ in + finish() + }) + } + + @objc private func toggleTimeSelection() { + timeSelectionView.isHidden.toggle() + updateTimeControlTheme() + } + + @objc private func resetTimeButtonPressed() { + setTimeAutoUpdateEnabled(true) + } + + @objc private func toggleARMode() { + arModeHelper.toggleArMode() + } + + @objc private func toggleCamera() { + cameraHelper.toggleCameraOverlay(in: view, below: starView) + } + + @objc private func transparencyChanged() { + cameraHelper.setTransparency(progress: Int(transparencySlider.value)) + } + + @objc private func resetFov() { + cameraHelper.resetFov() + } + + @objc private func toggleMagnitudeSlider() { + magnitudeSliderCard.isHidden.toggle() + updateMagnitudeFilterTheme() + } + + @objc private func magnitudeChanged() { + starView.magnitudeFilter = Double(magnitudeSlider.value) + starView.showMagnitudeFilter = true + settings.updateStarMapConfig { current in + var config = current + config.showMagnitudeFilter = true + config.magnitudeFilter = starView.magnitudeFilter + return config + } + updateMagnitudeControls() + starView.setNeedsDisplay() + } +} + +private final class StarMapControlsContainer: UIView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hitView = super.hitTest(point, with: event) + return hitView === self ? nil : hitView + } +} diff --git a/Sources/Plugins/Astronomy/StarObjectsViewModel.swift b/Sources/Plugins/Astronomy/StarObjectsViewModel.swift new file mode 100644 index 0000000000..4e6583ba3d --- /dev/null +++ b/Sources/Plugins/Astronomy/StarObjectsViewModel.swift @@ -0,0 +1,69 @@ +// +// StarObjectsViewModel.swift +// OsmAnd Maps +// +// Created by Codex on 19.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation + +final class StarObjectsViewModel { + private let provider: AstroDataProvider + private(set) var settings: AstronomyPluginSettings + + var onDataChanged: (() -> Void)? + private(set) var skyObjects: [SkyObject] = [] + private(set) var constellations: [Constellation] = [] + + init(provider: AstroDataProvider, settings: AstronomyPluginSettings) { + self.provider = provider + self.settings = settings + } + + func updateSettings(_ settings: AstronomyPluginSettings) { + self.settings = settings + applyObjectSettings() + onDataChanged?() + } + + func load(preferredLocale: String?) { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { + return + } + let objects = provider.getSkyObjects(preferredLocale: preferredLocale) + let constellations = provider.getConstellations(preferredLocale: preferredLocale) + DispatchQueue.main.async { + self.applyObjectSettings(to: objects + constellations.map { $0 as SkyObject }) + let favoriteOrder = Dictionary(uniqueKeysWithValues: self.settings.starMap.favorites.enumerated().map { ($0.element.id, $0.offset) }) + self.skyObjects = objects.sorted { (favoriteOrder[$0.id] ?? Int.max) < (favoriteOrder[$1.id] ?? Int.max) } + self.constellations = constellations.sorted { (favoriteOrder[$0.id] ?? Int.max) < (favoriteOrder[$1.id] ?? Int.max) } + self.onDataChanged?() + } + } + } + + private func applyObjectSettings(to objects: [SkyObject]? = nil) { + let targetObjects: [SkyObject] + if let providedObjects = objects { + targetObjects = providedObjects + } else { + targetObjects = skyObjects + constellations.map { $0 as SkyObject } + } + + let favoritesMap = Dictionary(uniqueKeysWithValues: settings.starMap.favorites.map { ($0.id, $0) }) + let directionsMap = Dictionary(uniqueKeysWithValues: settings.starMap.directions.map { ($0.id, $0) }) + let celestialPathsMap = Dictionary(uniqueKeysWithValues: settings.starMap.celestialPaths.map { ($0.id, $0) }) + for object in targetObjects { + object.isFavorite = favoritesMap[object.id] != nil + object.showDirection = directionsMap[object.id] != nil + object.showCelestialPath = celestialPathsMap[object.id] != nil + if let direction = directionsMap[object.id] { + object.colorIndex = direction.colorIndex + } else { + object.colorIndex = 0 + } + } + } +} diff --git a/Sources/Plugins/Astronomy/StarView.swift b/Sources/Plugins/Astronomy/StarView.swift new file mode 100644 index 0000000000..18ab054db0 --- /dev/null +++ b/Sources/Plugins/Astronomy/StarView.swift @@ -0,0 +1,2038 @@ +// +// StarView.swift +// OsmAnd Maps +// +// Created by Codex on 19.05.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import OsmAndShared +import UIKit + +protocol StarViewDelegate: AnyObject { + func starView(_ starView: StarView, didSelect object: SkyObject?) +} + +final class StarView: UIView { + private struct CelestialPathData { + let azimuths: [Double] + let altitudes: [Double] + let labels: [String?] + let lastTime: Double + let lastLat: Double + let lastLon: Double + } + + private struct ConstellationCenter { + let ra: Double + let dec: Double + var azimuth: Double + var altitude: Double + var startAzimuth: Double = 0 + var startAltitude: Double = 0 + var targetAzimuth: Double + var targetAltitude: Double + } + + private struct FocusAnimation { + let startAzimuth: Double + let targetAzimuth: Double + let startAltitude: Double + let targetAltitude: Double + let startViewAngle: Double + let targetViewAngle: Double + let startPan: CGPoint + let targetPan: CGPoint? + } + + private enum ViewAngleBounds { + static let min = 10.0 + static let max = 150.0 + static let max2D = 220.0 + } + + weak var delegate: StarViewDelegate? + + var viewModel: StarObjectsViewModel? { + didSet { + rebuildObjectMap() + setNeedsDisplay() + } + } + + var settings = AstronomyPluginSettings() { + didSet { + if oldValue.starMap.is2DMode != settings.starMap.is2DMode { + if settings.starMap.is2DMode { + roll = 0 + } else { + panX = 0 + panY = 0 + } + } + updateRedFilter() + setNeedsDisplay() + } + } + + var isCameraMode = false + var onAnimationFinished: (() -> Void)? + var onAzimuthManualChangeListener: ((Double) -> Void)? + var onViewAngleChangeListener: ((Double) -> Void)? + + private(set) var centerAzimuth = 180.0 + private(set) var centerAltitude = 45.0 + private(set) var viewAngle = 60.0 + var roll = 0.0 + + var showAzimuthalGrid: Bool { + get { settings.starMap.showAzimuthalGrid } + set { settings.starMap.showAzimuthalGrid = newValue; setNeedsDisplay() } + } + + var showEquatorialGrid: Bool { + get { settings.starMap.showEquatorialGrid } + set { settings.starMap.showEquatorialGrid = newValue; setNeedsDisplay() } + } + + var showEclipticLine: Bool { + get { settings.starMap.showEclipticLine } + set { settings.starMap.showEclipticLine = newValue; setNeedsDisplay() } + } + + var showMeridianLine: Bool { + get { settings.starMap.showMeridianLine } + set { settings.starMap.showMeridianLine = newValue; setNeedsDisplay() } + } + + var showEquatorLine: Bool { + get { settings.starMap.showEquatorLine } + set { settings.starMap.showEquatorLine = newValue; setNeedsDisplay() } + } + + var showGalacticLine: Bool { + get { settings.starMap.showGalacticLine } + set { settings.starMap.showGalacticLine = newValue; setNeedsDisplay() } + } + + var showFavorites: Bool { + get { settings.starMap.showFavorites } + set { settings.starMap.showFavorites = newValue; setNeedsDisplay() } + } + + var showDirections: Bool { + get { settings.starMap.showDirections } + set { settings.starMap.showDirections = newValue; setNeedsDisplay() } + } + + var showCelestialPaths: Bool { + get { settings.starMap.showCelestialPaths } + set { settings.starMap.showCelestialPaths = newValue; setNeedsDisplay() } + } + + var showRedFilter: Bool { + get { settings.starMap.showRedFilter } + set { settings.starMap.showRedFilter = newValue; updateRedFilter() } + } + + var showStars: Bool { + get { settings.starMap.showStars } + set { settings.starMap.showStars = newValue; setNeedsDisplay() } + } + + var showConstellations: Bool { + get { settings.starMap.showConstellations } + set { settings.starMap.showConstellations = newValue; setNeedsDisplay() } + } + + var showGalaxies: Bool { + get { settings.starMap.showGalaxies } + set { settings.starMap.showGalaxies = newValue; setNeedsDisplay() } + } + + var showBlackHoles: Bool { + get { settings.starMap.showBlackHoles } + set { settings.starMap.showBlackHoles = newValue; setNeedsDisplay() } + } + + var showNebulae: Bool { + get { settings.starMap.showNebulae } + set { settings.starMap.showNebulae = newValue; setNeedsDisplay() } + } + + var showOpenClusters: Bool { + get { settings.starMap.showOpenClusters } + set { settings.starMap.showOpenClusters = newValue; setNeedsDisplay() } + } + + var showGlobularClusters: Bool { + get { settings.starMap.showGlobularClusters } + set { settings.starMap.showGlobularClusters = newValue; setNeedsDisplay() } + } + + var showGalaxyClusters: Bool { + get { settings.starMap.showGalaxyClusters } + set { settings.starMap.showGalaxyClusters = newValue; setNeedsDisplay() } + } + + var showSun: Bool { + get { settings.starMap.showSun } + set { settings.starMap.showSun = newValue; setNeedsDisplay() } + } + + var showMoon: Bool { + get { settings.starMap.showMoon } + set { settings.starMap.showMoon = newValue; setNeedsDisplay() } + } + + var showPlanets: Bool { + get { settings.starMap.showPlanets } + set { settings.starMap.showPlanets = newValue; setNeedsDisplay() } + } + + var magnitudeFilter: Double? { + get { settings.starMap.magnitudeFilter } + set { settings.starMap.magnitudeFilter = newValue; setNeedsDisplay() } + } + + var showMagnitudeFilter: Bool { + get { settings.starMap.showMagnitudeFilter } + set { settings.starMap.showMagnitudeFilter = newValue; setNeedsDisplay() } + } + + var is2DMode: Bool { + get { settings.starMap.is2DMode } + set { settings.starMap.is2DMode = newValue; setNeedsDisplay() } + } + + private var panX: CGFloat = 0 + private var panY: CGFloat = 0 + private var lastTouchPoint = CGPoint.zero + private var isPanning = false + + private var projectionSinAltCenter = 0.0 + private var projectionCosAltCenter = 1.0 + private var projectionScale = 1.0 + private var projectionHalfWidth = 0.0 + private var projectionHalfHeight = 0.0 + private var minCosCVisible = -1.0 + + private var skyObjectMap: [String: SkyObject] = [:] + private var occupiedRects: [CGRect] = [] + private var pathCache: [String: CelestialPathData] = [:] + private var selectedConstellationId: String? + private var selectedObject: SkyObject? + private var selectedConstellationStarIds = Set() + private var pinnedObjects = Set() + private var manualSkyObjects: [SkyObject]? + private var manualConstellations: [Constellation]? + private var constellationCenterCache: [String: ConstellationCenter] = [:] + private var maxViewAngle: Double { + settings.starMap.is2DMode ? ViewAngleBounds.max2D : ViewAngleBounds.max + } + private var explicitCurrentTime: Time? + private var explicitObserver: Observer? + private var timeAnimationDisplayLink: CADisplayLink? + private var timeAnimationStartTime: CFTimeInterval = 0 + private let timeAnimationDuration: CFTimeInterval = 0.4 + private var focusAnimationDisplayLink: CADisplayLink? + private var focusAnimationStartTime: CFTimeInterval = 0 + private let focusAnimationDuration: CFTimeInterval = 0.5 + private var focusAnimation: FocusAnimation? + private var onObjectClickListener: ((SkyObject?) -> Void)? + private var onConstellationClickListener: ((Constellation?) -> Void)? + + private let eclipticStep = 10 + private var eclipticAzimuths: [Double] = [] + private var eclipticAltitudes: [Double] = [] + private var lastEclipticTimeT = -1.0 + private var lastEclipticLat = -999.0 + private var lastEclipticLon = -999.0 + + private let equatorStep = 2 + private var equatorAzimuths: [Double] = [] + private var equatorAltitudes: [Double] = [] + private var lastEquatorTimeT = -1.0 + private var lastEquatorLat = -999.0 + private var lastEquatorLon = -999.0 + + private let galacticStep = 5 + private var galacticAzimuths: [Double] = [] + private var galacticAltitudes: [Double] = [] + private var lastGalacticTimeT = -1.0 + private var lastGalacticLat = -999.0 + private var lastGalacticLon = -999.0 + + private var gridDensityLevel = -1 + private var equRaStepMin = 120 + private var equDecStep = 20 + private var equLineResStep = 5 + private var equRaAzimuths: [[Double]] = [] + private var equRaAltitudes: [[Double]] = [] + private var equDecAzimuths: [[Double]] = [] + private var equDecAltitudes: [[Double]] = [] + private var lastEquGridTimeT = -1.0 + private var lastEquGridLat = -999.0 + private var lastEquGridLon = -999.0 + + var currentTime: Time { + get { + explicitCurrentTime ?? AstroUtils.astronomyTime(from: Date()) + } + set { + explicitCurrentTime = newValue + } + } + + var observer: Observer { + get { + explicitObserver ?? AstroUtils.observer(from: nil) + } + set { + explicitObserver = newValue + } + } + + private var skyObjects: [SkyObject] { + (manualSkyObjects ?? viewModel?.skyObjects ?? []) + .filter { $0.type != .CONSTELLATION } + .sorted { $0.magnitude < $1.magnitude } + } + + private var constellations: [Constellation] { + manualConstellations ?? viewModel?.constellations ?? [] + } + + private var trackableObjects: [SkyObject] { + skyObjects + constellations.map { $0 as SkyObject } + } + + private var screenScale: CGFloat { + window?.screen.scale ?? UIScreen.main.scale + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + deinit { + timeAnimationDisplayLink?.invalidate() + focusAnimationDisplayLink?.invalidate() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + backgroundColor = .clear + isOpaque = false + contentMode = .redraw + + addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))) + addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))) + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))) + } + + func resetView() { + cancelFocusAnimation() + centerAzimuth = 180 + centerAltitude = 45 + viewAngle = 60 + roll = 0 + panX = 0 + panY = 0 + setNeedsDisplay() + } + + func setObserverLocation(lat: Double, lon: Double, alt: Double) { + let previousObserver = observer + observer = Observer(latitude: lat, longitude: lon, height: alt) + if previousObserver.latitude != lat || + previousObserver.longitude != lon || + previousObserver.height != alt { + pathCache.removeAll() + } + recalculatePositions(time: currentTime, updateTargets: false, force: true) + setNeedsDisplay() + } + + func setCameraOrientation(azimuth: Double, altitude: Double, roll: Double) { + setCenter(azimuth: azimuth, altitude: altitude) + self.roll = roll + setNeedsDisplay() + } + + func setCenter(azimuth: Double, altitude: Double, animate: Bool = false) { + if animate { + animateTo(azimuth: azimuth, altitude: altitude) + return + } + cancelFocusAnimation() + centerAzimuth = normalizedDegrees(azimuth) + centerAltitude = max(-90, min(90, altitude)) + setNeedsDisplay() + } + + private func setCenter(azimuth: Double, + altitude: Double, + at targetPoint: CGPoint, + animate: Bool = false, + targetViewAngle: Double? = nil) { + guard bounds.width > 0, bounds.height > 0 else { + if animate { + animateTo(azimuth: azimuth, altitude: altitude, targetViewAngle: targetViewAngle) + } else { + setCenterState(azimuth: azimuth, altitude: altitude, targetViewAngle: targetViewAngle) + } + return + } + + let finalViewAngle = boundedViewAngle(targetViewAngle ?? viewAngle) + if settings.starMap.is2DMode { + let targetPan = CGPoint(x: targetPoint.x - bounds.midX, + y: targetPoint.y - bounds.midY) + if animate { + animateTo(azimuth: azimuth, + altitude: altitude, + targetViewAngle: targetViewAngle, + targetPan: targetPan) + } else { + setCenterState(azimuth: azimuth, altitude: altitude, targetViewAngle: targetViewAngle) + panX = targetPan.x + panY = targetPan.y + setNeedsDisplay() + } + return + } + + let screenOffsetX = Double(targetPoint.x - bounds.midX) + let screenOffsetY = Double(targetPoint.y - bounds.midY) + let rollRad = roll * .pi / 180.0 + let unrotatedX = screenOffsetX * cos(rollRad) + screenOffsetY * sin(rollRad) + let unrotatedY = -screenOffsetX * sin(rollRad) + screenOffsetY * cos(rollRad) + let scale = finalViewAngle / Double(max(bounds.width, 1)) + let targetAzimuth = normalizedDegrees(azimuth - unrotatedX * scale) + let targetAltitude = max(-90, min(90, altitude + unrotatedY * scale)) + if animate { + animateTo(azimuth: targetAzimuth, altitude: targetAltitude, targetViewAngle: targetViewAngle) + } else { + setCenterState(azimuth: targetAzimuth, altitude: targetAltitude, targetViewAngle: targetViewAngle) + } + } + + private func setCenterState(azimuth: Double, altitude: Double, targetViewAngle: Double? = nil) { + cancelFocusAnimation() + centerAzimuth = normalizedDegrees(azimuth) + centerAltitude = max(-90, min(90, altitude)) + if let targetViewAngle { + viewAngle = boundedViewAngle(targetViewAngle) + onViewAngleChangeListener?(viewAngle) + } + setNeedsDisplay() + } + + private func boundedViewAngle(_ angle: Double) -> Double { + max(ViewAngleBounds.min, min(maxViewAngle, angle)) + } + + private func animateTo(azimuth: Double, + altitude: Double, + targetViewAngle: Double? = nil, + targetPan: CGPoint? = nil) { + cancelFocusAnimation() + focusAnimation = FocusAnimation(startAzimuth: centerAzimuth, + targetAzimuth: normalizedDegrees(azimuth), + startAltitude: centerAltitude, + targetAltitude: max(-90, min(90, altitude)), + startViewAngle: viewAngle, + targetViewAngle: targetViewAngle.map { boundedViewAngle($0) } ?? viewAngle, + startPan: CGPoint(x: panX, y: panY), + targetPan: targetPan) + focusAnimationStartTime = CACurrentMediaTime() + let displayLink = CADisplayLink(target: self, selector: #selector(handleFocusAnimationFrame(_:))) + focusAnimationDisplayLink = displayLink + displayLink.add(to: .main, forMode: .common) + } + + private func cancelFocusAnimation() { + focusAnimationDisplayLink?.invalidate() + focusAnimationDisplayLink = nil + focusAnimation = nil + } + + @objc private func handleFocusAnimationFrame(_ displayLink: CADisplayLink) { + guard let animation = focusAnimation else { + cancelFocusAnimation() + return + } + + let rawFraction = min(1.0, max(0.0, (displayLink.timestamp - focusAnimationStartTime) / focusAnimationDuration)) + let fraction = 1.0 - pow(1.0 - rawFraction, 2.0) + applyFocusAnimation(animation, fraction: fraction) + if rawFraction >= 1 { + applyFocusAnimation(animation, fraction: 1) + cancelFocusAnimation() + onAnimationFinished?() + } + } + + private func applyFocusAnimation(_ animation: FocusAnimation, fraction: Double) { + centerAzimuth = interpolateAngle(start: animation.startAzimuth, end: animation.targetAzimuth, fraction: fraction) + centerAltitude = animation.startAltitude + (animation.targetAltitude - animation.startAltitude) * fraction + viewAngle = animation.startViewAngle + (animation.targetViewAngle - animation.startViewAngle) * fraction + if let targetPan = animation.targetPan { + let cgFraction = CGFloat(fraction) + panX = animation.startPan.x + (targetPan.x - animation.startPan.x) * cgFraction + panY = animation.startPan.y + (targetPan.y - animation.startPan.y) * cgFraction + } + if abs(animation.targetViewAngle - animation.startViewAngle) > 0.001 { + onViewAngleChangeListener?(viewAngle) + } + setNeedsDisplay() + } + + func getAltitude() -> Double { + centerAltitude + } + + func getAzimuth() -> Double { + centerAzimuth + } + + func getViewAngle() -> Double { + viewAngle + } + + func setAzimuth(_ azimuth: Double, animate: Bool = false, fps: Int? = 30) { + guard abs(centerAzimuth - azimuth) >= 0.5 else { + return + } + if animate { + animateTo(azimuth: azimuth, altitude: centerAltitude) + } else { + cancelFocusAnimation() + centerAzimuth = normalizedDegrees(azimuth) + setNeedsDisplay() + } + onAzimuthManualChangeListener?(centerAzimuth) + } + + func setSkyObjects(_ objects: [SkyObject]) { + manualSkyObjects = objects.sorted { $0.magnitude < $1.magnitude } + rebuildObjectMap() + rebuildConstellationCenterCache() + recalculatePositions(time: currentTime, updateTargets: false, force: true) + let celestialPathObjects = Set(objects.filter { $0.showCelestialPath }) + let staleObjects = pinnedObjects.filter { !($0 is Constellation) && !celestialPathObjects.contains($0) } + pinnedObjects.subtract(staleObjects) + for object in staleObjects { + pathCache.removeValue(forKey: object.id) + } + pinnedObjects.formUnion(celestialPathObjects) + setNeedsDisplay() + } + + func setConstellations(_ list: [Constellation]) { + manualConstellations = list + rebuildObjectMap() + rebuildConstellationCenterCache() + recalculatePositions(time: currentTime, updateTargets: false, force: true) + let celestialPathConstellations = Set(list.filter { $0.showCelestialPath }.map { $0 as SkyObject }) + let staleConstellations = pinnedObjects.filter { $0 is Constellation && !celestialPathConstellations.contains($0) } + pinnedObjects.subtract(staleConstellations) + for constellation in staleConstellations { + pathCache.removeValue(forKey: constellation.id) + } + pinnedObjects.formUnion(celestialPathConstellations) + setNeedsDisplay() + } + + func updateVisibility() { + recalculatePositions(time: currentTime, updateTargets: false) + setNeedsDisplay() + } + + func refreshObjects() { + recalculatePositions(time: currentTime, updateTargets: false) + setNeedsDisplay() + } + + func updateRedFilter() { + AstroRedFilter.apply(settings.starMap.showRedFilter, to: self) + setNeedsDisplay() + } + + func setOnObjectClickListener(_ listener: @escaping (SkyObject?) -> Void) { + onObjectClickListener = listener + } + + func setOnConstellationClickListener(_ listener: @escaping (Constellation?) -> Void) { + onConstellationClickListener = listener + } + + func getSelectedConstellationItem() -> Constellation? { + guard let selectedConstellationId else { + return nil + } + return constellations.first { $0.id == selectedConstellationId } + } + + func setSelectedObject(_ object: SkyObject?, center: Bool = false, animate: Bool = false) { + if let constellation = object as? Constellation { + setSelectedConstellation(constellation, center: center, animate: animate) + return + } + selectedConstellationId = nil + selectedConstellationStarIds.removeAll() + selectedObject = object + if let object { + if object.azimuth == 0 && object.altitude == 0 { + calculatePosition(object) + } + if center { + setCenter(azimuth: object.azimuth, altitude: object.altitude, animate: animate) + } + } + setNeedsDisplay() + } + + func setSelectedObject(_ object: SkyObject?, centerAt targetPoint: CGPoint, animate: Bool = false) { + if let constellation = object as? Constellation { + setSelectedConstellation(constellation, centerAt: targetPoint, animate: animate) + return + } + selectedConstellationId = nil + selectedConstellationStarIds.removeAll() + selectedObject = object + if let object { + if object.azimuth == 0 && object.altitude == 0 { + calculatePosition(object) + } + setCenter(azimuth: object.azimuth, altitude: object.altitude, at: targetPoint, animate: animate) + } + setNeedsDisplay() + } + + func setSelectedConstellation(_ constellation: Constellation?, center: Bool = false, animate: Bool = false) { + selectedConstellationId = constellation?.id + selectedObject = constellation + selectedConstellationStarIds.removeAll() + for (first, second) in constellation?.lines ?? [] { + selectedConstellationStarIds.insert(first) + selectedConstellationStarIds.insert(second) + } + for object in skyObjects where selectedConstellationStarIds.contains(object.hip) { + calculatePosition(object, time: currentTime, updateTargets: false) + object.azimuth = object.targetAzimuth + object.altitude = object.targetAltitude + } + if let constellation, center { + let centers = constellationCenters() + if let center = centers[constellation.id] { + let targetAngle = targetViewAngle(for: constellation, center: center) + if animate { + animateTo(azimuth: center.azimuth, altitude: center.altitude, targetViewAngle: targetAngle) + } else { + setCenter(azimuth: center.azimuth, altitude: center.altitude) + setViewAngle(targetAngle) + } + } + } + setNeedsDisplay() + } + + func setSelectedConstellation(_ constellation: Constellation?, centerAt targetPoint: CGPoint, animate: Bool = false) { + selectedConstellationId = constellation?.id + selectedObject = constellation + selectedConstellationStarIds.removeAll() + for (first, second) in constellation?.lines ?? [] { + selectedConstellationStarIds.insert(first) + selectedConstellationStarIds.insert(second) + } + for object in skyObjects where selectedConstellationStarIds.contains(object.hip) { + calculatePosition(object, time: currentTime, updateTargets: false) + object.azimuth = object.targetAzimuth + object.altitude = object.targetAltitude + } + if let constellation { + let centers = constellationCenters() + if let center = centers[constellation.id] { + setCenter(azimuth: center.azimuth, + altitude: center.altitude, + at: targetPoint, + animate: animate, + targetViewAngle: targetViewAngle(for: constellation, center: center)) + } + } + setNeedsDisplay() + } + + private func targetViewAngle(for constellation: Constellation, center: ConstellationCenter) -> Double { + var maxDistance = 0.0 + var uniqueStars = Set() + for (first, second) in constellation.lines { + uniqueStars.insert(first) + uniqueStars.insert(second) + } + for id in uniqueStars { + guard let star = skyObjectMap[String(id)] else { + continue + } + maxDistance = max(maxDistance, angularDistance(ra1: center.ra, dec1: center.dec, ra2: star.ra, dec2: star.dec)) + } + return maxDistance > 0 ? max(20, min(120, maxDistance * 3.5)) : viewAngle + } + + func isObjectPinned(_ object: SkyObject) -> Bool { + pinnedObjects.contains(object) + } + + func setObjectPinned(_ object: SkyObject, pinned: Bool, forceUpdate: Bool = false) { + if pinned { + pinnedObjects.insert(object) + } else { + pinnedObjects.remove(object) + pathCache.removeValue(forKey: object.id) + } + if forceUpdate { + setNeedsDisplay() + } + } + + func setDateTime(_ time: Time, animate: Bool = true) { + timeAnimationDisplayLink?.invalidate() + timeAnimationDisplayLink = nil + + if animate { + for object in skyObjects { + object.startAzimuth = object.azimuth + object.startAltitude = object.altitude + } + captureConstellationAnimationStarts() + recalculatePositions(time: time, updateTargets: true, force: true) + currentTime = time + timeAnimationStartTime = CACurrentMediaTime() + let displayLink = CADisplayLink(target: self, selector: #selector(handleTimeAnimationFrame(_:))) + timeAnimationDisplayLink = displayLink + displayLink.add(to: .main, forMode: .common) + } else { + currentTime = time + recalculatePositions(time: time, updateTargets: true, force: true) + for object in skyObjects { + object.azimuth = object.targetAzimuth + object.altitude = object.targetAltitude + } + var updatedCenters = constellationCenterCache + for (id, center) in constellationCenterCache { + var updated = center + updated.azimuth = center.targetAzimuth + updated.altitude = center.targetAltitude + updatedCenters[id] = updated + } + constellationCenterCache = updatedCenters + updateConstellationObjectPositions() + setNeedsDisplay() + onAnimationFinished?() + } + } + + private func captureConstellationAnimationStarts() { + var updatedCenters = constellationCenterCache + for (id, center) in constellationCenterCache { + var updated = center + updated.startAzimuth = center.azimuth + updated.startAltitude = center.altitude + updatedCenters[id] = updated + } + constellationCenterCache = updatedCenters + } + + @objc private func handleTimeAnimationFrame(_ displayLink: CADisplayLink) { + let rawFraction = min(1.0, max(0.0, (displayLink.timestamp - timeAnimationStartTime) / timeAnimationDuration)) + let fraction = 1.0 - pow(1.0 - rawFraction, 2.0) + for object in skyObjects { + object.azimuth = interpolateAngle(start: object.startAzimuth, end: object.targetAzimuth, fraction: fraction) + object.altitude = object.startAltitude + (object.targetAltitude - object.startAltitude) * fraction + } + var updatedCenters = constellationCenterCache + for (id, center) in constellationCenterCache { + var updated = center + updated.azimuth = interpolateAngle(start: center.startAzimuth, end: center.targetAzimuth, fraction: fraction) + updated.altitude = center.startAltitude + (center.targetAltitude - center.startAltitude) * fraction + updatedCenters[id] = updated + } + constellationCenterCache = updatedCenters + updateConstellationObjectPositions() + setNeedsDisplay() + + if rawFraction >= 1 { + displayLink.invalidate() + timeAnimationDisplayLink = nil + onAnimationFinished?() + } + } + + func setViewAngle(_ angle: Double) { + updateViewAngle(angle) + } + + func zoomIn() { + updateViewAngle(viewAngle / 1.5) + } + + func zoomOut() { + updateViewAngle(viewAngle * 1.5) + } + + func project(object: SkyObject) -> CGPoint? { + updateProjectionCache() + return skyToScreen(azimuth: object.azimuth, altitude: object.altitude) + } + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext() else { + return + } + + updateProjectionCache() + rebuildObjectMap() + occupiedRects.removeAll(keepingCapacity: true) + + context.saveGState() + drawBackground(in: context) + if settings.starMap.showEquatorialGrid { + drawEquatorialGrid(in: context) + } + if settings.starMap.showAzimuthalGrid { + drawAzimuthalGrid(in: context) + } + if settings.starMap.showEclipticLine { + drawEclipticLine(in: context) + } + if settings.starMap.showMeridianLine { + drawMeridianLine(in: context) + } + if settings.starMap.showEquatorLine { + drawEquatorLine(in: context) + } + if settings.starMap.showGalacticLine { + drawGalacticLine(in: context) + } + drawConstellationLines(in: context) + drawHorizon(in: context) + drawCelestialPaths(in: context) + drawConstellationLabels(in: context) + drawSkyObjects(in: context) + drawHighlights(in: context) + drawDirectionArrows(in: context) + + context.restoreGState() + } + + private func rebuildObjectMap() { + skyObjectMap.removeAll(keepingCapacity: true) + for object in (manualSkyObjects ?? viewModel?.skyObjects ?? []) { + skyObjectMap[object.id] = object + if !object.wid.isEmpty { + skyObjectMap[object.wid] = object + } + let hip = object.hip + if hip > 0 { + skyObjectMap[String(hip)] = object + } + } + } + + private func drawBackground(in context: CGContext) { + if isCameraMode { + context.clear(bounds) + UIColor.black.withAlphaComponent(0.20).setFill() + } else { + UIColor.black.setFill() + } + context.fill(bounds) + } + + private func drawSkyObjects(in context: CGContext) { + for object in skyObjects { + if isObjectVisibleInSettings(object) || object === selectedObject { + drawSkyObject(object, in: context) + } + } + } + + private func drawSkyObject(_ object: SkyObject, in context: CGContext) { + guard let point = skyToScreen(azimuth: object.azimuth, altitude: object.altitude), + bounds.insetBy(dx: -30, dy: -30).contains(point) else { + return + } + + let zoomFactor = max(0, min(1, (viewAngle - ViewAngleBounds.min) / (maxViewAngle - ViewAngleBounds.min))) + var color = object.color + var baseSize: CGFloat = 15 + if object.type == .STAR && zoomFactor > 0.3 && object.magnitude > 2.5 { + baseSize = 8 + color = .gray + } + + var radius = max(2, baseSize - CGFloat(object.magnitude) * 2) + if object.type == .STAR && zoomFactor > 0.5 { + radius *= 0.7 + } + if object.type == .SUN || object.type == .MOON { + radius *= 0.5 + } + radius = pixelsToPoints(radius) + + color.setFill() + context.fillEllipse(in: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2)) + let objectRect = CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2) + + guard shouldShowLabel(for: object, zoomFactor: zoomFactor) else { + occupiedRects.append(objectRect) + return + } + + let text = object.getDisplayName() + let font = UIFont.systemFont(ofSize: 13, weight: .regular) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: labelColor(for: object) + ] + let size = text.size(withAttributes: attributes) + let origin = CGPoint(x: point.x + radius + pixelsToPoints(5), y: point.y - size.height * 0.75) + let labelPadding = pixelsToPoints(5) + let labelRect = CGRect(origin: origin, size: size).insetBy(dx: -labelPadding, dy: -labelPadding) + let overlaps = occupiedRects.contains { $0.intersects(labelRect) } + if !overlaps || object === selectedObject || object.showCelestialPath { + text.draw(at: origin, withAttributes: attributes) + occupiedRects.append(labelRect) + occupiedRects.append(objectRect) + } + } + + private func shouldShowLabel(for object: SkyObject, zoomFactor: Double) -> Bool { + if object === selectedObject || (settings.starMap.showCelestialPaths && object.showCelestialPath) { + return true + } + if object.type == .STAR { + if object.getDisplayName().lowercased().hasPrefix("hip") { + return false + } + let threshold = 5.0 - zoomFactor * 3.5 + return object.magnitude <= threshold + } + return true + } + + private func labelColor(for object: SkyObject) -> UIColor { + if object === selectedObject { + return .red + } + if settings.starMap.showCelestialPaths && object.showCelestialPath { + return .yellow + } + return .lightGray + } + + private func drawHighlights(in context: CGContext) { + if settings.starMap.showCelestialPaths { + for object in pinnedObjects where isObjectVisibleInSettings(object) || object === selectedObject { + if let point = skyToScreen(azimuth: object.azimuth, altitude: object.altitude) { + strokeCircle(at: point, radius: pixelsToPoints(25), color: UIColor(red: 1, green: 0.84, blue: 0, alpha: 1), width: pixelsToPoints(4), in: context) + } + } + } + + if let object = selectedObject, + let point = skyToScreen(azimuth: object.azimuth, altitude: object.altitude) { + strokeCircle(at: point, radius: pixelsToPoints(25), color: .red, width: pixelsToPoints(3), in: context) + } + + if settings.starMap.showDirections { + for object in trackableObjects where object.showDirection && isObjectVisibleInSettings(object) { + if let point = skyToScreen(azimuth: object.azimuth, altitude: object.altitude) { + strokeCircle(at: point, radius: pixelsToPoints(26), color: directionColor(object.colorIndex), width: pixelsToPoints(3), in: context) + } + } + } + } + + private func pixelsToPoints(_ pixels: CGFloat) -> CGFloat { + pixels / max(1, screenScale) + } + + private func strokeCircle(at point: CGPoint, radius: CGFloat, color: UIColor, width: CGFloat, in context: CGContext) { + color.setStroke() + context.setLineWidth(width) + context.strokeEllipse(in: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2)) + } + + private func drawHorizon(in context: CGContext) { + let path = CGMutablePath() + appendSkyLine(to: path, range: stride(from: 0.0, through: 360.0, by: 2.0)) { azimuth in + (azimuth, 0.0) + } + stroke(path, color: .green, width: 1.2, in: context) + + drawOutsideLabel("N", azimuth: 0, altitude: 0, color: .green, offset: 30, in: context) + drawOutsideLabel("E", azimuth: 90, altitude: 0, color: .green, offset: 30, in: context) + drawOutsideLabel("S", azimuth: 180, altitude: 0, color: .green, offset: 30, in: context) + drawOutsideLabel("W", azimuth: 270, altitude: 0, color: .green, offset: 30, in: context) + } + + private func drawAzimuthalGrid(in context: CGContext) { + let density: (azStep: Int, altStep: Int, lineStep: Int) + if viewAngle < 20 { + density = (10, 5, 1) + } else if viewAngle < 50 { + density = (15, 10, 2) + } else { + density = (45, 20, 5) + } + + let path = CGMutablePath() + for altitude in stride(from: -80, through: 80, by: density.altStep) { + appendSkyLine(to: path, range: stride(from: 0.0, through: 360.0, by: Double(density.lineStep))) { azimuth in + (azimuth, Double(altitude)) + } + if altitude != 0 { + drawGridLabel("\(altitude)°", azimuth: centerAzimuth, altitude: Double(altitude), align: .left, color: UIColor(white: 0.55, alpha: 1), in: context) + drawGridLabel("\(altitude)°", azimuth: centerAzimuth + 180, altitude: Double(altitude), align: .left, color: UIColor(white: 0.55, alpha: 1), in: context) + } + } + + for azimuth in stride(from: 0, to: 360, by: density.azStep) { + appendSkyLine(to: path, range: stride(from: -90.0, through: 90.0, by: Double(density.lineStep))) { altitude in + (Double(azimuth), altitude) + } + if !azimuth.isMultiple(of: 90) { + drawOutsideLabel("\(azimuth)°", azimuth: Double(azimuth), altitude: 0, color: UIColor(white: 0.55, alpha: 1), offset: 25, in: context) + } + } + stroke(path, color: UIColor(white: 0.27, alpha: 1), width: 2.0, in: context) + } + + private func updateEquatorialGridCache() { + let newLevel: Int + if viewAngle < 20 { + newLevel = 2 + } else if viewAngle < 50 { + newLevel = 1 + } else { + newLevel = 0 + } + + if newLevel != gridDensityLevel { + gridDensityLevel = newLevel + lastEquGridTimeT = -1 + switch newLevel { + case 2: + equRaStepMin = 20 + equDecStep = 5 + equLineResStep = 1 + case 1: + equRaStepMin = 60 + equDecStep = 10 + equLineResStep = 2 + default: + equRaStepMin = 120 + equDecStep = 20 + equLineResStep = 5 + } + } + + let time = currentTime + let obs = observer + guard abs(time.tt - lastEquGridTimeT) >= 0.0000001 || + obs.latitude != lastEquGridLat || + obs.longitude != lastEquGridLon else { + return + } + + let raLinesCount = (24 * 60) / equRaStepMin + let raPointsCount = (180 / equLineResStep) + 1 + equRaAzimuths = Array(repeating: Array(repeating: 0, count: raPointsCount), count: raLinesCount) + equRaAltitudes = equRaAzimuths + for i in 0..= 0 { + let dec = -80 + i * equDecStep + if dec != 0 { + let ra = Double(bestRaIndex * equRaStepMin) / 60.0 + let hor = AstronomyKt.horizon(time: currentTime, observer: observer, ra: ra, dec: Double(dec), refraction: Refraction.normal) + drawGridLabel("\(dec)°", azimuth: hor.azimuth, altitude: hor.altitude, align: .left, color: UIColor(red: 0, green: 0.74, blue: 0.74, alpha: 1), in: context) + } + } + } + + stroke(path, color: UIColor(red: 0, green: 0.40, blue: 0.40, alpha: 1), width: 0.8, in: context) + } + + private func updateEclipticCache() { + let time = currentTime + let obs = observer + guard abs(time.tt - lastEclipticTimeT) >= 0.0000001 || + obs.latitude != lastEclipticLat || + obs.longitude != lastEclipticLon else { + return + } + + eclipticAzimuths.removeAll(keepingCapacity: true) + eclipticAltitudes.removeAll(keepingCapacity: true) + let rotation = AstronomyKt.rotationEclEqd(time: time) + for longitude in stride(from: 0, through: 360, by: eclipticStep) { + let lon = Double(longitude) * .pi / 180.0 + let vecEcl = Vector(x: cos(lon), y: sin(lon), z: 0, t: time) + let vecEqd = rotation.rotate(vec: vecEcl) + let equatorial = vecEqd.toEquatorial() + let hor = AstronomyKt.horizon(time: time, observer: obs, ra: equatorial.ra, dec: equatorial.dec, refraction: Refraction.normal) + eclipticAzimuths.append(hor.azimuth) + eclipticAltitudes.append(hor.altitude) + } + lastEclipticTimeT = time.tt + lastEclipticLat = obs.latitude + lastEclipticLon = obs.longitude + } + + private func drawEclipticLine(in context: CGContext) { + updateEclipticCache() + stroke(cachedLinePath(azimuths: eclipticAzimuths, altitudes: eclipticAltitudes), + color: .yellow, + width: 1.2, + dash: [8, 8], + in: context) + } + + private func drawMeridianLine(in context: CGContext) { + let path = CGMutablePath() + appendSkyLine(to: path, range: stride(from: -90.0, through: 90.0, by: 2.0)) { altitude in + (0.0, altitude) + } + appendSkyLine(to: path, range: stride(from: -90.0, through: 90.0, by: 2.0)) { altitude in + (180.0, altitude) + } + stroke(path, color: .green, width: 1.2, dash: [12, 8], in: context) + } + + private func updateEquatorCache() { + let time = currentTime + let obs = observer + guard abs(time.tt - lastEquatorTimeT) >= 0.0000001 || + obs.latitude != lastEquatorLat || + obs.longitude != lastEquatorLon else { + return + } + + equatorAzimuths.removeAll(keepingCapacity: true) + equatorAltitudes.removeAll(keepingCapacity: true) + for raDeg in stride(from: 0, through: 360, by: equatorStep) { + let hor = AstronomyKt.horizon(time: time, observer: obs, ra: Double(raDeg) / 15.0, dec: 0, refraction: Refraction.normal) + equatorAzimuths.append(hor.azimuth) + equatorAltitudes.append(hor.altitude) + } + lastEquatorTimeT = time.tt + lastEquatorLat = obs.latitude + lastEquatorLon = obs.longitude + } + + private func drawEquatorLine(in context: CGContext) { + updateEquatorCache() + stroke(cachedLinePath(azimuths: equatorAzimuths, altitudes: equatorAltitudes), + color: UIColor(red: 0, green: 0.67, blue: 0.67, alpha: 1), + width: 1.2, + dash: [12, 8], + in: context) + } + + private func updateGalacticCache() { + let time = currentTime + let obs = observer + guard abs(time.tt - lastGalacticTimeT) >= 0.0000001 || + obs.latitude != lastGalacticLat || + obs.longitude != lastGalacticLon else { + return + } + + galacticAzimuths.removeAll(keepingCapacity: true) + galacticAltitudes.removeAll(keepingCapacity: true) + let alphaNGP = 192.85948 + let deltaNGP = 27.12825 + let cotDeltaNGP = 1.0 / tan(deltaNGP * .pi / 180.0) + for raDeg in stride(from: 0, through: 360, by: galacticStep) { + let alphaDiff = (Double(raDeg) - alphaNGP) * .pi / 180.0 + let dec = atan(-cotDeltaNGP * cos(alphaDiff)) * 180.0 / .pi + let hor = AstronomyKt.horizon(time: time, observer: obs, ra: Double(raDeg) / 15.0, dec: dec, refraction: Refraction.normal) + galacticAzimuths.append(hor.azimuth) + galacticAltitudes.append(hor.altitude) + } + lastGalacticTimeT = time.tt + lastGalacticLat = obs.latitude + lastGalacticLon = obs.longitude + } + + private func drawGalacticLine(in context: CGContext) { + updateGalacticCache() + stroke(cachedLinePath(azimuths: galacticAzimuths, altitudes: galacticAltitudes), + color: .magenta, + width: 1.2, + dash: [8, 8], + in: context) + } + + private func drawConstellationLines(in context: CGContext) { + for constellation in constellations { + let isSelected = constellation.id == selectedConstellationId + if !settings.starMap.showConstellations && !isSelected { + continue + } + + let path = CGMutablePath() + for (firstId, secondId) in constellation.lines { + guard let first = skyObjectMap[String(firstId)], + let second = skyObjectMap[String(secondId)], + let p1 = skyToScreen(azimuth: first.azimuth, altitude: first.altitude, allowLimitedOffScreen: true), + let p2 = skyToScreen(azimuth: second.azimuth, altitude: second.altitude, allowLimitedOffScreen: true) else { + continue + } + path.move(to: p1) + path.addLine(to: p2) + } + stroke(path, + color: isSelected ? UIColor(red: 1, green: 0.84, blue: 0, alpha: 1) : UIColor(red: 0.33, green: 0.60, blue: 1.0, alpha: 0.58), + width: isSelected ? 1.8 : 0.9, + in: context) + } + } + + private func drawConstellationLabels(in context: CGContext) { + let centers = constellationCenters() + for constellation in constellations { + let isSelected = constellation.id == selectedConstellationId + if !settings.starMap.showConstellations && !isSelected { + continue + } + guard let center = centers[constellation.id], + let point = skyToScreen(azimuth: center.azimuth, altitude: center.altitude) else { + continue + } + + let color = isSelected ? UIColor(red: 1, green: 0.84, blue: 0, alpha: 1) : UIColor(red: 0.67, green: 0.73, blue: 1.0, alpha: 1) + let font = UIFont.italicSystemFont(ofSize: 16) + let text = constellation.getDisplayName() + let size = text.size(withAttributes: [.font: font]) + let rect = CGRect(x: point.x - size.width / 2 - 10, y: point.y - 10, width: size.width + 20, height: size.height + 20) + let overlaps = occupiedRects.contains { $0.intersects(rect) } + if !overlaps || isSelected { + drawText(text, at: CGPoint(x: point.x, y: point.y + size.height), color: color, font: font, align: .center) + occupiedRects.append(rect) + } + } + } + + private func constellationCenters() -> [String: ConstellationCenter] { + if constellationCenterCache.isEmpty && !constellations.isEmpty { + rebuildConstellationCenterCache() + } + return constellationCenterCache + } + + private func rebuildConstellationCenterCache() { + guard !constellations.isEmpty else { + constellationCenterCache.removeAll(keepingCapacity: true) + return + } + + var objectMap: [Int: SkyObject] = [:] + for object in skyObjects where object.hip > 0 { + objectMap[object.hip] = object + } + + var centers: [String: ConstellationCenter] = [:] + for constellation in constellations { + guard let center = AstroUtils.calculateConstellationCenter(constellation, skyObjectMap: objectMap) else { + continue + } + let ra = center.0 + let dec = center.1 + let hor = AstronomyKt.horizon(time: currentTime, observer: observer, ra: ra, dec: dec, refraction: Refraction.normal) + constellation.ra = ra + constellation.dec = dec + centers[constellation.id] = ConstellationCenter(ra: ra, + dec: dec, + azimuth: hor.azimuth, + altitude: hor.altitude, + targetAzimuth: hor.azimuth, + targetAltitude: hor.altitude) + } + constellationCenterCache = centers + } + + private func drawCelestialPaths(in context: CGContext) { + var objects: [SkyObject] = [] + if let selectedObject { + objects.append(selectedObject) + } + if settings.starMap.showCelestialPaths { + objects.append(contentsOf: pinnedObjects) + objects.append(contentsOf: skyObjects.filter { $0.showCelestialPath }) + } + + var seenIds: Set = [] + for object in objects where seenIds.insert(object.id).inserted && isObjectVisibleInSettings(object) { + drawCelestialPath(for: object, in: context) + } + } + + private func drawCelestialPath(for object: SkyObject, in context: CGContext) { + guard let data = pathData(for: object), data.azimuths.count > 1 else { + return + } + + let drawCount = object.type == .MOON ? data.azimuths.count : min(data.azimuths.count, 145) + let path = CGMutablePath() + var penDown = false + var previousPoint: CGPoint? + + for index in 0.. bounds.width * 0.8 { + penDown = false + } + + if penDown { + path.addLine(to: point) + } else { + path.move(to: point) + penDown = true + } + previousPoint = point + } + stroke(path, color: .cyan, width: 1.1, dash: [5, 8], in: context) + + var drawnLabels: Set = [] + for index in 0.. 0 && distPrevious > 200) || (index < drawCount - 1 && distNext > 200) { + continue + } + + let angle = atan2(next.y - previous.y, next.x - previous.x) + let labelAngle = Double(angle - .pi / 2) + let labelPoint = CGPoint(x: point.x + 30 * CGFloat(cos(labelAngle)), + y: point.y + 30 * CGFloat(sin(labelAngle)) + 8) + drawText(label, at: labelPoint, color: .cyan, font: .systemFont(ofSize: 12), align: .center) + drawArrow(at: point, angle: angle, in: context) + } + } + + private func pathData(for object: SkyObject) -> CelestialPathData? { + let time = currentTime + let obs = observer + if let cached = pathCache[object.id], + abs(time.tt - cached.lastTime) < 0.0000001, + obs.latitude == cached.lastLat, + obs.longitude == cached.lastLon { + return cached + } + + var azimuths: [Double] = [] + var altitudes: [Double] = [] + var labels: [String?] = [] + let calendar = Calendar.current + let currentDate = Date(timeIntervalSince1970: TimeInterval(time.toMillisecondsSince1970()) / 1000.0) + guard let startCandidate = calendar.date(byAdding: .hour, value: -12, to: currentDate), + let start = calendar.date(from: calendar.dateComponents([.year, .month, .day, .hour], from: startCandidate)), + let maxDate = calendar.date(byAdding: .hour, value: 14, to: currentDate) else { + return nil + } + + for step in 0..<(25 * 6 + 1) { + let stepDate = start.addingTimeInterval(TimeInterval(step * 10 * 60)) + if stepDate > maxDate { + break + } + let stepTime = AstroUtils.astronomyTime(from: stepDate) + guard let horizontal = horizontalPosition(for: object, time: stepTime, observer: obs) else { + continue + } + azimuths.append(horizontal.azimuth) + altitudes.append(horizontal.altitude) + let minute = calendar.component(.minute, from: stepDate) + let hour = calendar.component(.hour, from: stepDate) + labels.append(minute == 0 ? String(format: "%02d", hour) : nil) + } + + let data = CelestialPathData(azimuths: azimuths, altitudes: altitudes, labels: labels, lastTime: time.tt, lastLat: obs.latitude, lastLon: obs.longitude) + pathCache[object.id] = data + return data + } + + private func horizontalPosition(for object: SkyObject, time: Time, observer: Observer) -> Topocentric? { + if let body = object.body { + let equatorial = AstronomyKt.equator(body: body, time: time, observer: observer, equdate: EquatorEpoch.ofdate, aberration: Aberration.corrected) + object.distAu = equatorial.dist + return AstronomyKt.horizon(time: time, observer: observer, ra: equatorial.ra, dec: equatorial.dec, refraction: Refraction.normal) + } + return AstronomyKt.horizon(time: time, observer: observer, ra: object.ra, dec: object.dec, refraction: Refraction.normal) + } + + private func recalculatePositions(time: Time, updateTargets: Bool, force: Bool = false) { + for object in skyObjects where shouldRecalculate(object) { + calculatePosition(object, time: time, updateTargets: updateTargets, force: force) + } + + if constellationCenterCache.isEmpty && !constellations.isEmpty { + rebuildConstellationCenterCache() + } + for constellation in constellations { + guard var center = constellationCenterCache[constellation.id] else { + constellation.azimuth = 0 + constellation.altitude = 0 + constellation.targetAzimuth = 0 + constellation.targetAltitude = 0 + continue + } + let horizontal = AstronomyKt.horizon(time: time, observer: observer, ra: center.ra, dec: center.dec, refraction: Refraction.normal) + constellation.ra = center.ra + constellation.dec = center.dec + if updateTargets { + center.targetAzimuth = horizontal.azimuth + center.targetAltitude = horizontal.altitude + constellation.targetAzimuth = horizontal.azimuth + constellation.targetAltitude = horizontal.altitude + } else { + center.azimuth = horizontal.azimuth + center.altitude = horizontal.altitude + center.targetAzimuth = horizontal.azimuth + center.targetAltitude = horizontal.altitude + constellation.azimuth = horizontal.azimuth + constellation.altitude = horizontal.altitude + constellation.targetAzimuth = horizontal.azimuth + constellation.targetAltitude = horizontal.altitude + } + constellationCenterCache[constellation.id] = center + } + } + + private func updateConstellationObjectPositions() { + for constellation in constellations { + guard let center = constellationCenterCache[constellation.id] else { + constellation.azimuth = 0 + constellation.altitude = 0 + constellation.targetAzimuth = 0 + constellation.targetAltitude = 0 + continue + } + constellation.ra = center.ra + constellation.dec = center.dec + constellation.azimuth = center.azimuth + constellation.altitude = center.altitude + constellation.targetAzimuth = center.targetAzimuth + constellation.targetAltitude = center.targetAltitude + } + } + + func calculatePosition(_ object: SkyObject) { + calculatePosition(object, time: currentTime, updateTargets: false) + } + + private func calculatePosition(_ object: SkyObject, time: Time, updateTargets: Bool, force: Bool = false) { + if !force && object.lastUpdateTime == time.tt && !updateTargets { + return + } + guard let horizontal = horizontalPosition(for: object, time: time, observer: observer) else { + return + } + if updateTargets { + object.targetAzimuth = horizontal.azimuth + object.targetAltitude = horizontal.altitude + } else { + object.azimuth = horizontal.azimuth + object.altitude = horizontal.altitude + object.targetAzimuth = horizontal.azimuth + object.targetAltitude = horizontal.altitude + object.lastUpdateTime = time.tt + } + } + + private func shouldRecalculate(_ object: SkyObject) -> Bool { + if object === selectedObject { + return true + } + if settings.starMap.showCelestialPaths && pinnedObjects.contains(object) { + return true + } + if settings.starMap.showConstellations { + return true + } + if selectedConstellationStarIds.contains(object.hip) { + return true + } + return isObjectVisibleInSettings(object) + } + + private func drawDirectionArrows(in context: CGContext) { + guard settings.starMap.showDirections else { + return + } + + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let radius = min(bounds.width, bounds.height) * 0.42 + for object in trackableObjects where object.showDirection && isObjectVisibleInSettings(object) { + guard let projected = skyToScreen(azimuth: object.azimuth, altitude: object.altitude, allowAnyOffScreen: true), + !bounds.contains(projected) else { + continue + } + + let angle = atan2(projected.y - center.y, projected.x - center.x) + let point = CGPoint(x: center.x + radius * CGFloat(cos(Double(angle))), + y: center.y + radius * CGFloat(sin(Double(angle)))) + drawDirectionArrow(at: point, angle: angle, color: directionColor(object.colorIndex), in: context) + } + } + + private func drawArrow(at point: CGPoint, angle: CGFloat, in context: CGContext) { + context.saveGState() + context.translateBy(x: point.x, y: point.y) + context.rotate(by: angle) + let path = CGMutablePath() + path.move(to: CGPoint(x: 10, y: 0)) + path.addLine(to: CGPoint(x: -10, y: -6)) + path.addLine(to: CGPoint(x: -10, y: 6)) + path.closeSubpath() + UIColor.cyan.setFill() + context.addPath(path) + context.fillPath() + context.restoreGState() + } + + private func drawDirectionArrow(at point: CGPoint, angle: CGFloat, color: UIColor, in context: CGContext) { + context.saveGState() + context.translateBy(x: point.x, y: point.y) + context.rotate(by: angle) + let path = CGMutablePath() + path.move(to: CGPoint(x: 18, y: 0)) + path.addLine(to: CGPoint(x: -14, y: -12)) + path.addLine(to: CGPoint(x: -7, y: 0)) + path.addLine(to: CGPoint(x: -14, y: 12)) + path.closeSubpath() + color.setFill() + context.addPath(path) + context.fillPath() + context.restoreGState() + } + + private func appendSkyLine(to path: CGMutablePath, + range: StrideThrough, + coordinate: (Double) -> (Double, Double)) { + var first = true + for value in range { + let coord = coordinate(value) + guard let point = skyToScreen(azimuth: coord.0, altitude: coord.1) else { + first = true + continue + } + if first { + path.move(to: point) + first = false + } else { + path.addLine(to: point) + } + } + } + + private func cachedLinePath(azimuths: [Double], altitudes: [Double]) -> CGPath { + let path = CGMutablePath() + var first = true + for index in 0.. 0.1 { + labelPoint = CGPoint(x: center.x + dx * (distance + offset) / distance, + y: center.y + dy * (distance + offset) / distance) + } else { + labelPoint = CGPoint(x: point.x, y: point.y - offset) + } + } else { + labelPoint = CGPoint(x: point.x, y: point.y - offset) + } + drawText(label, at: labelPoint, color: color, font: .boldSystemFont(ofSize: 20), align: .center) + } + + private func drawGridLabel(_ label: String, azimuth: Double, altitude: Double, align: NSTextAlignment, color: UIColor, in context: CGContext) { + guard let point = skyToScreen(azimuth: azimuth, altitude: altitude), + bounds.contains(point) else { + return + } + drawText(label, at: CGPoint(x: point.x + 5, y: point.y - 12), color: color, font: .systemFont(ofSize: 11), align: align) + } + + private func drawText(_ text: String, at point: CGPoint, color: UIColor, font: UIFont, align: NSTextAlignment) { + let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] + let size = text.size(withAttributes: attributes) + let origin: CGPoint + switch align { + case .center: + origin = CGPoint(x: point.x - size.width / 2, y: point.y - size.height / 2) + case .right: + origin = CGPoint(x: point.x - size.width, y: point.y - size.height / 2) + default: + origin = CGPoint(x: point.x, y: point.y - size.height / 2) + } + text.draw(at: origin, withAttributes: attributes) + } + + private func isObjectVisibleInSettings(_ object: SkyObject) -> Bool { + if settings.starMap.showFavorites && object.isFavorite { + return true + } + if object.type == .STAR, + settings.starMap.showStars, + let maxMagnitude = settings.starMap.magnitudeFilter, + object.magnitude > maxMagnitude { + return false + } + switch object.type { + case .STAR: + return settings.starMap.showStars + case .GALAXY: + return settings.starMap.showGalaxies + case .BLACK_HOLE: + return settings.starMap.showBlackHoles + case .SUN: + return settings.starMap.showSun + case .MOON: + return settings.starMap.showMoon + case .PLANET: + return settings.starMap.showPlanets + case .NEBULA: + return settings.starMap.showNebulae + case .OPEN_CLUSTER: + return settings.starMap.showOpenClusters + case .GLOBULAR_CLUSTER: + return settings.starMap.showGlobularClusters + case .GALAXY_CLUSTER: + return settings.starMap.showGalaxyClusters + case .CONSTELLATION: + return settings.starMap.showConstellations + } + } + + private func directionColor(_ index: Int) -> UIColor { + let colors = AstronomyPluginSettings.DirectionColor.allCases + return colors[index % colors.count].color + } + + private func updateProjectionCache() { + guard bounds.width > 0 && bounds.height > 0 else { + return + } + + let altitudeCenterRad = centerAltitude * .pi / 180.0 + projectionSinAltCenter = sin(altitudeCenterRad) + projectionCosAltCenter = cos(altitudeCenterRad) + let viewAngleRad = viewAngle * .pi / 180.0 + projectionScale = Double(bounds.width) / (4.0 * tan(viewAngleRad / 4.0)) + projectionHalfWidth = Double(bounds.width) / 2.0 + projectionHalfHeight = Double(bounds.height) / 2.0 + + let cx = projectionHalfWidth + Double(panX) + let cy = projectionHalfHeight + Double(panY) + let width = Double(bounds.width) + let height = Double(bounds.height) + let d1Sq = cx * cx + cy * cy + let d2Sq = (cx - width) * (cx - width) + cy * cy + let d3Sq = (cx - width) * (cx - width) + (cy - height) * (cy - height) + let d4Sq = cx * cx + (cy - height) * (cy - height) + let maxDistSq = max(d1Sq, max(d2Sq, max(d3Sq, d4Sq))) + let maxTanHalf = sqrt(maxDistSq) / (2.0 * projectionScale) + let t2 = maxTanHalf * maxTanHalf + minCosCVisible = (1.0 - t2) / (1.0 + t2) + } + + private func skyToScreen(azimuth: Double, + altitude: Double, + allowLimitedOffScreen: Bool = false, + allowAnyOffScreen: Bool = false) -> CGPoint? { + if abs(altitude - centerAltitude) > viewAngle + 40 && !allowLimitedOffScreen && !allowAnyOffScreen { + return nil + } + + let azRad = (azimuth - centerAzimuth) * .pi / 180.0 + let altRad = altitude * .pi / 180.0 + let sinAlt = sin(altRad) + let cosAlt = cos(altRad) + let sinAz = sin(azRad) + let cosAz = cos(azRad) + let cosC = projectionSinAltCenter * sinAlt + projectionCosAltCenter * cosAlt * cosAz + if settings.starMap.is2DMode && cosC <= -0.3 { + return nil + } + if !allowLimitedOffScreen && !allowAnyOffScreen && cosC < minCosCVisible { + return nil + } + if allowLimitedOffScreen && !allowAnyOffScreen && cosC <= -0.2 { + return nil + } + + let k = 2.0 / (1.0 + cosC) + let combinedScale = k * projectionScale + let xRaw = cosAlt * sinAz + let yRaw = projectionCosAltCenter * sinAlt - projectionSinAltCenter * cosAlt * cosAz + var xScaled = combinedScale * xRaw + let yScaled = -combinedScale * yRaw + if settings.starMap.is2DMode { + xScaled = -xScaled + } + + let rollRad = roll * .pi / 180.0 + let xRot = xScaled * cos(rollRad) - yScaled * sin(rollRad) + let yRot = xScaled * sin(rollRad) + yScaled * cos(rollRad) + return CGPoint(x: CGFloat(projectionHalfWidth + xRot + Double(panX)), + y: CGFloat(projectionHalfHeight + yRot + Double(panY))) + } + + private func updateViewAngle(_ newAngle: Double, focus: CGPoint? = nil) { + let finalAngle = max(ViewAngleBounds.min, min(maxViewAngle, newAngle)) + guard abs(viewAngle - finalAngle) > 0.001, bounds.width > 0, bounds.height > 0 else { + return + } + + cancelFocusAnimation() + let focusPoint = focus ?? CGPoint(x: bounds.midX, y: bounds.midY) + if settings.starMap.is2DMode { + let oldTan = tan(viewAngle * .pi / 180.0 / 4.0) + let newTan = tan(finalAngle * .pi / 180.0 / 4.0) + if oldTan > 0 && newTan > 0 { + let ratio = CGFloat(oldTan / newTan) + panX = focusPoint.x - bounds.midX - (focusPoint.x - bounds.midX - panX) * ratio + panY = focusPoint.y - bounds.midY - (focusPoint.y - bounds.midY - panY) * ratio + } + } else { + let oldScale = viewAngle / Double(bounds.width) + let newScale = finalAngle / Double(bounds.width) + let offX = Double(focusPoint.x - bounds.midX) + let offY = Double(focusPoint.y - bounds.midY) + centerAzimuth = normalizedDegrees(centerAzimuth + offX * (oldScale - newScale)) + centerAltitude = max(-90, min(90, centerAltitude - offY * (oldScale - newScale))) + } + viewAngle = finalAngle + onViewAngleChangeListener?(viewAngle) + setNeedsDisplay() + } + + @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { + let point = recognizer.location(in: self) + switch recognizer.state { + case .began: + cancelFocusAnimation() + lastTouchPoint = point + isPanning = false + case .changed: + let dx = point.x - lastTouchPoint.x + let dy = point.y - lastTouchPoint.y + if isCameraMode && hypot(dx, dy) > 10 { + isPanning = true + } else if hypot(dx, dy) > 0 || isPanning { + isPanning = true + if settings.starMap.is2DMode { + panX += dx + panY += dy + } else { + let scale = viewAngle / Double(max(bounds.width, 1)) + centerAzimuth = normalizedDegrees(centerAzimuth - Double(dx) * scale) + centerAltitude = max(-90, min(90, centerAltitude + Double(dy) * scale)) + onAzimuthManualChangeListener?(centerAzimuth) + } + lastTouchPoint = point + setNeedsDisplay() + } + default: + isPanning = false + } + } + + @objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) { + guard recognizer.state == .changed || recognizer.state == .ended else { + return + } + cancelFocusAnimation() + updateViewAngle(viewAngle / Double(recognizer.scale), focus: recognizer.location(in: self)) + recognizer.scale = 1 + } + + @objc private func handleTap(_ recognizer: UITapGestureRecognizer) { + guard !isPanning else { + return + } + cancelFocusAnimation() + performClick(at: recognizer.location(in: self)) + } + + private func performClick(at point: CGPoint) { + let clickRadius = pixelsToPoints(60) + var bestObject: SkyObject? + var bestObjectDistance = CGFloat.greatestFiniteMagnitude + for object in skyObjects where isObjectVisibleInSettings(object) { + guard let objectPoint = skyToScreen(azimuth: object.azimuth, altitude: object.altitude) else { + continue + } + let distance = hypot(point.x - objectPoint.x, point.y - objectPoint.y) + if distance < clickRadius && distance < bestObjectDistance { + bestObject = object + bestObjectDistance = distance + } + } + + if let bestObject { + setSelectedObject(bestObject) + delegate?.starView(self, didSelect: bestObject) + onObjectClickListener?(bestObject) + onConstellationClickListener?(nil) + return + } + + if settings.starMap.showConstellations || selectedConstellationId != nil { + var bestConstellation: Constellation? + var bestDistance = CGFloat.greatestFiniteMagnitude + let centers = constellationCenters() + for constellation in constellations { + let isSelected = constellation.id == selectedConstellationId + if !settings.starMap.showConstellations && !isSelected { + continue + } + + for (firstId, secondId) in constellation.lines { + guard let first = skyObjectMap[String(firstId)], + let second = skyObjectMap[String(secondId)], + let p1 = skyToScreen(azimuth: first.azimuth, altitude: first.altitude), + let p2 = skyToScreen(azimuth: second.azimuth, altitude: second.altitude) else { + continue + } + let distance = distanceFrom(point: point, toSegmentStart: p1, end: p2) + if distance < clickRadius && distance < bestDistance { + bestDistance = distance + bestConstellation = constellation + } + } + + if let center = centers[constellation.id], + let centerPoint = skyToScreen(azimuth: center.azimuth, altitude: center.altitude) { + let distance = hypot(point.x - centerPoint.x, point.y - centerPoint.y) + if distance < clickRadius && distance < bestDistance { + bestDistance = distance + bestConstellation = constellation + } + } + } + + if let bestConstellation { + setSelectedConstellation(bestConstellation) + delegate?.starView(self, didSelect: bestConstellation) + onObjectClickListener?(nil) + onConstellationClickListener?(bestConstellation) + return + } + } + + setSelectedObject(nil) + delegate?.starView(self, didSelect: nil) + onObjectClickListener?(nil) + onConstellationClickListener?(nil) + } + + private func distanceFrom(point: CGPoint, toSegmentStart start: CGPoint, end: CGPoint) -> CGFloat { + let segmentX = end.x - start.x + let segmentY = end.y - start.y + let lenSq = segmentX * segmentX + segmentY * segmentY + let parameter = lenSq == 0 ? -1 : ((point.x - start.x) * segmentX + (point.y - start.y) * segmentY) / lenSq + let closest: CGPoint + if parameter < 0 { + closest = start + } else if parameter > 1 { + closest = end + } else { + closest = CGPoint(x: start.x + parameter * segmentX, y: start.y + parameter * segmentY) + } + return hypot(point.x - closest.x, point.y - closest.y) + } + + private func angularDistance(ra1: Double, dec1: Double, ra2: Double, dec2: Double) -> Double { + let phi1 = dec1 * .pi / 180.0 + let phi2 = dec2 * .pi / 180.0 + let lambda1 = ra1 * 15.0 * .pi / 180.0 + let lambda2 = ra2 * 15.0 * .pi / 180.0 + let cosD = sin(phi1) * sin(phi2) + cos(phi1) * cos(phi2) * cos(lambda1 - lambda2) + return acos(max(-1, min(1, cosD))) * 180.0 / .pi + } + + private func interpolateAngle(start: Double, end: Double, fraction: Double) -> Double { + var diff = end - start + while diff > 180 { + diff -= 360 + } + while diff < -180 { + diff += 360 + } + var result = start + diff * fraction + while result < 0 { + result += 360 + } + while result >= 360 { + result -= 360 + } + return result + } + + private func normalizedDegrees(_ degrees: Double) -> Double { + var value = degrees.truncatingRemainder(dividingBy: 360) + if value < 0 { + value += 360 + } + return value + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroArticleViewController.swift b/Sources/Plugins/Astronomy/contextmenu/AstroArticleViewController.swift new file mode 100644 index 0000000000..44a8c2aa24 --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroArticleViewController.swift @@ -0,0 +1,307 @@ +// +// AstroArticleViewController.swift +// OsmAnd Maps +// +// Ported from the Android astronomy article dialog. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit +import WebKit + +final class AstroArticleViewController: UIViewController { + static let tag = "AstroArticleViewController" + private static let headerInner = """ + + + + + + """ + private static let footerInner = """ + + + """ + private static let bodyContentRegex = try? NSRegularExpression(pattern: "]*>([\\s\\S]*?)", + options: [.caseInsensitive]) + + private let article: AstroArticle + private var articleHtml: String? + private let webView = WKWebView(frame: .zero) + private let titleLabel = UILabel() + private let emptyStateLabel = UILabel() + private let readFullArticleButton = UIButton(type: .system) + private var webViewClient: AstroArticleWebViewClient? + private var htmlLoadToken: UUID? + + init(article: AstroArticle) { + self.article = article + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .pageSheet + if let sheetPresentationController { + sheetPresentationController.detents = [.large()] + sheetPresentationController.prefersGrabberVisible = true + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + htmlLoadToken = nil + } + + static func showInstance(from viewController: UIViewController, article: AstroArticle) -> Bool { + let viewControllerToPresent = AstroArticleViewController(article: article) + viewController.present(viewControllerToPresent, animated: true) + return true + } + + override func viewDidLoad() { + super.viewDidLoad() + applyTheme() + setupToolbar() + setupWebView() + populateArticle() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true { + applyTheme() + if let articleHtml = articleHtml, !articleHtml.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + webView.loadHTMLString(createHtmlContent(articleHtml: articleHtml), baseURL: articleBaseURL()) + } + } + } + + private func applyTheme() { + view.backgroundColor = AstroContextMenuTheme.pageBackground + titleLabel.textColor = AstroContextMenuTheme.primaryText + readFullArticleButton.tintColor = .white + readFullArticleButton.backgroundColor = AstroContextMenuTheme.primaryButton + webView.backgroundColor = AstroContextMenuTheme.pageBackground + emptyStateLabel.textColor = AstroContextMenuTheme.secondaryText + } + + private func setupToolbar() { + let toolbar = UIView() + toolbar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(toolbar) + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.textColor = AstroContextMenuTheme.primaryText + titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + titleLabel.numberOfLines = 2 + toolbar.addSubview(titleLabel) + + let closeButton = UIButton(type: .system) + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeButton.setImage(.icCustomClose, for: .normal) + closeButton.tintColor = AstroContextMenuTheme.secondaryIcon + closeButton.addAction(UIAction { [weak self] _ in + self?.dismiss(animated: true) + }, for: .touchUpInside) + toolbar.addSubview(closeButton) + + readFullArticleButton.translatesAutoresizingMaskIntoConstraints = false + readFullArticleButton.setTitle(localizedString("context_menu_read_full_article"), for: .normal) + readFullArticleButton.setImage(AstroIcon.template("ic_world_globe_dark"), for: .normal) + readFullArticleButton.tintColor = .white + readFullArticleButton.backgroundColor = AstroContextMenuTheme.primaryButton + readFullArticleButton.layer.cornerRadius = 10 + readFullArticleButton.contentEdgeInsets = UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14) + readFullArticleButton.addAction(UIAction { [weak self] _ in + self?.openFullArticle() + }, for: .touchUpInside) + view.addSubview(readFullArticleButton) + + NSLayoutConstraint.activate([ + toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + toolbar.heightAnchor.constraint(equalToConstant: 60), + + titleLabel.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor, constant: 20), + titleLabel.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -12), + titleLabel.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor), + + closeButton.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor, constant: -12), + closeButton.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor), + closeButton.widthAnchor.constraint(equalToConstant: 44), + closeButton.heightAnchor.constraint(equalToConstant: 44), + + readFullArticleButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + readFullArticleButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + readFullArticleButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12), + readFullArticleButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44) + ]) + } + + private func setupWebView() { + webView.translatesAutoresizingMaskIntoConstraints = false + webView.backgroundColor = AstroContextMenuTheme.pageBackground + webView.isOpaque = false + webView.configuration.preferences.javaScriptEnabled = true + view.addSubview(webView) + + emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false + emptyStateLabel.text = localizedString("shared_string_unavailable") + emptyStateLabel.textAlignment = .center + emptyStateLabel.font = .systemFont(ofSize: 16) + emptyStateLabel.numberOfLines = 0 + emptyStateLabel.isHidden = true + view.addSubview(emptyStateLabel) + + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 60), + webView.bottomAnchor.constraint(equalTo: readFullArticleButton.topAnchor, constant: -12), + + emptyStateLabel.leadingAnchor.constraint(equalTo: webView.leadingAnchor, constant: 24), + emptyStateLabel.trailingAnchor.constraint(equalTo: webView.trailingAnchor, constant: -24), + emptyStateLabel.centerYAnchor.constraint(equalTo: webView.centerYAnchor) + ]) + } + + private func populateArticle() { + titleLabel.text = article.title + let onlineArticleUrl = article.getOnlineArticleUrl() + webViewClient = AstroArticleWebViewClient(sourceView: webView, articleUrl: onlineArticleUrl, presenter: self) + webView.navigationDelegate = webViewClient + + readFullArticleButton.isHidden = onlineArticleUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false + loadArticleHtml() + } + + private func loadArticleHtml() { + let token = UUID() + htmlLoadToken = token + webView.isHidden = false + emptyStateLabel.isHidden = true + + DispatchQueue.global(qos: .userInitiated).async { [article] in + let html = article.getMobileHtmlString() + DispatchQueue.main.async { [weak self] in + guard let self = self, self.htmlLoadToken == token else { + return + } + self.htmlLoadToken = nil + guard let html = html, !html.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + self.showEmptyState() + return + } + self.articleHtml = html + self.webView.isHidden = false + self.emptyStateLabel.isHidden = true + self.webView.loadHTMLString(self.createHtmlContent(articleHtml: html), baseURL: self.articleBaseURL()) + } + } + } + + private func showEmptyState() { + webView.isHidden = true + emptyStateLabel.isHidden = false + } + + private func createHtmlContent(articleHtml: String) -> String { + let isRtl = Locale.characterDirection(forLanguage: article.lang) == .rightToLeft + let bodyTag = isRtl ? "\n" : "\n" + let bodyContent = extractBodyContent(articleHtml) + let nightModeClass = ThemeManager.shared.isLightTheme() ? "" : " nightmode" + var header = Self.headerInner + let css = articleStyleCss() + header = header.replacingOccurrences(of: "{{css-file-content}}", with: css) + return """ + \(header) + \(bodyTag) +
+ \(bodyContent) + \(Self.footerInner) + """ + } + + private func articleStyleCss() -> String { + guard let cssURL = Bundle.main.url(forResource: "article_style", withExtension: "css"), + let css = try? String(contentsOf: cssURL, encoding: .utf8) else { + return "" + } + return css.replacingOccurrences(of: "\n", with: " ") + } + + private func articleBaseURL() -> URL? { + article.getOnlineArticleUrl().flatMap(URL.init(string:)) + } + + private func extractBodyContent(_ html: String) -> String { + guard let regex = Self.bodyContentRegex else { + return html + } + let range = NSRange(html.startIndex.. 1, + let bodyRange = Range(match.range(at: 1), in: html) else { + return html + } + return String(html[bodyRange]) + } + + private func openFullArticle() { + guard let string = article.getOnlineArticleUrl(), + let url = URL(string: string) else { + return + } + UIApplication.shared.open(url) + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroArticleWebViewClient.swift b/Sources/Plugins/Astronomy/contextmenu/AstroArticleWebViewClient.swift new file mode 100644 index 0000000000..bb2b124b13 --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroArticleWebViewClient.swift @@ -0,0 +1,97 @@ +// +// AstroArticleWebViewClient.swift +// OsmAnd Maps +// +// Ported from Android AstroArticleWebViewClient.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit +import WebKit + +final class AstroArticleWebViewClient: NSObject, WKNavigationDelegate { + private let sourceView: UIView + private let articleUrl: String? + private weak var presenter: UIViewController? + + init(sourceView: UIView, articleUrl: String?, presenter: UIViewController) { + self.sourceView = sourceView + self.articleUrl = articleUrl + self.presenter = presenter + super.init() + } + + func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { + guard navigationAction.navigationType == .linkActivated else { + decisionHandler(.allow) + return + } + let rawUrl = navigationAction.request.url?.absoluteString + decisionHandler(handleUrl(rawUrl) ? .cancel : .allow) + } + + private func handleUrl(_ rawUrl: String?) -> Bool { + guard let rawUrl, + !rawUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + let url = OAWikiArticleHelper.normalizeFileUrl(rawUrl) ?? rawUrl + if url.hasPrefix("#") || isSamePageAnchor(url) { + return false + } + if shouldAllowInternalLoad(url) { + return false + } + if url.hasPrefix("http://") || url.hasPrefix("https://") { + warnAboutExternalLoad(url) + return true + } + guard let parsed = URL(string: url) else { + return true + } + UIApplication.shared.open(parsed) + return true + } + + private func warnAboutExternalLoad(_ url: String) { + let alert = UIAlertController(title: url, + message: localizedString("online_webpage_warning"), + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), + style: .cancel, + handler: nil)) + alert.addAction(UIAlertAction(title: localizedString("shared_string_ok"), + style: .default) { _ in + guard let urlObject = URL(string: url) else { + return + } + UIApplication.shared.open(urlObject) + }) + + if let popoverPresentationController = alert.popoverPresentationController { + popoverPresentationController.sourceView = sourceView + popoverPresentationController.permittedArrowDirections = .any + } + presenter?.present(alert, animated: true) + } + + private func shouldAllowInternalLoad(_ url: String) -> Bool { + if url == "about:blank" { + return true + } + guard let scheme = URL(string: url)?.scheme?.lowercased() else { + return false + } + return scheme == "file" || scheme == "data" || scheme == "applewebdata" + } + + private func isSamePageAnchor(_ url: String) -> Bool { + guard url.contains("#"), + let currentUrl = articleUrl.flatMap({ OAWikiArticleHelper.normalizeFileUrl($0) })?.components(separatedBy: "#").first else { + return false + } + return url.components(separatedBy: "#").first == currentUrl + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroBottomSheetBehavior.swift b/Sources/Plugins/Astronomy/contextmenu/AstroBottomSheetBehavior.swift new file mode 100644 index 0000000000..f9b45e30d7 --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroBottomSheetBehavior.swift @@ -0,0 +1,45 @@ +// +// AstroBottomSheetBehavior.swift +// OsmAnd Maps +// +// Ported from Android AstroBottomSheetBehavior.kt. +// UIKit owns the actual sheet sizing on iOS; this object keeps the Android +// behavior state names available to the migrated context menu. +// + +import UIKit + +final class AstroBottomSheetBehavior { + enum State { + case hidden + case collapsed + case expanded + } + + private weak var view: ViewType? + private(set) var state: State = .collapsed + var isHideable = true + var skipCollapsed = false + var isFitToContents = true + var expandedOffset: CGFloat = 0 + var isDraggable = true + var peekHeight: CGFloat = 0 + + init(view: ViewType) { + self.view = view + } + + func setLockedNestedScrollTargetId(_ id: String) { + } + + func setState(_ state: State) { + self.state = state + switch state { + case .hidden: + view?.isHidden = true + case .collapsed, .expanded: + view?.isHidden = false + } + } +} + diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroCatalogsCardViewHolder.swift b/Sources/Plugins/Astronomy/contextmenu/AstroCatalogsCardViewHolder.swift new file mode 100644 index 0000000000..e094ba84fe --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroCatalogsCardViewHolder.swift @@ -0,0 +1,95 @@ +// +// AstroCatalogsCardViewHolder.swift +// OsmAnd Maps +// +// Ported from Android AstroCatalogsCardViewHolder.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum AstroCatalogsCardViewHolder { + private static let maxVisible = 5 + + static func makeView(item: AstroCatalogsCardItem, + onToggleExpanded: @escaping () -> Void, + onCatalogClick: @escaping (Catalog) -> Void) -> UIView { + let card = AstroCardContainerView(title: localizedString("astro_designations")) + let chips = WrappingChipsView() + let needShowMore = item.catalogs.count > maxVisible + let visible = !item.expanded && needShowMore ? Array(item.catalogs.prefix(maxVisible)) : item.catalogs + visible.forEach { catalog in + chips.addChip(title: catalog.catalogId) { + onCatalogClick(catalog) + } + } + if needShowMore { + chips.addChip(title: item.expanded + ? localizedString("shared_string_show_less") + : localizedString("shared_string_ellipsis")) { + onToggleExpanded() + } + } + card.stack.addArrangedSubview(chips) + return card + } +} + +final class WrappingChipsView: UIView { + private var chips: [UIButton] = [] + + func addChip(title: String, action: @escaping () -> Void) { + var config = UIButton.Configuration.filled() + config.title = title + config.baseBackgroundColor = AstroContextMenuTheme.actionBackground + config.baseForegroundColor = AstroContextMenuTheme.activeText + config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12) + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = .systemFont(ofSize: 14) + outgoing.foregroundColor = AstroContextMenuTheme.activeText + return outgoing + } + let button = UIButton(configuration: config) + button.layer.cornerRadius = 19 + button.layer.masksToBounds = true + button.addAction(UIAction { _ in action() }, for: .touchUpInside) + chips.append(button) + addSubview(button) + invalidateIntrinsicContentSize() + } + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: layoutHeight(for: bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width - 64)) + } + + override func layoutSubviews() { + super.layoutSubviews() + _ = layoutHeight(for: bounds.width, apply: true) + } + + private func layoutHeight(for width: CGFloat, apply: Bool = false) -> CGFloat { + guard width > 0 else { + return 0 + } + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + let spacing: CGFloat = 10 + for chip in chips { + let size = chip.sizeThatFits(CGSize(width: width, height: 38)) + let chipSize = CGSize(width: min(width, ceil(size.width)), height: 38) + if x > 0 && x + chipSize.width > width { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + if apply { + chip.frame = CGRect(origin: CGPoint(x: x, y: y), size: chipSize) + } + x += chipSize.width + spacing + rowHeight = max(rowHeight, chipSize.height) + } + return y + rowHeight + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroChartUtils.swift b/Sources/Plugins/Astronomy/contextmenu/AstroChartUtils.swift new file mode 100644 index 0000000000..5cf0c2911e --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroChartUtils.swift @@ -0,0 +1,261 @@ +// +// AstroChartUtils.swift +// OsmAnd Maps +// +// Ported from Android AstroChartUtils.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared +import UIKit + +struct AstroChartDaySamples { + let startMillis: Int64 + let endMillis: Int64 + let sunAltitudes: [Double] + let objectAltitudes: [Double] + let objectAzimuths: [Double]? +} + +struct AstroChartCulmination { + let time: Date? + let altitude: Double? +} + +enum AstroChartMath { + static let dayMinutes = 24 * 60 + static let scheduleSampleStepMinutes = 5 + static let visibilitySampleCount = dayMinutes / scheduleSampleStepMinutes + 1 + static let scheduleSampleCount = dayMinutes / scheduleSampleStepMinutes + 1 + + private struct HorizontalPoint { + let altitude: Double + let azimuth: Double + } + + static func computeDaySamples(objectToRender: SkyObject, + observer: Observer, + startLocal: Date, + endLocal: Date, + sampleCount: Int, + includeAzimuth: Bool) -> AstroChartDaySamples { + let safeSamples = max(sampleCount, 2) + let startMillis = millis(startLocal) + let endMillis = millis(endLocal) + let spanMillis = max(1, endMillis - startMillis) + var objectAltitudes = Array(repeating: 0.0, count: safeSamples) + var sunAltitudes = Array(repeating: 0.0, count: safeSamples) + var objectAzimuths = includeAzimuth ? Array(repeating: 0.0, count: safeSamples) : nil + + for index in 0.. AstroChartCulmination { + guard let coarseBest = sampleBestAltitudeTime(obj: obj, + observer: observer, + startLocal: startLocal, + endLocal: endLocal, + stepMinutes: culminationCoarseStepMinutes) else { + return AstroChartCulmination(time: nil, altitude: nil) + } + + var refineStart = coarseBest.addingTimeInterval(TimeInterval(-culminationCoarseStepMinutes * 60)) + var refineEnd = coarseBest.addingTimeInterval(TimeInterval(culminationCoarseStepMinutes * 60)) + if refineStart < startLocal { + refineStart = startLocal + } + if refineEnd > endLocal { + refineEnd = endLocal + } + + let fineResult = sampleBestAltitudeTime(obj: obj, + observer: observer, + startLocal: refineStart, + endLocal: refineEnd, + stepMinutes: culminationFineStepMinutes) + let culminationTime = fineResult ?? coarseBest + return AstroChartCulmination(time: culminationTime, + altitude: AstroUtils.altitude(obj, at: culminationTime, observer: observer)) + } + + private static func sampleBestAltitudeTime(obj: SkyObject, + observer: Observer, + startLocal: Date, + endLocal: Date, + stepMinutes: Int) -> Date? { + var cursor = startLocal + var bestTime: Date? + var bestAltitude = -Double.infinity + let step = TimeInterval(stepMinutes * 60) + while cursor <= endLocal { + let altitude = AstroUtils.altitude(obj, at: cursor, observer: observer) + if altitude > bestAltitude { + bestAltitude = altitude + bestTime = cursor + } + cursor = cursor.addingTimeInterval(step) + } + return bestTime + } + + private static func calculateObjectHorizontal(objectToRender: SkyObject, + date: Date, + observer: Observer) -> HorizontalPoint { + if let body = objectToRender.body { + return calculateBodyHorizontal(body: body, date: date, observer: observer) + } + return AstroUtils.withCustomStar(ra: objectToRender.ra, dec: objectToRender.dec) { body in + calculateBodyHorizontal(body: body, date: date, observer: observer) + } + } + + private static func calculateBodyHorizontal(body: Body, + date: Date, + observer: Observer) -> HorizontalPoint { + let time = AstroUtils.astronomyTime(from: date) + let equatorial = AstronomyKt.equator(body: body, + time: time, + observer: observer, + equdate: EquatorEpoch.ofdate, + aberration: Aberration.corrected) + let horizontal = AstronomyKt.horizon(time: time, + observer: observer, + ra: equatorial.ra, + dec: equatorial.dec, + refraction: Refraction.normal) + return HorizontalPoint(altitude: horizontal.altitude, azimuth: AstroUtils.normalizedDegrees(horizontal.azimuth)) + } + + private static func millis(_ date: Date) -> Int64 { + Int64((date.timeIntervalSince1970 * 1000.0).rounded()) + } + + private static let culminationCoarseStepMinutes = 10 + private static let culminationFineStepMinutes = 1 +} + +final class AstroChartColorPalette { + private let sunGt15: UIColor + private let sun6To15: UIColor + private let sun0To6: UIColor + private let sunM6To0: UIColor + private let sunM12ToM6: UIColor + private let sunLtM12: UIColor + let fillGt45: UIColor + let fill15To45: UIColor + let fill0To15: UIColor + let fillLt0: UIColor + + init(sunGt15: UIColor = UIColor(rgbValue: 0x80A0FF), + sun6To15: UIColor = UIColor(rgbValue: 0x668CFF), + sun0To6: UIColor = UIColor(rgbValue: 0x2E62FF), + sunM6To0: UIColor = UIColor(rgbValue: 0x0034CC), + sunM12ToM6: UIColor = UIColor(rgbValue: 0x00134D), + sunLtM12: UIColor = UIColor(rgbValue: 0x020D2C), + fillGt45: UIColor = UIColor(rgbValue: 0xF3FF5A), + fill15To45: UIColor = UIColor(rgbValue: 0xF7D750), + fill0To15: UIColor = UIColor(rgbValue: 0xFB5934), + fillLt0: UIColor = UIColor(rgbValue: 0x8E24AA)) { + self.sunGt15 = sunGt15 + self.sun6To15 = sun6To15 + self.sun0To6 = sun0To6 + self.sunM6To0 = sunM6To0 + self.sunM12ToM6 = sunM12ToM6 + self.sunLtM12 = sunLtM12 + self.fillGt45 = fillGt45 + self.fill15To45 = fill15To45 + self.fill0To15 = fill0To15 + self.fillLt0 = fillLt0 + } + + func colorForSunAltitude(_ altitude: Double) -> UIColor { + if altitude >= 15.0 { + return sunGt15 + } else if altitude >= 6.0 { + return sun6To15 + } else if altitude >= 0.0 { + return sun0To6 + } else if altitude >= -6.0 { + return sunM6To0 + } else if altitude >= -12.0 { + return sunM12ToM6 + } else { + return sunLtM12 + } + } + + func colorForObjectAltitude(_ altitude: Double) -> UIColor { + if altitude >= 45.0 { + return fillGt45 + } else if altitude >= 15.0 { + return fill15To45 + } else if altitude >= 0.0 { + return fill0To15 + } else { + return fillLt0 + } + } + + func colorForPositiveObjectAltitude(_ altitude: Double) -> UIColor { + let transitionHalf = Self.objectGradientTransitionDegrees / 2.0 + if altitude >= 45.0 + transitionHalf { + return fillGt45 + } else if altitude >= 45.0 - transitionHalf { + return blend(from: fill15To45, + to: fillGt45, + ratio: (altitude - (45.0 - transitionHalf)) / (2.0 * transitionHalf)) + } else if altitude >= 15.0 + transitionHalf { + return fill15To45 + } else if altitude >= 15.0 - transitionHalf { + return blend(from: fill0To15, + to: fill15To45, + ratio: (altitude - (15.0 - transitionHalf)) / (2.0 * transitionHalf)) + } else { + return fill0To15 + } + } + + private func blend(from: UIColor, to: UIColor, ratio: Double) -> UIColor { + let clamped = max(0.0, min(1.0, ratio)) + var fr: CGFloat = 0 + var fg: CGFloat = 0 + var fb: CGFloat = 0 + var fa: CGFloat = 0 + var tr: CGFloat = 0 + var tg: CGFloat = 0 + var tb: CGFloat = 0 + var ta: CGFloat = 0 + from.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + to.getRed(&tr, green: &tg, blue: &tb, alpha: &ta) + let r = fr + (tr - fr) * CGFloat(clamped) + let g = fg + (tg - fg) * CGFloat(clamped) + let b = fb + (tb - fb) * CGFloat(clamped) + let a = fa + (ta - fa) * CGFloat(clamped) + return UIColor(red: r, green: g, blue: b, alpha: a) + } + + static let objectGradientTransitionDegrees = 15.0 +} + diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroContextCardFactory.swift b/Sources/Plugins/Astronomy/contextmenu/AstroContextCardFactory.swift new file mode 100644 index 0000000000..38a822fb6b --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroContextCardFactory.swift @@ -0,0 +1,83 @@ +// +// AstroContextCardFactory.swift +// OsmAnd Maps +// +// Ported from Android AstroContextCardFactory.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation + +final class AstroContextCardFactory { + func buildCards(skyObject: SkyObject?, + article: AstroArticle?, + uiState: AstroContextUiState, + knowledgeItem: AstroKnowledgeCardItem?, + visibilityItem: AstroVisibilityCardItem?, + scheduleItem: AstroScheduleCardItem?) -> [AstroContextMenuItem] { + guard let skyObject else { + return [] + } + + var items: [AstroContextMenuItem] = [] + let descriptionItem = buildDescriptionCardItem(obj: skyObject, astroArticle: article) + if let descriptionItem { + items.append(descriptionItem) + } + if !skyObject.catalogs.isEmpty { + items.append(AstroCatalogsCardItem(catalogs: skyObject.catalogs, expanded: uiState.catalogsExpanded)) + } + items.append(AstroGalleryCardItem(wid: skyObject.wid, + showAllTitle: skyObject.niceName(), + state: uiState.galleryState)) + if let knowledgeItem, (knowledgeItem.state == .download || descriptionItem == nil) { + items.append(knowledgeItem) + } + if let visibilityItem { + items.append(visibilityItem) + } + if let scheduleItem { + items.append(scheduleItem) + } + return items + } + + private func buildDescriptionCardItem(obj: SkyObject, astroArticle: AstroArticle?) -> AstroDescriptionCardItem? { + let description = astroArticle?.description.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let hasOfflineArticle = astroArticle?.hasOfflineContent() == true + let wikipediaUri = astroArticle?.getOnlineArticleUrl().flatMap(URL.init(string:)) + let hasWikipediaArticle = hasOfflineArticle || wikipediaUri != nil + let wikidataUri = obj.wid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !shouldOpenWikidata(obj: obj, hasWikipediaArticle: hasWikipediaArticle) + ? nil + : buildWikidataUri(wikidataId: obj.wid) + let readMoreUri = wikipediaUri ?? wikidataUri + let linkType: AstroDescriptionLinkType? + if hasWikipediaArticle { + linkType = .wikipedia + } else if wikidataUri != nil { + linkType = .wikidata + } else { + linkType = nil + } + + if description.isEmpty && readMoreUri == nil && !hasOfflineArticle { + return nil + } + return AstroDescriptionCardItem(description: description, + readMoreUri: readMoreUri, + linkType: linkType, + hasOfflineArticle: hasOfflineArticle) + } + + private func buildWikidataUri(wikidataId: String) -> URL? { + let encoded = wikidataId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? wikidataId + return URL(string: "https://www.wikidata.org/wiki/\(encoded)") + } + + private func shouldOpenWikidata(obj: SkyObject, hasWikipediaArticle: Bool) -> Bool { + if hasWikipediaArticle { + return false + } + return obj.hasMissingPrimaryName() + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroContextMenuAdapter.swift b/Sources/Plugins/Astronomy/contextmenu/AstroContextMenuAdapter.swift new file mode 100644 index 0000000000..463b6b543b --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroContextMenuAdapter.swift @@ -0,0 +1,181 @@ +// +// AstroContextMenuAdapter.swift +// OsmAnd Maps +// +// Ported from Android AstroContextMenuAdapter.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum AstroContextMenuTheme { + static var pageBackground: UIColor { .viewBg } + static var cardBackground: UIColor { .groupBg } + static var secondaryBackground: UIColor { UIColor(named: "groupBgColorSecondary") ?? .viewBg } + static var actionBackground: UIColor { .contextMenuButtonBg } + static var iconButtonBackground: UIColor { UIColor(named: "iconButtonBgColor") ?? secondaryBackground } + static var primaryText: UIColor { .textColorPrimary } + static var secondaryText: UIColor { .textColorSecondary } + static var tertiaryText: UIColor { UIColor(named: "textColorTertiary") ?? .textColorSecondary } + static var activeText: UIColor { .textColorActive } + static var activeIcon: UIColor { .iconColorActive } + static var defaultIcon: UIColor { .iconColorDefault } + static var secondaryIcon: UIColor { .iconColorSecondary } + static var separator: UIColor { .customSeparator } + static var primaryButton: UIColor { .buttonBgColorPrimary } + static var secondaryButton: UIColor { .buttonBgColorSecondary } + + static var resolvedSeparator: UIColor { + separator.currentMapThemeColor + } +} + +final class AstroContextMenuAdapter { + private let presentingController: UIViewController + private let onDescriptionRead: (AstroDescriptionCardItem) -> Void + private let onGalleryToggle: (String) -> Void + private let onUpdateImage: () -> Void + private let onKnowledgeCardAction: () -> Void + private let onVisibilityResetToToday: () -> Void + private let onVisibilityCursorChanged: (Int64) -> Void + private let onScheduleResetPeriod: () -> Void + private let onScheduleShiftPeriod: (Int) -> Void + private let onScheduleSelectDate: (Date) -> Void + private let onCatalogsToggleExpanded: () -> Void + private let onCatalogClick: (Catalog) -> Void + + private(set) var currentList: [AstroContextMenuItem] = [] + + init(presentingController: UIViewController, + onDescriptionRead: @escaping (AstroDescriptionCardItem) -> Void, + onGalleryToggle: @escaping (String) -> Void, + onUpdateImage: @escaping () -> Void, + onKnowledgeCardAction: @escaping () -> Void, + onVisibilityResetToToday: @escaping () -> Void, + onVisibilityCursorChanged: @escaping (Int64) -> Void, + onScheduleResetPeriod: @escaping () -> Void, + onScheduleShiftPeriod: @escaping (Int) -> Void, + onScheduleSelectDate: @escaping (Date) -> Void, + onCatalogsToggleExpanded: @escaping () -> Void, + onCatalogClick: @escaping (Catalog) -> Void) { + self.presentingController = presentingController + self.onDescriptionRead = onDescriptionRead + self.onGalleryToggle = onGalleryToggle + self.onUpdateImage = onUpdateImage + self.onKnowledgeCardAction = onKnowledgeCardAction + self.onVisibilityResetToToday = onVisibilityResetToToday + self.onVisibilityCursorChanged = onVisibilityCursorChanged + self.onScheduleResetPeriod = onScheduleResetPeriod + self.onScheduleShiftPeriod = onScheduleShiftPeriod + self.onScheduleSelectDate = onScheduleSelectDate + self.onCatalogsToggleExpanded = onCatalogsToggleExpanded + self.onCatalogClick = onCatalogClick + } + + func submitItems(_ items: [AstroContextMenuItem], onCommitted: () -> Void = {}) { + currentList = items + onCommitted() + } + + func getItemPosition(_ cardKey: AstroContextCardKey) -> Int { + currentList.firstIndex { $0.key == cardKey } ?? -1 + } + + func makeCardViews() -> [UIView] { + currentList.compactMap { item in + switch item.key { + case .description: + guard let item = item as? AstroDescriptionCardItem else { return nil } + return AstroDescriptionCardViewHolder.makeView(item: item, onReadClick: onDescriptionRead) + case .visibility: + guard let item = item as? AstroVisibilityCardItem else { return nil } + return AstroVisibilityCardViewHolder.makeView(item: item, + onResetToToday: onVisibilityResetToToday, + onCursorTimeChanged: onVisibilityCursorChanged) + case .schedule: + guard let item = item as? AstroScheduleCardItem else { return nil } + return AstroScheduleCardViewHolder.makeView(item: item, + onResetPeriod: onScheduleResetPeriod, + onShiftPeriod: onScheduleShiftPeriod, + onSelectDate: onScheduleSelectDate) + case .catalogs: + guard let item = item as? AstroCatalogsCardItem else { return nil } + return AstroCatalogsCardViewHolder.makeView(item: item, + onToggleExpanded: onCatalogsToggleExpanded, + onCatalogClick: onCatalogClick) + case .knowledge: + guard let item = item as? AstroKnowledgeCardItem else { return nil } + return AstroKnowledgeCardViewHolder.makeView(item: item, onActionClick: onKnowledgeCardAction) + case .gallery: + guard let item = item as? AstroGalleryCardItem else { return nil } + return AstroGalleryCardViewHolder.makeView(item: item, + presentingController: presentingController, + onUpdateImage: onUpdateImage, + onToggle: onGalleryToggle) + } + } + } +} + +class AstroCardContainerView: UIView { + let stack = UIStackView() + + init(title: String? = nil, iconName: String? = nil) { + super.init(frame: .zero) + setup(title: title, iconName: iconName) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup(title: String?, iconName: String?) { + translatesAutoresizingMaskIntoConstraints = false + backgroundColor = AstroContextMenuTheme.cardBackground + layer.cornerRadius = 12 + layer.masksToBounds = true + + stack.axis = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + + if title != nil || iconName != nil { + let row = UIStackView() + row.axis = .horizontal + row.alignment = .center + row.spacing = 8 + if let iconName { + let imageView = UIImageView(image: AstroIcon.template(iconName)) + imageView.tintColor = AstroContextMenuTheme.activeIcon + imageView.contentMode = .scaleAspectFit + imageView.widthAnchor.constraint(equalToConstant: 22).isActive = true + imageView.heightAnchor.constraint(equalToConstant: 22).isActive = true + row.addArrangedSubview(imageView) + } + if let title { + let label = UILabel() + label.text = title + label.textColor = AstroContextMenuTheme.primaryText + label.font = .systemFont(ofSize: 16, weight: .bold) + label.numberOfLines = 0 + row.addArrangedSubview(label) + } + stack.addArrangedSubview(row) + } + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 16), + stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16) + ]) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true { + backgroundColor = AstroContextMenuTheme.cardBackground + } + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroContextMenuFragment.swift b/Sources/Plugins/Astronomy/contextmenu/AstroContextMenuFragment.swift new file mode 100644 index 0000000000..a113f2950f --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroContextMenuFragment.swift @@ -0,0 +1,12 @@ +// +// AstroContextMenuFragment.swift +// OsmAnd Maps +// +// Ported from Android AstroContextMenuFragment.kt. +// The iOS entrypoint is AstroContextMenuViewController. +// + +import Foundation + +typealias AstroContextMenuFragment = AstroContextMenuViewController + diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroContextMenuItem.swift b/Sources/Plugins/Astronomy/contextmenu/AstroContextMenuItem.swift new file mode 100644 index 0000000000..a304b59abf --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroContextMenuItem.swift @@ -0,0 +1,154 @@ +// +// AstroContextMenuItem.swift +// OsmAnd Maps +// +// Ported from Android AstroContextMenuItem.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import UIKit + +enum AstroContextCardKey: Int64, CaseIterable { + case knowledge = 1 + case description = 2 + case catalogs = 3 + case gallery = 4 + case visibility = 5 + case schedule = 6 + + var stableId: Int64 { + rawValue + } +} + +protocol AstroContextMenuItem { + var key: AstroContextCardKey { get } +} + +enum AstroKnowledgeCardState { + case upsell + case download +} + +enum AstroDescriptionLinkType { + case wikipedia + case wikidata +} + +enum AstroGalleryState { + case collapsed + case loading + case ready([AbstractCard]) +} + +struct AstroKnowledgeCardItem: AstroContextMenuItem { + let state: AstroKnowledgeCardState + let resourceId: String? + let resourceItem: OAResourceSwiftItem? + let downloadTask: OADownloadTask? + let progress: Float? + let buttonTitle: String + let actionEnabled: Bool + let key: AstroContextCardKey = .knowledge + + var isDownloading: Bool { + downloadTask != nil + } + + func getTitle() -> String { + switch state { + case .upsell: + return localizedString("astro_expand_your_universe_title") + case .download: + return localizedString("astro_offline_knowledge_base_title") + } + } + + func getDescription() -> String { + switch state { + case .upsell: + return localizedString("astro_expand_your_universe_description") + case .download: + return localizedString("astro_offline_knowledge_base_description") + } + } + + func getIconName() -> String { + switch state { + case .upsell: + return "ic_custom_telescope_colored" + case .download: + return "ic_custom_sky_map_download" + } + } +} + +struct AstroDescriptionCardItem: AstroContextMenuItem { + let description: String + let readMoreUri: URL? + let linkType: AstroDescriptionLinkType? + let hasOfflineArticle: Bool + let key: AstroContextCardKey = .description +} + +struct AstroCatalogsCardItem: AstroContextMenuItem { + let catalogs: [Catalog] + let expanded: Bool + let key: AstroContextCardKey = .catalogs +} + +struct AstroGalleryCardItem: AstroContextMenuItem { + let wid: String + let showAllTitle: String? + let state: AstroGalleryState + let key: AstroContextCardKey = .gallery +} + +struct AstroVisibilityGraphSnapshot { + let startMillis: Int64 + let endMillis: Int64 + let timeZone: TimeZone + let objectAltitudes: [Double] + let objectAzimuths: [Double] + let sunAltitudes: [Double] + + var size: Int { + objectAltitudes.count + } +} + +struct AstroVisibilityCardItem: AstroContextMenuItem { + let graph: AstroVisibilityGraphSnapshot? + let cursorReferenceTimeMillis: Int64 + let riseTime: String? + let culminationTime: String? + let setTime: String? + let locationText: String + let culminationColor: UIColor + let titleText: String + let showResetButton: Bool + let key: AstroContextCardKey = .visibility +} + +struct AstroScheduleDayGraphSnapshot { + let sunAltitudes: [Double] + let objectAltitudes: [Double] +} + +struct AstroScheduleDayItem { + let date: Date + let dayLabel: String + let riseTime: String? + let setTime: String? + let setDayOffset: Int + let graph: AstroScheduleDayGraphSnapshot +} + +struct AstroScheduleCardItem: AstroContextMenuItem { + let periodStart: Date + let rangeLabel: String + let days: [AstroScheduleDayItem] + let showResetPeriodButton: Bool + let key: AstroContextCardKey = .schedule +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroContextUiState.swift b/Sources/Plugins/Astronomy/contextmenu/AstroContextUiState.swift new file mode 100644 index 0000000000..567a3a5365 --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroContextUiState.swift @@ -0,0 +1,36 @@ +// +// AstroContextUiState.swift +// OsmAnd Maps +// +// Ported from Android AstroContextUiState.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation + +struct AstroContextUiState { + var selectedObjectId: String? + var currentLocalDate: Date? + var selectedVisibilityDateOverride: Date? + var visibilityCursorReferenceTimeMillis: Int64? + var schedulePeriodStart: Date? + var catalogsExpanded: Bool + var galleryState: AstroGalleryState + + init(selectedObjectId: String? = nil, + currentLocalDate: Date? = nil, + selectedVisibilityDateOverride: Date? = nil, + visibilityCursorReferenceTimeMillis: Int64? = nil, + schedulePeriodStart: Date? = nil, + catalogsExpanded: Bool = false, + galleryState: AstroGalleryState = .collapsed) { + self.selectedObjectId = selectedObjectId + self.currentLocalDate = currentLocalDate + self.selectedVisibilityDateOverride = selectedVisibilityDateOverride + self.visibilityCursorReferenceTimeMillis = visibilityCursorReferenceTimeMillis + self.schedulePeriodStart = schedulePeriodStart + self.catalogsExpanded = catalogsExpanded + self.galleryState = galleryState + } +} + diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroDescriptionCardViewHolder.swift b/Sources/Plugins/Astronomy/contextmenu/AstroDescriptionCardViewHolder.swift new file mode 100644 index 0000000000..fc6be9fd4c --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroDescriptionCardViewHolder.swift @@ -0,0 +1,80 @@ +// +// AstroDescriptionCardViewHolder.swift +// OsmAnd Maps +// +// Ported from Android AstroDescriptionCardViewHolder.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum AstroDescriptionCardViewHolder { + static func makeView(item: AstroDescriptionCardItem, + onReadClick: @escaping (AstroDescriptionCardItem) -> Void) -> UIView { + let card = AstroCardContainerView() + + if !item.description.isEmpty { + let description = UILabel() + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = 5 + paragraphStyle.lineBreakMode = .byTruncatingTail + description.attributedText = NSAttributedString( + string: item.description, + attributes: [ + .font: UIFont.systemFont(ofSize: 16), + .foregroundColor: AstroContextMenuTheme.primaryText, + .paragraphStyle: paragraphStyle + ]) + description.textColor = AstroContextMenuTheme.primaryText + description.font = .systemFont(ofSize: 16) + description.numberOfLines = 3 + description.lineBreakMode = .byTruncatingTail + card.stack.addArrangedSubview(description) + } + + if let linkType = item.linkType, item.readMoreUri != nil || item.hasOfflineArticle { + var config = UIButton.Configuration.plain() + config.image = AstroIcon.template(linkType == .wikidata ? "ic_custom_logo_wikidata" : "ic_plugin_wikipedia") + config.imagePadding = 10 + config.imageColorTransformer = UIConfigurationColorTransformer { _ in + AstroContextMenuTheme.defaultIcon + } + config.baseForegroundColor = AstroContextMenuTheme.secondaryText + config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14) + config.background.backgroundColor = .clear + config.background.cornerRadius = 6 + config.background.strokeColor = AstroContextMenuTheme.separator + config.background.strokeWidth = 1 + + let opensOfflineArticle = item.hasOfflineArticle && linkType == .wikipedia + let text: String + let activeText: String + if opensOfflineArticle { + text = localizedString("context_menu_read_full_article") + activeText = text + } else { + let targetName = linkType == .wikidata + ? localizedString("wikidata") + : localizedString("shared_string_wikipedia") + let readOn = localizedString("read_on") + text = readOn.contains("%@") ? String(format: readOn, targetName) : "\(readOn) \(targetName)" + activeText = targetName + } + + var attributedTitle = AttributedString(text) + attributedTitle.font = .systemFont(ofSize: 16) + attributedTitle.foregroundColor = AstroContextMenuTheme.secondaryText + if let range = attributedTitle.range(of: activeText) { + attributedTitle[range].foregroundColor = AstroContextMenuTheme.activeText + } + config.attributedTitle = attributedTitle + + let button = UIButton(configuration: config) + button.contentHorizontalAlignment = .leading + button.heightAnchor.constraint(greaterThanOrEqualToConstant: 48).isActive = true + button.addAction(UIAction { _ in onReadClick(item) }, for: .touchUpInside) + card.stack.addArrangedSubview(button) + } + return card + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroGalleryCardViewHolder.swift b/Sources/Plugins/Astronomy/contextmenu/AstroGalleryCardViewHolder.swift new file mode 100644 index 0000000000..f41889b12b --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroGalleryCardViewHolder.swift @@ -0,0 +1,272 @@ +// +// AstroGalleryCardViewHolder.swift +// OsmAnd Maps +// +// Ported from Android AstroGalleryCardViewHolder.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum AstroGalleryCardViewHolder { + static func makeView(item: AstroGalleryCardItem, + presentingController: UIViewController, + onUpdateImage: @escaping () -> Void, + onToggle: @escaping (String) -> Void) -> UIView { + AstroGalleryCardView(item: item, + presentingController: presentingController, + onUpdateImage: onUpdateImage, + onToggle: onToggle) + } +} + +private final class AstroGalleryCardView: UIView { + private let item: AstroGalleryCardItem + private weak var presentingController: UIViewController? + private let onUpdateImage: () -> Void + private let onToggle: (String) -> Void + + private let stack = UIStackView() + private let headerButton = UIControl() + private let iconView = UIImageView(image: .icCustomPhoto) + private let titleLabel = UILabel() + private let arrowView = UIImageView() + private var galleryHeightConstraint: NSLayoutConstraint? + + init(item: AstroGalleryCardItem, + presentingController: UIViewController, + onUpdateImage: @escaping () -> Void, + onToggle: @escaping (String) -> Void) { + self.item = item + self.presentingController = presentingController + self.onUpdateImage = onUpdateImage + self.onToggle = onToggle + super.init(frame: .zero) + setupView() + applyState() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true { + applyTheme() + } + } + + private func setupView() { + translatesAutoresizingMaskIntoConstraints = false + layer.cornerRadius = 12 + layer.masksToBounds = true + + stack.axis = .vertical + stack.spacing = 0 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + + setupHeader() + stack.addArrangedSubview(headerButton) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + applyTheme() + } + + private func setupHeader() { + headerButton.translatesAutoresizingMaskIntoConstraints = false + headerButton.heightAnchor.constraint(equalToConstant: 52).isActive = true + headerButton.addAction(UIAction { [weak self] _ in + guard let self else { + return + } + onToggle(item.wid) + }, for: .touchUpInside) + + iconView.contentMode = .scaleAspectFit + iconView.translatesAutoresizingMaskIntoConstraints = false + titleLabel.text = localizedString("online_photos") + titleLabel.font = .systemFont(ofSize: 16, weight: .bold) + titleLabel.numberOfLines = 1 + titleLabel.translatesAutoresizingMaskIntoConstraints = false + arrowView.contentMode = .scaleAspectFit + arrowView.translatesAutoresizingMaskIntoConstraints = false + + let arrowContainer = UIView() + arrowContainer.translatesAutoresizingMaskIntoConstraints = false + arrowContainer.isUserInteractionEnabled = false + + [iconView, titleLabel, arrowContainer].forEach(headerButton.addSubview) + arrowContainer.addSubview(arrowView) + + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: headerButton.leadingAnchor, constant: 16), + iconView.centerYAnchor.constraint(equalTo: headerButton.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 24), + iconView.heightAnchor.constraint(equalToConstant: 24), + + titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 16), + titleLabel.centerYAnchor.constraint(equalTo: headerButton.centerYAnchor), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: arrowContainer.leadingAnchor, constant: -16), + + arrowContainer.trailingAnchor.constraint(equalTo: headerButton.trailingAnchor, constant: -4), + arrowContainer.centerYAnchor.constraint(equalTo: headerButton.centerYAnchor), + arrowContainer.widthAnchor.constraint(equalToConstant: 48), + arrowContainer.heightAnchor.constraint(equalToConstant: 48), + + arrowView.centerXAnchor.constraint(equalTo: arrowContainer.centerXAnchor), + arrowView.centerYAnchor.constraint(equalTo: arrowContainer.centerYAnchor), + arrowView.widthAnchor.constraint(equalToConstant: 24), + arrowView.heightAnchor.constraint(equalToConstant: 24) + ]) + } + + private func applyState() { + let arrowName: String + switch item.state { + case .collapsed: + arrowName = "ic_custom_arrow_down" + case .loading, .ready: + arrowName = "ic_custom_arrow_up" + } + arrowView.image = AstroIcon.template(arrowName) + + switch item.state { + case .collapsed: + break + case .loading: + stack.addArrangedSubview(AstroIndeterminateProgressLine()) + case .ready(let cards): + stack.addArrangedSubview(makeContent(cards: cards)) + } + } + + private func makeContent(cards: [AbstractCard]) -> UIView { + let contentView = UIView() + contentView.translatesAutoresizingMaskIntoConstraints = false + + let contentStack = UIStackView() + contentStack.axis = .vertical + contentStack.spacing = 12 + contentStack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(contentStack) + + let preparedCards = cards.map { card -> AbstractCard in + if let noInternetCard = card as? NoInternetCard { + noInternetCard.onTryAgainAction = onUpdateImage + } + return card + } + + let cardsViewController = CardsViewController(frame: .zero) + cardsViewController.translatesAutoresizingMaskIntoConstraints = false + cardsViewController.contentType = .onlinePhoto + cardsViewController.title = item.showAllTitle ?? "" + cardsViewController.carouselPresenter = presentingController + cardsViewController.didChangeHeightAction = { [weak self] _, height in + self?.galleryHeightConstraint?.constant = CGFloat(height) + } + contentStack.addArrangedSubview(cardsViewController) + + galleryHeightConstraint = cardsViewController.heightAnchor.constraint(equalToConstant: 156) + galleryHeightConstraint?.isActive = true + cardsViewController.setCardsFilter(CardsFilter(cards: preparedCards)) + + let imageCards = preparedCards.compactMap { $0 as? ImageCard } + if !imageCards.isEmpty { + contentStack.addArrangedSubview(makeShowAllButton(cards: imageCards)) + } + + NSLayoutConstraint.activate([ + contentStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentStack.topAnchor.constraint(equalTo: contentView.topAnchor), + contentStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16) + ]) + return contentView + } + + private func makeShowAllButton(cards: [ImageCard]) -> UIButton { + var config = UIButton.Configuration.plain() + config.title = localizedString("shared_string_show_all") + config.baseForegroundColor = AstroContextMenuTheme.activeText + config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20) + + let button = UIButton(configuration: config) + button.contentHorizontalAlignment = .leading + button.addAction(UIAction { [weak self] _ in + self?.openGalleryGrid(cards: cards) + }, for: .touchUpInside) + return button + } + + private func openGalleryGrid(cards: [ImageCard]) { + guard let presentingController else { + return + } + let controller = GalleryGridViewController() + controller.cards = cards.map { $0 as AbstractCard } + controller.titleString = item.showAllTitle ?? "" + controller.presentsCarouselFromSelf = true + + let navigationController = UINavigationController(rootViewController: controller) + navigationController.modalPresentationStyle = .fullScreen + presentingController.present(navigationController, animated: true) + } + + private func applyTheme() { + backgroundColor = AstroContextMenuTheme.cardBackground + iconView.tintColor = AstroContextMenuTheme.defaultIcon + arrowView.tintColor = AstroContextMenuTheme.defaultIcon + titleLabel.textColor = AstroContextMenuTheme.primaryText + } +} + +private final class AstroIndeterminateProgressLine: UIView { + private let progressView = UIProgressView(progressViewStyle: .bar) + + init() { + super.init(frame: .zero) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMoveToWindow() { + super.didMoveToWindow() + window == nil ? progressView.layer.removeAllAnimations() : startAnimating() + } + + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + heightAnchor.constraint(equalToConstant: 2).isActive = true + progressView.translatesAutoresizingMaskIntoConstraints = false + progressView.trackTintColor = AstroContextMenuTheme.separator.withAlphaComponent(0.25) + progressView.progressTintColor = AstroContextMenuTheme.primaryButton + addSubview(progressView) + NSLayoutConstraint.activate([ + progressView.leadingAnchor.constraint(equalTo: leadingAnchor), + progressView.trailingAnchor.constraint(equalTo: trailingAnchor), + progressView.topAnchor.constraint(equalTo: topAnchor), + progressView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + private func startAnimating() { + progressView.setProgress(0.0, animated: false) + UIView.animate(withDuration: 0.9, + delay: 0, + options: [.repeat, .autoreverse, .curveEaseInOut]) { [progressView] in + progressView.setProgress(1.0, animated: true) + } + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroGalleryLoader.swift b/Sources/Plugins/Astronomy/contextmenu/AstroGalleryLoader.swift new file mode 100644 index 0000000000..825d9ccca7 --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroGalleryLoader.swift @@ -0,0 +1,229 @@ +// +// AstroGalleryLoader.swift +// OsmAnd Maps +// +// Ported from Android AstroGalleryLoader.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import CryptoKit +import OsmAndShared + +final class AstroGalleryLoader { + private var getAstroImagesTask: GetAstroImagesTask? + private var requestWid: String? + private var galleryItemsByWid: [String: [AbstractCard]] = [:] + private let onStateChanged: (String, AstroGalleryState) -> Void + private let cacheManager = AstroPhotoListCache() + + init(onStateChanged: @escaping (String, AstroGalleryState) -> Void) { + self.onStateChanged = onStateChanged + } + + func startLoading(_ wikidataId: String) { + requestWid = wikidataId + let rawKey = Self.buildRawKey(wikidataId: wikidataId) + + if let existingGalleryItems = galleryItemsByWid[wikidataId], !existingGalleryItems.isEmpty { + publishReadyState(wikidataId: wikidataId, galleryItems: existingGalleryItems) + return + } + + guard AFNetworkReachabilityManagerWrapper.isReachable() else { + cancelTaskOnly() + loadFromCache(rawKey: rawKey, wikidataId: wikidataId) + return + } + + cancelTaskOnly() + requestWid = wikidataId + let networkResponseListener = AstroGalleryNetworkResponseListener { [weak self] response in + self?.savePhotoListToCache(rawKey: rawKey, response: response) + } + let task = GetAstroImagesTask(wikidataId: wikidataId, + getImageCardsListener: self, + networkResponseListener: networkResponseListener) + getAstroImagesTask = task + task.execute() + } + + func cancel() { + requestWid = nil + cancelTaskOnly() + } + + private func cancelTaskOnly() { + getAstroImagesTask?.cancel() + getAstroImagesTask = nil + } + + private func publishReadyState(wikidataId: String, galleryItems: [AbstractCard]) { + onStateChanged(wikidataId, .ready(galleryItems)) + } + + private static func buildRawKey(wikidataId: String) -> String { + "wikidataId=\(wikidataId)" + } + + private func loadFromCache(rawKey: String, wikidataId: String) { + guard cacheManager.exists(rawKey: rawKey) else { + publishReadyState(wikidataId: wikidataId, galleryItems: [NoInternetCard()]) + return + } + + DispatchQueue.global(qos: .utility).async { [weak self] in + let json = self?.cacheManager.load(rawKey: rawKey) + let images = json.flatMap { cachedJson -> [OsmAndShared.WikiImage]? in + guard !cachedJson.isEmpty else { + return nil + } + return WikiCoreHelper.shared.getAstroImagesFromJson(json: cachedJson) + } + + DispatchQueue.main.async { [weak self] in + guard let self, requestWid == wikidataId else { + return + } + let galleryItems = buildCards(images: images ?? []) + if !galleryItems.isEmpty { + galleryItemsByWid[wikidataId] = galleryItems + } + publishReadyState(wikidataId: wikidataId, galleryItems: galleryItems) + } + } + } + + private func savePhotoListToCache(rawKey: String, response: String?) { + guard let response, !response.isEmpty else { + return + } + DispatchQueue.global(qos: .utility).async { [cacheManager] in + cacheManager.save(rawKey: rawKey, json: response) + } + } + + private func buildCards(images: [OsmAndShared.WikiImage]) -> [AbstractCard] { + images.map { image in + let wikiImage = WikiImage(wikiMediaTag: image.wikiMediaTag, + imageName: image.imageName, + imageStubUrl: image.imageStubUrl, + imageHiResUrl: image.imageHiResUrl) + wikiImage.mediaId = Int(image.getMediaId()) + wikiImage.metadata = Metadata(date: image.metadata.date, + author: image.metadata.author, + license: image.metadata.license, + description: image.metadata.getDescription(preferredLanguage: Locale.current.languageCode)) + return WikiImageCard(wikiImage: wikiImage, type: "wikimedia-photo") + } + } +} + +extension AstroGalleryLoader: GetAstroImagesTask.GetImageCardsListener { + func onTaskStarted() { + } + + func onFinish(wikidataId: String, images: [OsmAndShared.WikiImage]?) { + getAstroImagesTask = nil + guard requestWid == wikidataId else { + return + } + let galleryItems = buildCards(images: images ?? []) + if !galleryItems.isEmpty { + galleryItemsByWid[wikidataId] = galleryItems + } + publishReadyState(wikidataId: wikidataId, galleryItems: galleryItems) + } +} + +private final class AstroGalleryNetworkResponseListener: NSObject, WikiCoreHelperNetworkResponseListener { + private let onRawResponse: (String) -> Void + + init(onRawResponse: @escaping (String) -> Void) { + self.onRawResponse = onRawResponse + } + + func onGetRawResponse(response: String) { + onRawResponse(response) + } +} + +private final class AstroPhotoListCache { + private static let cacheDirectoryName = "online_photos_list_cache" + private static let maxCacheItems = 100 + + private let fileManager = FileManager.default + private let cacheDirectory: URL + + init() { + let baseDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first + cacheDirectory = (baseDirectory ?? URL(fileURLWithPath: NSTemporaryDirectory())) + .appendingPathComponent(Self.cacheDirectoryName, isDirectory: true) + try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true, attributes: nil) + } + + func save(rawKey: String, json: String) { + guard let fileURL = fileURL(rawKey: rawKey) else { + return + } + do { + try json.write(to: fileURL, atomically: true, encoding: .utf8) + cleanupIfNeeded() + } catch { + debugPrint("Error trying to save json photos list: \(error)") + } + } + + func load(rawKey: String) -> String? { + guard let fileURL = fileURL(rawKey: rawKey), fileManager.fileExists(atPath: fileURL.path) else { + return nil + } + do { + return try String(contentsOf: fileURL, encoding: .utf8) + } catch { + debugPrint("Error trying to load cached json photos list: \(error)") + return nil + } + } + + func exists(rawKey: String) -> Bool { + guard let fileURL = fileURL(rawKey: rawKey) else { + return false + } + return fileManager.fileExists(atPath: fileURL.path) + } + + private func fileURL(rawKey: String) -> URL? { + guard let fileName = hashKey(rawKey) else { + return nil + } + return cacheDirectory.appendingPathComponent(fileName).appendingPathExtension("json") + } + + private func cleanupIfNeeded() { + guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles]) else { + return + } + let jsonFiles = files.filter { $0.pathExtension == "json" } + guard jsonFiles.count > Self.maxCacheItems else { + return + } + let sortedFiles = jsonFiles.sorted { + let firstDate = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let secondDate = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return firstDate < secondDate + } + sortedFiles.prefix(jsonFiles.count - Self.maxCacheItems).forEach { + try? fileManager.removeItem(at: $0) + } + } + + private func hashKey(_ key: String) -> String? { + guard let data = key.data(using: .utf8) else { + return nil + } + return Insecure.MD5.hash(data: data).map { String(format: "%02x", Int($0)) }.joined() + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroKnowledgeBaseController.swift b/Sources/Plugins/Astronomy/contextmenu/AstroKnowledgeBaseController.swift new file mode 100644 index 0000000000..73e3d64e11 --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroKnowledgeBaseController.swift @@ -0,0 +1,154 @@ +// +// AstroKnowledgeBaseController.swift +// OsmAnd Maps +// +// Ported from Android AstroKnowledgeBaseController.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import UIKit + +final class AstroKnowledgeBaseController { + static let knowledgeBaseFileName = "stars-articles.stardb" + static let resourceId = "stars-articles.stardb" + static let resourceTaskKey = "resource:\(resourceId)" + + private static let travelRegionId = "travel" + private static let resourceTypeName = "starmap" + + private(set) var requestedIndexesReload = false + private var cachedDownloadItem: OAResourceSwiftItem? + + func buildCardItem(progressOverride: Float? = nil) -> AstroKnowledgeCardItem? { + if isDownloaded() { + return nil + } + guard let state = currentState() else { + return nil + } + let resourceItem = state == .download ? findDownloadItem() : nil + let downloadTask = state == .download ? findActiveDownload(resourceItem: resourceItem) : nil + let progress = progressOverride ?? downloadTask?.progressCompleted + return AstroKnowledgeCardItem(state: state, + resourceId: resourceItem?.resourceId() ?? (state == .download ? Self.resourceId : nil), + resourceItem: resourceItem, + downloadTask: downloadTask, + progress: progress, + buttonTitle: buttonTitle(state: state, + resourceItem: resourceItem, + downloadTask: downloadTask, + progressOverride: progressOverride), + actionEnabled: true) + } + + func currentState() -> AstroKnowledgeCardState? { + if isDownloaded() { + return nil + } + return hasAccess() ? .download : .upsell + } + + func hasAccess() -> Bool { + OAIAPHelper.isOsmAndProAvailable() || OAIAPHelper.isMapsPlusAvailable() + } + + func isDownloaded() -> Bool { + knowledgeBaseFileUrl().map { FileManager.default.fileExists(atPath: $0.path) } ?? false + } + + func ensureIndexesLoaded() { + requestedIndexesReload = true + } + + func resetIndexesReloadFlag() { + requestedIndexesReload = false + } + + func shouldRefreshAfterDownload(_ actionWasDisabled: Bool) -> Bool { + isDownloaded() || findActiveDownload(resourceItem: findDownloadItem()) != nil || actionWasDisabled + } + + func findDownloadItem() -> OAResourceSwiftItem? { + if let cachedDownloadItem { + cachedDownloadItem.refreshDownloadTask() + return cachedDownloadItem + } + guard let resources = OAResourcesUISwiftHelper.getResourcesInRepositoryIds(byRegionId: Self.travelRegionId, + resourceTypeNames: [Self.resourceTypeName]) else { + return nil + } + guard let item = resources.first(where: { $0.resourceId() == Self.resourceId }) else { + return nil + } + item.refreshDownloadTask() + cachedDownloadItem = item + return item + } + + func findActiveDownload(resourceItem: OAResourceSwiftItem? = nil) -> OADownloadTask? { + resourceItem?.refreshDownloadTask() + if let task = resourceItem?.downloadTask() { + return task + } + guard let app = OsmAndApp.swiftInstance() else { + return nil + } + return app.downloadsManager.downloadTasks(withKey: Self.resourceTaskKey).first as? OADownloadTask + } + + private func knowledgeBaseFileUrl() -> URL? { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first? + .appendingPathComponent(RESOURCES_DIR, isDirectory: true) + .appendingPathComponent("astro", isDirectory: true) + .appendingPathComponent(Self.knowledgeBaseFileName, isDirectory: false) + } + + private func buttonTitle(state: AstroKnowledgeCardState, + resourceItem: OAResourceSwiftItem?, + downloadTask: OADownloadTask?, + progressOverride: Float?) -> String { + switch state { + case .upsell: + return localizedString("shared_string_get") + case .download: + if let downloadTask { + let downloading = localizedString("downloading") + let progress = progressOverride ?? downloadTask.progressCompleted + if let progressText = downloadProgressText(resourceItem: resourceItem, progress: progress) { + return combine(downloading, progressText) + } + if let size = resourceSizeText(resourceItem) { + return combine(downloading, size) + } + return downloading + } + let download = localizedString("shared_string_download") + if let size = resourceSizeText(resourceItem) { + return combine(download, size) + } + return download + } + } + + private func downloadProgressText(resourceItem: OAResourceSwiftItem?, progress: Float) -> String? { + guard let resourceItem, + let text = OAResourcesUISwiftHelper.formatedDownloadingProgressString(resourceItem.sizePkg(), progress: progress), + !text.isEmpty else { + return nil + } + return text + } + + private func resourceSizeText(_ resourceItem: OAResourceSwiftItem?) -> String? { + guard let text = resourceItem?.formatedSizePkg(), + !text.isEmpty else { + return nil + } + return text + } + + private func combine(_ first: String, _ second: String) -> String { + String(format: localizedString("ltr_or_rtl_combine_via_space"), first, second) + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroKnowledgeCardViewHolder.swift b/Sources/Plugins/Astronomy/contextmenu/AstroKnowledgeCardViewHolder.swift new file mode 100644 index 0000000000..b8b7be2eae --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroKnowledgeCardViewHolder.swift @@ -0,0 +1,90 @@ +// +// AstroKnowledgeCardViewHolder.swift +// OsmAnd Maps +// +// Ported from Android AstroKnowledgeCardViewHolder.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class AstroKnowledgeCardView: AstroCardContainerView { + private let actionButton: UIButton + private var currentButtonTitle: String + private var currentActionEnabled: Bool + + init(item: AstroKnowledgeCardItem, onActionClick: @escaping () -> Void) { + currentButtonTitle = item.buttonTitle + currentActionEnabled = item.actionEnabled + actionButton = UIButton(configuration: Self.makeButtonConfiguration(title: item.buttonTitle)) + super.init() + setup(item: item, onActionClick: onActionClick) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(item: AstroKnowledgeCardItem) { + guard currentButtonTitle != item.buttonTitle || currentActionEnabled != item.actionEnabled else { + return + } + currentButtonTitle = item.buttonTitle + currentActionEnabled = item.actionEnabled + actionButton.configuration = Self.makeButtonConfiguration(title: item.buttonTitle) + actionButton.isEnabled = item.actionEnabled + } + + private func setup(item: AstroKnowledgeCardItem, onActionClick: @escaping () -> Void) { + let row = UIStackView() + row.axis = .horizontal + row.alignment = .top + row.spacing = 12 + + let iconName = item.getIconName() + let iconView = UIImageView(image: AstroIcon.original(iconName) ?? AstroIcon.template(iconName)) + if iconName != "ic_custom_telescope_colored" && iconName != "ic_custom_sky_map_download" { + iconView.tintColor = AstroContextMenuTheme.activeIcon + } + iconView.contentMode = .scaleAspectFit + iconView.widthAnchor.constraint(equalToConstant: 34).isActive = true + iconView.heightAnchor.constraint(equalToConstant: 34).isActive = true + row.addArrangedSubview(iconView) + + let textStack = UIStackView() + textStack.axis = .vertical + textStack.spacing = 5 + let title = UILabel() + title.text = item.getTitle() + title.textColor = AstroContextMenuTheme.primaryText + title.font = .systemFont(ofSize: 17, weight: .semibold) + title.numberOfLines = 0 + let description = UILabel() + description.text = item.getDescription() + description.textColor = AstroContextMenuTheme.secondaryText + description.font = .systemFont(ofSize: 14) + description.numberOfLines = 0 + textStack.addArrangedSubview(title) + textStack.addArrangedSubview(description) + row.addArrangedSubview(textStack) + stack.addArrangedSubview(row) + + actionButton.isEnabled = item.actionEnabled + actionButton.addAction(UIAction { _ in onActionClick() }, for: .touchUpInside) + stack.addArrangedSubview(actionButton) + } + + private static func makeButtonConfiguration(title: String) -> UIButton.Configuration { + var config = UIButton.Configuration.filled() + config.title = title + config.baseBackgroundColor = AstroContextMenuTheme.primaryButton + config.baseForegroundColor = .white + return config + } +} + +enum AstroKnowledgeCardViewHolder { + static func makeView(item: AstroKnowledgeCardItem, onActionClick: @escaping () -> Void) -> UIView { + AstroKnowledgeCardView(item: item, onActionClick: onActionClick) + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardController.swift b/Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardController.swift new file mode 100644 index 0000000000..cbeb23ab7c --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardController.swift @@ -0,0 +1,218 @@ +// +// AstroScheduleCardController.swift +// OsmAnd Maps +// +// Ported from Android AstroScheduleCardController.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared + +final class AstroScheduleCardController { + static let periodDays = 7 + private static let sampleCount = AstroChartMath.scheduleSampleCount + private static let setSearchLimitDays = 5.0 + + private(set) var skyObject: SkyObject? + private(set) var observer: Observer? + private(set) var timeZone: TimeZone = .current + private(set) var periodStart: Date = Date() + private(set) var rangeLabel = "" + private(set) var days: [AstroScheduleDayItem] = [] + private(set) var showResetPeriodButton = false + + var onDataChanged: (() -> Void)? + + private var computeWorkItem: DispatchWorkItem? + private var lastObjectId: String? + private var lastObserverLat = Double.nan + private var lastObserverLon = Double.nan + private var lastObserverHeight = Double.nan + private var lastPeriodStart: Date? + private var lastTimeZone: TimeZone? + private var lastShowResetPeriodButton = false + + private let dayLabelFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .current + formatter.dateFormat = "EEE, d" + return formatter + }() + + private let rangeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .current + formatter.dateFormat = "d MMM" + return formatter + }() + + func update(skyObject: SkyObject?, + observer: Observer?, + periodStart: Date, + timeZone: TimeZone, + showResetPeriodButton: Bool) { + self.skyObject = skyObject + self.observer = observer + self.periodStart = normalizedDay(periodStart, timeZone: timeZone) + self.timeZone = timeZone + self.showResetPeriodButton = showResetPeriodButton + dayLabelFormatter.timeZone = timeZone + rangeFormatter.timeZone = timeZone + + guard let skyObject, let observer else { + computeWorkItem?.cancel() + rangeLabel = "" + days = [] + lastObjectId = nil + lastObserverLat = .nan + lastObserverLon = .nan + lastObserverHeight = .nan + lastPeriodStart = nil + lastTimeZone = nil + lastShowResetPeriodButton = false + onDataChanged?() + return + } + + let calendar = makeCalendar(timeZone: timeZone) + let periodEnd = calendar.date(byAdding: .day, + value: Self.periodDays - 1, + to: self.periodStart) ?? self.periodStart + rangeLabel = "\(rangeFormatter.string(from: self.periodStart)) - \(rangeFormatter.string(from: periodEnd))" + let computationMatchesState = + lastObjectId == skyObject.id && + lastObserverLat == observer.latitude && + lastObserverLon == observer.longitude && + lastObserverHeight == observer.height && + lastPeriodStart == self.periodStart && + lastTimeZone == timeZone && + lastShowResetPeriodButton == showResetPeriodButton && + !days.isEmpty + if computationMatchesState { + return + } + + computeWorkItem?.cancel() + let periodStartCopy = self.periodStart + let workItem = DispatchWorkItem { [weak self] in + guard let self else { + return + } + let entries = self.buildPeriodEntries(obj: skyObject, + observer: observer, + periodStart: periodStartCopy, + timeZone: timeZone) + DispatchQueue.main.async { [weak self] in + guard let self, + !(self.computeWorkItem?.isCancelled ?? true), + self.skyObject?.id == skyObject.id, + self.observer?.latitude == observer.latitude, + self.observer?.longitude == observer.longitude, + self.observer?.height == observer.height, + self.periodStart == periodStartCopy, + self.timeZone == timeZone else { + return + } + self.days = entries + self.lastObjectId = skyObject.id + self.lastObserverLat = observer.latitude + self.lastObserverLon = observer.longitude + self.lastObserverHeight = observer.height + self.lastPeriodStart = periodStartCopy + self.lastTimeZone = timeZone + self.lastShowResetPeriodButton = showResetPeriodButton + self.onDataChanged?() + } + } + computeWorkItem = workItem + DispatchQueue.global(qos: .userInitiated).async(execute: workItem) + } + + func buildItem() -> AstroScheduleCardItem? { + guard skyObject != nil, observer != nil else { + return nil + } + return AstroScheduleCardItem(periodStart: periodStart, + rangeLabel: rangeLabel, + days: days, + showResetPeriodButton: showResetPeriodButton) + } + + func cancelPendingWork() { + computeWorkItem?.cancel() + computeWorkItem = nil + } + + private func buildPeriodEntries(obj: SkyObject, + observer: Observer, + periodStart: Date, + timeZone: TimeZone) -> [AstroScheduleDayItem] { + let calendar = makeCalendar(timeZone: timeZone) + return (0.. AstroScheduleDayItem { + let startLocal = normalizedDay(day, timeZone: timeZone) + let endLocal = startLocal.addingTimeInterval(24 * 60 * 60) + let dayEndInclusive = endLocal.addingTimeInterval(-0.001) + let riseSet = AstroUtils.nextRiseSet(object: obj, + startSearch: startLocal, + observer: observer, + windowStart: startLocal, + windowEnd: dayEndInclusive) + let setTime = riseSet.rise.flatMap { + AstroUtils.nextRiseSet(object: obj, + startSearch: $0, + observer: observer, + limitDays: Self.setSearchLimitDays).set + } + let setDayOffset = setTime.map { dayOffset(from: startLocal, to: $0, timeZone: timeZone) } ?? 0 + let samples = AstroChartMath.computeDaySamples(objectToRender: obj, + observer: observer, + startLocal: startLocal, + endLocal: endLocal, + sampleCount: Self.sampleCount, + includeAzimuth: false) + let timeFormatter = createTimeFormatter(timeZone: timeZone) + return AstroScheduleDayItem(date: day, + dayLabel: dayLabelFormatter.string(from: day), + riseTime: riseSet.rise.map { timeFormatter.string(from: $0) }, + setTime: setTime.map { timeFormatter.string(from: $0) }, + setDayOffset: max(0, setDayOffset), + graph: AstroScheduleDayGraphSnapshot(sunAltitudes: samples.sunAltitudes, + objectAltitudes: samples.objectAltitudes)) + } + + private func dayOffset(from start: Date, to end: Date, timeZone: TimeZone) -> Int { + var calendar = Calendar.current + calendar.timeZone = timeZone + let from = calendar.startOfDay(for: start) + let to = calendar.startOfDay(for: end) + return calendar.dateComponents([.day], from: from, to: to).day ?? 0 + } + + private func createTimeFormatter(timeZone: TimeZone) -> DateFormatter { + let formatter = DateFormatter() + formatter.timeZone = timeZone + formatter.locale = .current + formatter.dateFormat = OAUtilities.is12HourTimeFormat() ? "h:mm a" : "HH:mm" + return formatter + } + + private func normalizedDay(_ date: Date, timeZone: TimeZone) -> Date { + makeCalendar(timeZone: timeZone).startOfDay(for: date) + } + + private func makeCalendar(timeZone: TimeZone) -> Calendar { + var calendar = Calendar.current + calendar.timeZone = timeZone + return calendar + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardViewHolder.swift b/Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardViewHolder.swift new file mode 100644 index 0000000000..970bb4a20e --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardViewHolder.swift @@ -0,0 +1,337 @@ +// +// AstroScheduleCardViewHolder.swift +// OsmAnd Maps +// +// Ported from Android AstroScheduleCardViewHolder.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum AstroScheduleCardViewHolder { + private static let riseArrow = "▲" + private static let setArrow = "▼" + private static let emptyTime = "—" + + static func makeView(item: AstroScheduleCardItem, + onResetPeriod: @escaping () -> Void, + onShiftPeriod: @escaping (Int) -> Void, + onSelectDate: @escaping (Date) -> Void) -> UIView { + let card = UIView() + card.translatesAutoresizingMaskIntoConstraints = false + card.backgroundColor = AstroContextMenuTheme.cardBackground + card.layer.cornerRadius = 12 + card.layer.masksToBounds = true + + let contentStack = UIStackView() + contentStack.axis = .vertical + contentStack.spacing = 0 + contentStack.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(contentStack) + + let headerView = header(item: item, + onResetPeriod: onResetPeriod, + onShiftPeriod: onShiftPeriod) + contentStack.addArrangedSubview(headerView) + + let daysStack = UIStackView() + daysStack.axis = .vertical + daysStack.spacing = 0 + for index in 0.. 0 }) { + let noteContainer = UIView() + let note = UILabel() + note.translatesAutoresizingMaskIntoConstraints = false + note.text = localizedString("astro_schedule_next_day_note") + note.textColor = AstroContextMenuTheme.secondaryText + note.font = .systemFont(ofSize: 14) + note.numberOfLines = 0 + noteContainer.addSubview(note) + NSLayoutConstraint.activate([ + note.leadingAnchor.constraint(equalTo: noteContainer.leadingAnchor, constant: 16), + note.trailingAnchor.constraint(equalTo: noteContainer.trailingAnchor, constant: -16), + note.topAnchor.constraint(equalTo: noteContainer.topAnchor), + note.bottomAnchor.constraint(equalTo: noteContainer.bottomAnchor) + ]) + contentStack.setCustomSpacing(12, after: daysStack) + contentStack.addArrangedSubview(noteContainer) + } + + NSLayoutConstraint.activate([ + contentStack.leadingAnchor.constraint(equalTo: card.leadingAnchor), + contentStack.trailingAnchor.constraint(equalTo: card.trailingAnchor), + contentStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 16), + contentStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16) + ]) + return card + } + + private static func header(item: AstroScheduleCardItem, + onResetPeriod: @escaping () -> Void, + onShiftPeriod: @escaping (Int) -> Void) -> UIView { + let header = UIStackView() + header.axis = .horizontal + header.alignment = .center + header.spacing = 8 + header.isLayoutMarginsRelativeArrangement = true + header.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 0) + + let titleColumn = UIStackView() + titleColumn.axis = .vertical + titleColumn.alignment = .leading + titleColumn.spacing = 2 + titleColumn.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let title = UILabel() + title.text = localizedString("astronomy_schedule") + title.textColor = AstroContextMenuTheme.primaryText + title.font = .systemFont(ofSize: 16, weight: .bold) + + let range = UILabel() + range.text = item.rangeLabel + range.textColor = AstroContextMenuTheme.secondaryText + range.font = .systemFont(ofSize: 14) + + titleColumn.addArrangedSubview(title) + titleColumn.addArrangedSubview(range) + + let buttons = UIStackView() + buttons.axis = .horizontal + buttons.alignment = .center + buttons.spacing = 4 + buttons.setContentHuggingPriority(.required, for: .horizontal) + buttons.setContentCompressionResistancePriority(.required, for: .horizontal) + + let reset = iconButton(name: "ic_custom_date", + accessibilityLabel: localizedString("astro_schedule_show_current_week")) { + onResetPeriod() + } + reset.isHidden = !item.showResetPeriodButton + let prev = iconButton(name: "ic_custom_arrow_back", + accessibilityLabel: localizedString("shared_string_previous")) { + onShiftPeriod(-AstroScheduleCardController.periodDays) + } + let next = iconButton(name: "ic_custom_arrow_forward", + accessibilityLabel: localizedString("shared_string_next")) { + onShiftPeriod(AstroScheduleCardController.periodDays) + } + buttons.addArrangedSubview(reset) + buttons.addArrangedSubview(prev) + buttons.addArrangedSubview(next) + + header.addArrangedSubview(titleColumn) + header.addArrangedSubview(buttons) + return header + } + + private static func iconButton(name: String, + accessibilityLabel: String, + action: @escaping () -> Void) -> UIButton { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.tintColor = AstroContextMenuTheme.activeIcon + button.setImage(AstroIcon.template(name)?.imageFlippedForRightToLeftLayoutDirection(), for: .normal) + button.accessibilityLabel = accessibilityLabel + button.addAction(UIAction { _ in action() }, for: .touchUpInside) + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 44), + button.heightAnchor.constraint(equalToConstant: 44) + ]) + return button + } + + private static func dayRow(_ day: AstroScheduleDayItem, showDivider: Bool, onTap: @escaping () -> Void) -> UIView { + let control = UIControl() + control.addAction(UIAction { _ in onTap() }, for: .touchUpInside) + + let row = UIView() + row.translatesAutoresizingMaskIntoConstraints = false + control.addSubview(row) + + let dayLabel = UILabel() + dayLabel.text = day.dayLabel + dayLabel.textColor = AstroContextMenuTheme.primaryText + dayLabel.font = .systemFont(ofSize: 16) + dayLabel.numberOfLines = 1 + dayLabel.lineBreakMode = .byTruncatingTail + dayLabel.adjustsFontSizeToFitWidth = true + dayLabel.minimumScaleFactor = 0.75 + dayLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + dayLabel.translatesAutoresizingMaskIntoConstraints = false + row.addSubview(dayLabel) + + let riseBlock = timeBlock(time: day.riseTime, arrow: riseArrow, alignment: .trailing) + row.addSubview(riseBlock) + + let graph = AstroScheduleGraphView() + graph.translatesAutoresizingMaskIntoConstraints = false + graph.submitModel(day.graph) + row.addSubview(graph) + + let setBlock = timeBlock(time: day.setTime, + arrow: setArrow, + suffix: nextDaySuffix(day.setDayOffset), + alignment: .leading) + row.addSubview(setBlock) + + let divider = UIView() + divider.backgroundColor = AstroContextMenuTheme.separator + divider.isHidden = !showDivider + divider.translatesAutoresizingMaskIntoConstraints = false + control.addSubview(divider) + let dividerHeight = divider.heightAnchor.constraint(equalToConstant: showDivider ? 1 : 0) + + NSLayoutConstraint.activate([ + control.heightAnchor.constraint(greaterThanOrEqualToConstant: 56), + + row.leadingAnchor.constraint(equalTo: control.leadingAnchor, constant: 16), + row.trailingAnchor.constraint(equalTo: control.trailingAnchor, constant: -4), + row.topAnchor.constraint(equalTo: control.topAnchor), + row.bottomAnchor.constraint(equalTo: divider.topAnchor), + row.heightAnchor.constraint(greaterThanOrEqualToConstant: 55), + + dayLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor), + dayLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor), + + riseBlock.leadingAnchor.constraint(equalTo: dayLabel.trailingAnchor, constant: 6), + riseBlock.centerYAnchor.constraint(equalTo: row.centerYAnchor), + riseBlock.widthAnchor.constraint(equalTo: dayLabel.widthAnchor, multiplier: 1.15), + + graph.leadingAnchor.constraint(equalTo: riseBlock.trailingAnchor, constant: 10), + graph.centerYAnchor.constraint(equalTo: row.centerYAnchor), + graph.widthAnchor.constraint(equalTo: dayLabel.widthAnchor, multiplier: 1.6), + graph.heightAnchor.constraint(equalToConstant: 24), + + setBlock.leadingAnchor.constraint(equalTo: graph.trailingAnchor, constant: 10), + setBlock.trailingAnchor.constraint(equalTo: row.trailingAnchor), + setBlock.centerYAnchor.constraint(equalTo: row.centerYAnchor), + setBlock.widthAnchor.constraint(equalTo: dayLabel.widthAnchor, multiplier: 1.35), + + divider.leadingAnchor.constraint(equalTo: control.leadingAnchor, constant: 16), + divider.trailingAnchor.constraint(equalTo: control.trailingAnchor, constant: -16), + divider.bottomAnchor.constraint(equalTo: control.bottomAnchor), + dividerHeight + ]) + return control + } + + private static func placeholderRow(showDivider _: Bool) -> UIView { + let view = UIView() + view.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true + view.alpha = 0 + return view + } + + private enum TimeBlockAlignment { + case leading + case trailing + } + + private static func timeBlock(time: String?, + arrow: String, + suffix: String? = nil, + alignment: TimeBlockAlignment) -> UIView { + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(stack) + + let arrowLabel = UILabel() + arrowLabel.text = arrow + arrowLabel.textColor = AstroContextMenuTheme.secondaryText + arrowLabel.font = .systemFont(ofSize: 12) + let timeLabel = UILabel() + timeLabel.attributedText = buildTimeText(time: time, suffix: suffix) + timeLabel.textColor = AstroContextMenuTheme.secondaryText + timeLabel.font = .systemFont(ofSize: 16) + timeLabel.numberOfLines = 1 + timeLabel.adjustsFontSizeToFitWidth = true + timeLabel.minimumScaleFactor = 0.7 + timeLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + stack.addArrangedSubview(arrowLabel) + stack.addArrangedSubview(timeLabel) + + let horizontalAnchor: NSLayoutConstraint + switch alignment { + case .leading: + horizontalAnchor = stack.leadingAnchor.constraint(equalTo: container.leadingAnchor) + case .trailing: + horizontalAnchor = stack.trailingAnchor.constraint(equalTo: container.trailingAnchor) + } + NSLayoutConstraint.activate([ + container.heightAnchor.constraint(greaterThanOrEqualToConstant: 24), + stack.topAnchor.constraint(greaterThanOrEqualTo: container.topAnchor), + stack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor), + stack.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor), + stack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor), + stack.centerYAnchor.constraint(equalTo: container.centerYAnchor), + horizontalAnchor + ]) + return container + } + + private static func buildTimeText(time: String?, suffix: String?) -> NSAttributedString { + let parts = splitTimeParts(time) + guard parts.main != emptyTime else { + return NSAttributedString(string: emptyTime, attributes: [.foregroundColor: AstroContextMenuTheme.secondaryText]) + } + let result = NSMutableAttributedString(string: parts.main, attributes: [ + .font: UIFont.systemFont(ofSize: 16), + .foregroundColor: AstroContextMenuTheme.secondaryText + ]) + if let meridiem = parts.meridiem, !meridiem.isEmpty { + result.append(NSAttributedString(string: " ")) + result.append(NSAttributedString(string: meridiem, attributes: [ + .font: UIFont.systemFont(ofSize: 11), + .foregroundColor: AstroContextMenuTheme.secondaryText + ])) + } + if let suffix, !suffix.isEmpty { + result.append(NSAttributedString(string: suffix, attributes: [ + .font: UIFont.systemFont(ofSize: 10), + .foregroundColor: AstroContextMenuTheme.secondaryText, + .baselineOffset: 6 + ])) + } + return result + } + + private static func splitTimeParts(_ time: String?) -> (main: String, meridiem: String?) { + guard let time, + !time.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return (emptyTime, nil) + } + let tokens = time.split(whereSeparator: { $0.isWhitespace }).map(String.init) + guard tokens.count > 1 else { + return (tokens.first ?? emptyTime, nil) + } + return (tokens.dropLast().joined(separator: " "), tokens.last) + } + + private static func nextDaySuffix(_ dayOffset: Int) -> String? { + dayOffset > 0 ? "+\(dayOffset)" : nil + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroScheduleGraphView.swift b/Sources/Plugins/Astronomy/contextmenu/AstroScheduleGraphView.swift new file mode 100644 index 0000000000..35b9fe6110 --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroScheduleGraphView.swift @@ -0,0 +1,145 @@ +// +// AstroScheduleGraphView.swift +// OsmAnd Maps +// +// Ported from Android AstroScheduleGraphView.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class AstroScheduleGraphView: UIView { + private var model: AstroScheduleDayGraphSnapshot? + private let palette = AstroChartColorPalette() + + override init(frame: CGRect) { + super.init(frame: frame) + isOpaque = false + backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func submitModel(_ model: AstroScheduleDayGraphSnapshot?) { + self.model = model + setNeedsDisplay() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true { + setNeedsDisplay() + } + } + + override func draw(_ rect: CGRect) { + guard let model, + bounds.width > 0, + bounds.height > 0 else { + return + } + let clip = UIBezierPath(roundedRect: bounds, cornerRadius: 2) + clip.addClip() + drawSunBackground(model) + drawObjectVisibilityOverlay(model) + } + + private func drawSunBackground(_ model: AstroScheduleDayGraphSnapshot) { + guard let context = UIGraphicsGetCurrentContext() else { + return + } + let drawStep: CGFloat = bounds.width > 256 ? 2 : 1 + var x: CGFloat = 0 + while x < bounds.width { + let nextX = min(bounds.width, x + drawStep) + let fraction = bounds.width <= 1 ? 0 : Double(x / (bounds.width - 1)) + palette.colorForSunAltitude(interpolate(model.sunAltitudes, fraction: fraction)).setFill() + context.fill(CGRect(x: x, y: 0, width: nextX - x, height: bounds.height)) + x = nextX + } + } + + private func drawObjectVisibilityOverlay(_ model: AstroScheduleDayGraphSnapshot) { + guard let context = UIGraphicsGetCurrentContext() else { + return + } + let objectBandHeight = min(CGFloat(16), bounds.height) + let objectBandTop = (bounds.height - objectBandHeight) / 2 + let objectBandBottom = objectBandTop + objectBandHeight + let sourceAltitudes = model.objectAltitudes + guard sourceAltitudes.count >= 2 else { + return + } + + let renderSampleCount = min(sourceAltitudes.count, max(2, Int(bounds.width))) + let renderAltitudes = (0.. 0 + if isVisible && segmentStart == -1 { + segmentStart = index + } + let isSegmentEnd = segmentStart != -1 && (!isVisible || index == renderSampleCount - 1) + guard isSegmentEnd else { + continue + } + let segmentEnd = isVisible && index == renderSampleCount - 1 ? index : index - 1 + let left = sampleToX(segmentStart, sampleCount: renderSampleCount) + let rightRaw = sampleToX(segmentEnd, sampleCount: renderSampleCount) + let right = rightRaw <= left ? min(bounds.width, left + 1) : rightRaw + if right > left { + let segmentRect = CGRect(x: left, + y: objectBandTop, + width: right - left, + height: objectBandBottom - objectBandTop) + if let gradient { + context.saveGState() + context.clip(to: segmentRect) + context.drawLinearGradient(gradient, + start: CGPoint(x: 0, y: objectBandTop), + end: CGPoint(x: bounds.width, y: objectBandTop), + options: []) + context.restoreGState() + } else { + palette.colorForPositiveObjectAltitude(altitude).setFill() + UIBezierPath(rect: segmentRect).fill() + } + } + segmentStart = -1 + } + } + + private func sampleToX(_ index: Int, sampleCount: Int) -> CGFloat { + guard sampleCount > 1, bounds.width > 0 else { + return 0 + } + let clampedIndex = max(0, min(sampleCount - 1, index)) + return CGFloat(clampedIndex) / CGFloat(sampleCount - 1) * bounds.width + } + + private func interpolate(_ values: [Double], fraction: Double) -> Double { + guard !values.isEmpty else { + return 0 + } + guard values.count > 1 else { + return values[0] + } + let index = min(1.0, max(0.0, fraction)) * Double(values.count - 1) + let startIndex = min(values.count - 1, max(0, Int(floor(index)))) + let endIndex = min(values.count - 1, startIndex + 1) + let t = index - Double(startIndex) + return values[startIndex] + (values[endIndex] - values[startIndex]) * t + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroVisibilityCardController.swift b/Sources/Plugins/Astronomy/contextmenu/AstroVisibilityCardController.swift new file mode 100644 index 0000000000..e423969f39 --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroVisibilityCardController.swift @@ -0,0 +1,287 @@ +// +// AstroVisibilityCardController.swift +// OsmAnd Maps +// +// Ported from Android AstroVisibilityCardController.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import CoreLocation +import Foundation +import OsmAndShared +import UIKit + +final class AstroVisibilityCardController { + private static let citySearchRadiusMeters = 50 * 1000 + + private(set) var skyObject: SkyObject? + private(set) var observer: Observer? + private(set) var date: Date = Date() + private(set) var timeZone: TimeZone = .current + + private(set) var riseTime: String? + private(set) var culminationTime: String? + private(set) var setTime: String? + private(set) var locationText = "" + private(set) var culminationColor = UIColor.clear + private(set) var titleText = localizedString("astro_today_visibility") + private(set) var showResetButton = false + private(set) var cursorReferenceTimeMillis: Int64 = 0 + + var onDataChanged: (() -> Void)? + + private var lastLocationKey: String? + private var graphSnapshot: AstroVisibilityGraphSnapshot? + private var graphObjectId: String? + private var graphObserverLat = Double.nan + private var graphObserverLon = Double.nan + private var graphObserverHeight = Double.nan + private var computeWorkItem: DispatchWorkItem? + private var locationWorkItem: DispatchWorkItem? + private let titleDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("EEEEMMMMd") + return formatter + }() + + func update(skyObject: SkyObject?, + observer: Observer?, + date: Date, + timeZone: TimeZone, + cursorReferenceTimeMillis: Int64, + isTodayVisibility: Bool) { + self.skyObject = skyObject + self.observer = observer + self.date = normalizedDay(date, timeZone: timeZone) + self.timeZone = timeZone + self.cursorReferenceTimeMillis = cursorReferenceTimeMillis + titleDateFormatter.timeZone = timeZone + titleText = isTodayVisibility + ? localizedString("astro_today_visibility") + : titleDateFormatter.string(from: self.date) + showResetButton = !isTodayVisibility + + guard let skyObject, let observer else { + cancelPendingWork() + riseTime = nil + culminationTime = nil + setTime = nil + locationText = "" + culminationColor = .clear + graphSnapshot = nil + graphObjectId = nil + graphObserverLat = .nan + graphObserverLon = .nan + graphObserverHeight = .nan + return + } + + let startLocal = noon(on: self.date, timeZone: timeZone) + let endLocal = startLocal.addingTimeInterval(24 * 60 * 60) + let riseSet = AstroUtils.nextRiseSet(object: skyObject, + startSearch: startLocal, + observer: observer, + windowStart: startLocal, + windowEnd: endLocal) + let culmination = AstroChartMath.findCulmination(obj: skyObject, + observer: observer, + startLocal: startLocal, + endLocal: endLocal) + let timeFormatter = createTimeFormatter(timeZone: timeZone) + + riseTime = riseSet.rise.map { timeFormatter.string(from: $0) } + culminationTime = culmination.time.map { timeFormatter.string(from: $0) } + setTime = riseSet.set.map { timeFormatter.string(from: $0) } + culminationColor = AstroChartColorPalette().colorForObjectAltitude(culmination.altitude ?? 0.0) + maybeRecomputeGraph(skyObject: skyObject, observer: observer, date: self.date, timeZone: timeZone) + + let location = resolveLocationTarget(observer: observer) + let locationKey = String(format: "%.6f,%.6f", location.coordinate.latitude, location.coordinate.longitude) + if lastLocationKey != locationKey || locationText.isEmpty { + lastLocationKey = locationKey + locationText = formatCoordinates(latitude: location.coordinate.latitude, longitude: location.coordinate.longitude) + onDataChanged?() + requestLocationText(location: location, key: locationKey) + } + } + + func buildItem() -> AstroVisibilityCardItem? { + guard skyObject != nil, observer != nil else { + return nil + } + return AstroVisibilityCardItem(graph: graphSnapshot, + cursorReferenceTimeMillis: cursorReferenceTimeMillis, + riseTime: riseTime, + culminationTime: culminationTime, + setTime: setTime, + locationText: locationText, + culminationColor: culminationColor, + titleText: titleText, + showResetButton: showResetButton) + } + + func cancelPendingWork() { + computeWorkItem?.cancel() + computeWorkItem = nil + locationWorkItem?.cancel() + locationWorkItem = nil + } + + private func maybeRecomputeGraph(skyObject: SkyObject, + observer: Observer, + date: Date, + timeZone: TimeZone) { + let graphStartMillis = millis(noon(on: date, timeZone: timeZone)) + let graphMatchesState = graphSnapshot?.timeZone == timeZone && + graphSnapshot?.startMillis == graphStartMillis && + graphObjectId == skyObject.id && + graphObserverLat == observer.latitude && + graphObserverLon == observer.longitude && + graphObserverHeight == observer.height + if graphMatchesState { + return + } + + computeWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { + return + } + let snapshot = self.computeGraphSnapshot(skyObject: skyObject, + observer: observer, + date: date, + timeZone: timeZone) + DispatchQueue.main.async { [weak self] in + guard let self, + !(self.computeWorkItem?.isCancelled ?? true), + self.skyObject?.id == skyObject.id, + self.observer?.latitude == observer.latitude, + self.observer?.longitude == observer.longitude, + self.observer?.height == observer.height, + self.date == date, + self.timeZone == timeZone else { + return + } + self.graphSnapshot = snapshot + self.graphObjectId = skyObject.id + self.graphObserverLat = observer.latitude + self.graphObserverLon = observer.longitude + self.graphObserverHeight = observer.height + self.onDataChanged?() + } + } + computeWorkItem = workItem + DispatchQueue.global(qos: .userInitiated).async(execute: workItem) + } + + private func computeGraphSnapshot(skyObject: SkyObject, + observer: Observer, + date: Date, + timeZone: TimeZone) -> AstroVisibilityGraphSnapshot { + let startLocal = noon(on: date, timeZone: timeZone) + let endLocal = startLocal.addingTimeInterval(24 * 60 * 60) + let samples = AstroChartMath.computeDaySamples(objectToRender: skyObject, + observer: observer, + startLocal: startLocal, + endLocal: endLocal, + sampleCount: AstroChartMath.visibilitySampleCount, + includeAzimuth: true) + return AstroVisibilityGraphSnapshot(startMillis: samples.startMillis, + endMillis: samples.endMillis, + timeZone: timeZone, + objectAltitudes: samples.objectAltitudes, + objectAzimuths: samples.objectAzimuths ?? Array(repeating: 0.0, count: samples.objectAltitudes.count), + sunAltitudes: samples.sunAltitudes) + } + + private func formatCoordinates(latitude: Double, longitude: Double) -> String { + let latDir = localizedString(latitude >= 0.0 ? "north_abbreviation" : "south_abbreviation") + let lonDir = localizedString(longitude >= 0.0 ? "east_abbreviation" : "west_abbreviation") + return String(format: "%.2f° %@, %.2f° %@", + locale: Locale(identifier: "en_US_POSIX"), + abs(latitude), + latDir, + abs(longitude), + lonDir) + } + + private func createTimeFormatter(timeZone: TimeZone) -> DateFormatter { + let formatter = DateFormatter() + formatter.timeZone = timeZone + formatter.locale = .current + formatter.dateFormat = OAUtilities.is12HourTimeFormat() ? "h:mm a" : "HH:mm" + return formatter + } + + private func resolveLocationTarget(observer: Observer) -> CLLocation { + if let trackingUtilities = OAMapViewTrackingUtilities.instance(), + trackingUtilities.isMapLinkedToLocation(), + let lastKnownLocation = OsmAndApp.swiftInstance()?.locationServices?.lastKnownLocation { + return lastKnownLocation + } + if let mapLocation = OARootViewController.instance()?.mapPanel?.mapViewController.getMapLocation() { + return mapLocation + } + return CLLocation(latitude: observer.latitude, longitude: observer.longitude) + } + + private func requestLocationText(location: CLLocation, key: String) { + locationWorkItem?.cancel() + let coordinate = location.coordinate + let coords = formatCoordinates(latitude: coordinate.latitude, longitude: coordinate.longitude) + let mapPanel = OARootViewController.instance()?.mapPanel + let workItem = DispatchWorkItem { [weak self] in + guard let self else { + return + } + let address = mapPanel?.findRoadName(byLat: coordinate.latitude, lon: coordinate.longitude) + let resolved = self.extractCity(address) ?? coords + DispatchQueue.main.async { [weak self] in + guard let self, + !(self.locationWorkItem?.isCancelled ?? true), + self.lastLocationKey == key else { + return + } + let changed = self.locationText != resolved + self.locationText = resolved + self.locationWorkItem = nil + if changed { + self.onDataChanged?() + } + } + } + locationWorkItem = workItem + DispatchQueue.global(qos: .userInitiated).async(execute: workItem) + } + + private func extractCity(_ address: String?) -> String? { + guard var normalized = address?.trimmingCharacters(in: .whitespacesAndNewlines), + !normalized.isEmpty else { + return nil + } + let nearPrefix = "\(localizedString("shared_string_near")) " + if normalized.hasPrefix(nearPrefix) { + normalized.removeFirst(nearPrefix.count) + normalized = normalized.trimmingCharacters(in: .whitespacesAndNewlines) + } + let city = normalized.components(separatedBy: ",").last?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return city.isEmpty ? nil : city + } + + private func normalizedDay(_ date: Date, timeZone: TimeZone) -> Date { + var calendar = Calendar.current + calendar.timeZone = timeZone + return calendar.startOfDay(for: date) + } + + private func noon(on date: Date, timeZone: TimeZone) -> Date { + var calendar = Calendar.current + calendar.timeZone = timeZone + let start = calendar.startOfDay(for: date) + return calendar.date(bySettingHour: 12, minute: 0, second: 0, of: start) ?? start.addingTimeInterval(12 * 60 * 60) + } + + private func millis(_ date: Date) -> Int64 { + Int64((date.timeIntervalSince1970 * 1000.0).rounded()) + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroVisibilityCardViewHolder.swift b/Sources/Plugins/Astronomy/contextmenu/AstroVisibilityCardViewHolder.swift new file mode 100644 index 0000000000..5a52eec705 --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroVisibilityCardViewHolder.swift @@ -0,0 +1,182 @@ +// +// AstroVisibilityCardViewHolder.swift +// OsmAnd Maps +// +// Ported from Android AstroVisibilityCardViewHolder.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum AstroVisibilityCardViewHolder { + static func makeView(item: AstroVisibilityCardItem, + onResetToToday: @escaping () -> Void, + onCursorTimeChanged: @escaping (Int64) -> Void) -> UIView { + let card = UIView() + card.translatesAutoresizingMaskIntoConstraints = false + card.backgroundColor = AstroContextMenuTheme.cardBackground + card.layer.cornerRadius = 12 + card.layer.masksToBounds = true + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 0 + stack.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(stack) + + let header = UIStackView() + header.axis = .horizontal + header.alignment = .center + header.spacing = 8 + header.isLayoutMarginsRelativeArrangement = true + header.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: item.showResetButton ? 8 : 16) + header.heightAnchor.constraint(greaterThanOrEqualToConstant: 48).isActive = true + let title = UILabel() + title.text = item.titleText + title.textColor = AstroContextMenuTheme.primaryText + title.font = .systemFont(ofSize: 16, weight: .bold) + title.numberOfLines = 0 + header.addArrangedSubview(title) + if item.showResetButton { + let resetButton = UIButton(type: .system) + resetButton.setImage(AstroIcon.template("ic_custom_date"), for: .normal) + resetButton.tintColor = AstroContextMenuTheme.activeIcon + resetButton.accessibilityLabel = localizedString("astro_visibility_show_today") + resetButton.addAction(UIAction { _ in onResetToToday() }, for: .touchUpInside) + resetButton.widthAnchor.constraint(equalToConstant: 44).isActive = true + resetButton.heightAnchor.constraint(equalToConstant: 44).isActive = true + header.addArrangedSubview(resetButton) + } + stack.addArrangedSubview(header) + + let graphView = AstroVisibilityGraphView() + graphView.translatesAutoresizingMaskIntoConstraints = false + graphView.heightAnchor.constraint(equalToConstant: 220).isActive = true + graphView.submitGraph(item.graph, cursorReferenceTimeMillis: item.cursorReferenceTimeMillis) + graphView.onCursorTimeChanged = onCursorTimeChanged + stack.addArrangedSubview(graphView) + + let events = UIStackView() + events.axis = .horizontal + events.alignment = .fill + events.distribution = .fill + events.spacing = 0 + events.isLayoutMarginsRelativeArrangement = true + events.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 12, leading: 16, bottom: 0, trailing: 16) + let rise = makeEvent(time: item.riseTime, + symbol: "▲", + title: localizedString("astro_rise"), + symbolColor: AstroContextMenuTheme.activeIcon) + let culmination = makeEvent(time: item.culminationTime, + symbol: "●", + title: localizedString("astro_culmination"), + symbolColor: item.culminationColor) + let set = makeEvent(time: item.setTime, + symbol: "▼", + title: localizedString("astro_set"), + symbolColor: AstroContextMenuTheme.activeIcon) + let eventViews = [rise, culmination, set].compactMap { $0 } + if !eventViews.isEmpty { + if let rise { + events.addArrangedSubview(rise) + } + if rise != nil && culmination != nil { + events.addArrangedSubview(makeDivider()) + } + if let culmination { + events.addArrangedSubview(culmination) + } + if culmination != nil && set != nil { + events.addArrangedSubview(makeDivider()) + } + if let set { + events.addArrangedSubview(set) + } + eventViews.dropFirst().forEach { $0.widthAnchor.constraint(equalTo: eventViews[0].widthAnchor).isActive = true } + stack.addArrangedSubview(events) + } + + if !item.locationText.isEmpty { + let location = UILabel() + location.text = item.locationText + location.textColor = AstroContextMenuTheme.secondaryText + location.font = .systemFont(ofSize: 14) + location.numberOfLines = 0 + let iconView = UIImageView(image: .icCustomMarker) + iconView.tintColor = AstroContextMenuTheme.secondaryIcon + iconView.contentMode = .scaleAspectFit + let row = UIStackView(arrangedSubviews: [iconView, location]) + row.axis = .horizontal + row.alignment = .center + row.spacing = 10 + row.isLayoutMarginsRelativeArrangement = true + row.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 12, leading: 16, bottom: 16, trailing: 16) + iconView.widthAnchor.constraint(equalToConstant: 20).isActive = true + iconView.heightAnchor.constraint(equalToConstant: 20).isActive = true + stack.addArrangedSubview(row) + } + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: card.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: card.trailingAnchor), + stack.topAnchor.constraint(equalTo: card.topAnchor), + stack.bottomAnchor.constraint(equalTo: card.bottomAnchor) + ]) + return card + } + + private static func makeEvent(time: String?, + symbol: String, + title: String, + symbolColor: UIColor) -> UIView? { + guard let time, !time.isEmpty else { + return nil + } + let block = UIStackView() + block.axis = .vertical + block.alignment = .leading + block.spacing = 3 + let valueRow = UIStackView() + valueRow.axis = .horizontal + valueRow.alignment = .center + valueRow.spacing = 6 + let timeLabel = UILabel() + timeLabel.text = time + timeLabel.textColor = AstroContextMenuTheme.activeText + timeLabel.font = .systemFont(ofSize: 16, weight: .bold) + timeLabel.adjustsFontSizeToFitWidth = true + timeLabel.minimumScaleFactor = 0.75 + let symbolLabel = UILabel() + symbolLabel.text = symbol + symbolLabel.textColor = symbolColor + symbolLabel.font = .systemFont(ofSize: 12, weight: .bold) + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.textColor = AstroContextMenuTheme.secondaryText + titleLabel.font = .systemFont(ofSize: 12) + titleLabel.adjustsFontSizeToFitWidth = true + titleLabel.minimumScaleFactor = 0.7 + valueRow.addArrangedSubview(timeLabel) + valueRow.addArrangedSubview(symbolLabel) + block.addArrangedSubview(valueRow) + block.addArrangedSubview(titleLabel) + return block + } + + private static func makeDivider() -> UIView { + let divider = UIView() + divider.backgroundColor = AstroContextMenuTheme.separator + divider.widthAnchor.constraint(equalToConstant: 1).isActive = true + let wrapper = UIView() + wrapper.translatesAutoresizingMaskIntoConstraints = false + wrapper.addSubview(divider) + divider.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + wrapper.widthAnchor.constraint(equalToConstant: 17), + divider.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor), + divider.topAnchor.constraint(equalTo: wrapper.topAnchor), + divider.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor) + ]) + return wrapper + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroVisibilityGraphView.swift b/Sources/Plugins/Astronomy/contextmenu/AstroVisibilityGraphView.swift new file mode 100644 index 0000000000..ae986a6235 --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/AstroVisibilityGraphView.swift @@ -0,0 +1,658 @@ +// +// AstroVisibilityGraphView.swift +// OsmAnd Maps +// +// Ported from Android AstroVisibilityGraphView.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class AstroVisibilityGraphView: UIView { + private enum ZeroCrossingType { + case sunrise + case sunset + } + + private struct PlotArea { + let left: CGFloat + let top: CGFloat + let right: CGFloat + let bottom: CGFloat + + var width: CGFloat { right - left } + var height: CGFloat { bottom - top } + } + + private struct ZeroCrossing { + let x: CGFloat + let type: ZeroCrossingType + } + + private enum Constants { + static let minAltitudeRender = -40.0 + static let maxAltitudeRender = 95.0 + + static let outerLeftPadding: CGFloat = 16 + static let outerRightPadding: CGFloat = 16 + static let outerTopPadding: CGFloat = 1 + static let outerBottomPadding: CGFloat = 1 + static let rightAxisOutset: CGFloat = 30 + + static let yGridStroke: CGFloat = 1 + static let yGridDash: CGFloat = 6 + static let yGridGap: CGFloat = 6 + static let yLabelTextSize: CGFloat = 10 + static let yLabelToLineGap: CGFloat = 2 + + static let xLabelTextSize: CGFloat = 10 + static let xTickStroke: CGFloat = 1 + static let xTickHeight: CGFloat = 7 + static let xLabelToGraphGap: CGFloat = 2 + static let labelEdgeMin: CGFloat = 0 + + static let sunIconSize: CGFloat = 12 + static let sunIconRaise: CGFloat = 2 + + static let cursorLineStroke: CGFloat = 2 + static let cursorSideOffset: CGFloat = 2 + static let cursorDotRadius: CGFloat = 5 + static let cursorDotStroke: CGFloat = 2 + + static let markerTextSize: CGFloat = 14 + static let markerBorderStroke: CGFloat = 1 + static let markerSeparatorStroke: CGFloat = 1 + static let markerCorner: CGFloat = 6 + static let markerHorizontalPadding: CGFloat = 10 + static let markerHeight: CGFloat = 24 + static let markerSeparatorInset: CGFloat = 2 + static let markerToGraphGap: CGFloat = 3 + } + + private enum GraphColors { + static let xTick = UIColor(rgbValue: 0xBEBCC2) + static let xLabel = UIColor(rgbValue: 0x7D738C) + static let yLabel = UIColor(rgbValue: 0x2183F4) + static let yGrid = UIColor(rgbValue: 0xD8D7DB).withAlphaComponent(0.5) + static let yZero = UIColor(rgbValue: 0xD8D7DB) + static let markerBorder = UIColor(rgbValue: 0xAEB4C2) + static let markerAzimuth = UIColor(rgbValue: 0x14CC9E) + static let cursorLineCenter = UIColor(rgbValue: 0xE6EBF9) + static let cursorLineSide = UIColor.white.withAlphaComponent(0.2) + static let cursorDotFill = UIColor(rgbValue: 0x1852FF) + static let cursorDotStroke = UIColor.white + } + + private var model: AstroVisibilityGraphSnapshot? + private let palette = AstroChartColorPalette() + private var isTouchTracking = false + private var cursorVisible = false + private var cursorX: CGFloat = 0 + private var cursorReferenceTimeMillis: Int64? + private weak var disabledParentScrollView: UIScrollView? + + var onCursorTimeChanged: ((Int64) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + isOpaque = false + backgroundColor = .clear + contentMode = .redraw + isUserInteractionEnabled = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func submitGraph(_ graph: AstroVisibilityGraphSnapshot?, cursorReferenceTimeMillis: Int64) { + model = graph + self.cursorReferenceTimeMillis = cursorReferenceTimeMillis + if let graph { + syncCursorToReference(graph) + } else { + cursorVisible = false + } + setNeedsDisplay() + } + + override func layoutSubviews() { + super.layoutSubviews() + model.map(syncCursorToReference) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true { + setNeedsDisplay() + } + } + + override func draw(_ rect: CGRect) { + guard let model, + model.size >= 2, + let area = getPlotArea(), + let context = UIGraphicsGetCurrentContext() else { + return + } + drawDynamicBackground(context: context, area: area, model: model) + if let trajectory = buildTrajectoryPath(area: area, model: model) { + drawObjectFill(context: context, area: area, trajectory: trajectory) + } + drawYAxisGridAndLabels(context: context, area: area) + drawXAxisTicksAndLabels(context: context, area: area, model: model) + drawSunriseSunsetIcons(area: area, model: model) + if cursorVisible { + drawCursor(context: context, area: area, model: model) + } + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first, + let area = getPlotArea(), + areaContains(touch.location(in: self), area: area) else { + super.touchesBegan(touches, with: event) + return + } + setParentScrollEnabled(false) + isTouchTracking = true + updateCursor(touch.location(in: self).x, area: area, notifyCallback: true) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard isTouchTracking, + let touch = touches.first, + let area = getPlotArea() else { + super.touchesMoved(touches, with: event) + return + } + setParentScrollEnabled(false) + updateCursor(touch.location(in: self).x, area: area, notifyCallback: true) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + defer { + isTouchTracking = false + setParentScrollEnabled(true) + } + guard isTouchTracking, + let touch = touches.first, + let area = getPlotArea() else { + super.touchesEnded(touches, with: event) + return + } + updateCursor(touch.location(in: self).x, area: area, notifyCallback: true) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + isTouchTracking = false + setParentScrollEnabled(true) + if let model { + syncCursorToReference(model) + } else { + cursorVisible = false + } + setNeedsDisplay() + } + + private func drawDynamicBackground(context: CGContext, area: PlotArea, model: AstroVisibilityGraphSnapshot) { + let drawStep: CGFloat = 2 + var x = area.left + while x < area.right { + let nextX = min(area.right, x + drawStep) + let centerFraction = Double((((x + nextX) * 0.5 - area.left) / area.width).clamped(to: 0...1)) + let altitude = interpolate(model.sunAltitudes, fraction: centerFraction) + context.setFillColor(palette.colorForSunAltitude(altitude).cgColor) + context.fill(CGRect(x: x, y: area.top, width: nextX - x, height: area.height)) + x = nextX + } + } + + private func drawObjectFill(context: CGContext, area: PlotArea, trajectory: UIBezierPath) { + let fillPath = UIBezierPath(cgPath: trajectory.cgPath) + fillPath.addLine(to: CGPoint(x: area.right, y: area.bottom)) + fillPath.addLine(to: CGPoint(x: area.left, y: area.bottom)) + fillPath.close() + + guard let gradient = buildObjectFillGradient(area: area) else { + return + } + context.saveGState() + context.addPath(fillPath.cgPath) + context.clip() + context.drawLinearGradient(gradient, + start: CGPoint(x: 0, y: area.top), + end: CGPoint(x: 0, y: area.bottom), + options: []) + context.restoreGState() + } + + private func buildObjectFillGradient(area: PlotArea) -> CGGradient? { + let transitionHalf = AstroChartColorPalette.objectGradientTransitionDegrees / 2.0 + let yRange = max(1, area.height) + func position(for altitude: Double) -> CGFloat { + ((yForAltitude(altitude, area: area) - area.top) / yRange).clamped(to: 0...1) + } + let colors = [ + palette.fillGt45.cgColor, + palette.fillGt45.cgColor, + palette.fill15To45.cgColor, + palette.fill15To45.cgColor, + palette.fill0To15.cgColor, + palette.fill0To15.cgColor, + palette.fillLt0.cgColor, + palette.fillLt0.cgColor + ] as CFArray + let locations: [CGFloat] = [ + 0, + position(for: 45.0 + transitionHalf), + position(for: 45.0 - transitionHalf), + position(for: 15.0 + transitionHalf), + position(for: 15.0 - transitionHalf), + position(for: 0.0 + transitionHalf), + position(for: 0.0 - transitionHalf), + 1 + ] + return CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors, locations: locations) + } + + private func drawYAxisGridAndLabels(context: CGContext, area: PlotArea) { + let axisEndX = area.right + Constants.rightAxisOutset + let labelFont = UIFont.systemFont(ofSize: Constants.yLabelTextSize) + let attributes: [NSAttributedString.Key: Any] = [ + .font: labelFont, + .foregroundColor: GraphColors.yLabel + ] + for altitude in stride(from: -30, through: 90, by: 15) { + let y = yForAltitude(Double(altitude), area: area) + context.saveGState() + context.setLineWidth(Constants.yGridStroke) + context.setLineDash(phase: 0, lengths: [Constants.yGridDash, Constants.yGridGap]) + context.setStrokeColor((altitude == 0 ? GraphColors.yZero : GraphColors.yGrid).cgColor) + context.move(to: CGPoint(x: area.left, y: y)) + context.addLine(to: CGPoint(x: axisEndX, y: y)) + context.strokePath() + context.restoreGState() + + let text = "\(altitude)°" as NSString + let size = text.size(withAttributes: attributes) + text.draw(at: CGPoint(x: axisEndX - size.width, + y: y + Constants.yLabelToLineGap), + withAttributes: attributes) + } + } + + private func drawXAxisTicksAndLabels(context: CGContext, area: PlotArea, model: AstroVisibilityGraphSnapshot) { + let formatter = createAxisTimeFormatter(timeZone: model.timeZone) + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: Constants.xLabelTextSize), + .foregroundColor: GraphColors.xLabel + ] + for step in 0...6 { + let fraction = CGFloat(step) / 6.0 + let millis = model.startMillis + Int64(Double(model.endMillis - model.startMillis) * Double(fraction)) + let x = timeToX(millis, area: area, model: model) + context.saveGState() + context.setStrokeColor(GraphColors.xTick.cgColor) + context.setLineWidth(Constants.xTickStroke) + context.move(to: CGPoint(x: x, y: area.bottom - Constants.xTickHeight)) + context.addLine(to: CGPoint(x: x, y: area.bottom)) + context.strokePath() + context.restoreGState() + + let label = formatter.string(from: date(fromMillis: millis)) as NSString + let size = label.size(withAttributes: attributes) + let half = size.width / 2 + let clampedX = x.clamped(to: (Constants.labelEdgeMin + half)...(bounds.width - Constants.labelEdgeMin - half)) + label.draw(at: CGPoint(x: clampedX - half, + y: area.bottom + Constants.xLabelToGraphGap), + withAttributes: attributes) + } + } + + private func drawSunriseSunsetIcons(area: PlotArea, model: AstroVisibilityGraphSnapshot) { + let crossings = findZeroCrossings(model: model, area: area) + guard !crossings.isEmpty else { + return + } + let y = yForAltitude(0.0, area: area) - Constants.sunIconRaise + for crossing in crossings { + let imageName = crossing.type == .sunrise ? "ic_action_sunrise_12" : "ic_action_sunset_12" + guard let image = AstroIcon.original(imageName) ?? AstroIcon.template(imageName) else { + continue + } + image.draw(in: CGRect(x: crossing.x - Constants.sunIconSize / 2, + y: y - Constants.sunIconSize / 2, + width: Constants.sunIconSize, + height: Constants.sunIconSize)) + } + } + + private func drawCursor(context: CGContext, area: PlotArea, model: AstroVisibilityGraphSnapshot) { + let x = cursorX.clamped(to: area.left...area.right) + let fraction = Double(((x - area.left) / area.width).clamped(to: 0...1)) + let altitude = interpolate(model.objectAltitudes, fraction: fraction) + let azimuth = interpolateAzimuth(model.objectAzimuths, fraction: fraction) + let millis = model.startMillis + Int64(Double(model.endMillis - model.startMillis) * fraction) + let y = yForAltitude(altitude, area: area) + let lineTop = area.top - Constants.markerToGraphGap + let sideOffset = Constants.cursorSideOffset + + context.saveGState() + context.setLineWidth(Constants.cursorLineStroke) + context.setStrokeColor(GraphColors.cursorLineSide.cgColor) + context.move(to: CGPoint(x: x - sideOffset, y: lineTop)) + context.addLine(to: CGPoint(x: x - sideOffset, y: area.bottom)) + context.move(to: CGPoint(x: x + sideOffset, y: lineTop)) + context.addLine(to: CGPoint(x: x + sideOffset, y: area.bottom)) + context.strokePath() + context.setStrokeColor(GraphColors.cursorLineCenter.cgColor) + context.move(to: CGPoint(x: x, y: lineTop)) + context.addLine(to: CGPoint(x: x, y: area.bottom)) + context.strokePath() + context.restoreGState() + + GraphColors.cursorDotFill.setFill() + GraphColors.cursorDotStroke.setStroke() + let dot = UIBezierPath(ovalIn: CGRect(x: x - Constants.cursorDotRadius, + y: y - Constants.cursorDotRadius, + width: Constants.cursorDotRadius * 2, + height: Constants.cursorDotRadius * 2)) + dot.fill() + dot.lineWidth = Constants.cursorDotStroke + dot.stroke() + + let timeLabel = createMarkerTimeFormatter(timeZone: model.timeZone).string(from: date(fromMillis: millis)) + let altitudeLabel = "\(Int(altitude.rounded()))° \(localizedString("astro_alt_short"))" + let azimuthLabel = "\(Int(azimuth.rounded()))° \(localizedString("astro_az_short")) (\(cardinalDirection(for: azimuth)))" + drawCursorMarker(context: context, + area: area, + anchorX: x, + timeLabel: timeLabel, + altitudeLabel: altitudeLabel, + azimuthLabel: azimuthLabel, + lineTop: lineTop) + } + + private func drawCursorMarker(context: CGContext, + area: PlotArea, + anchorX: CGFloat, + timeLabel: String, + altitudeLabel: String, + azimuthLabel: String, + lineTop: CGFloat) { + let font = UIFont.systemFont(ofSize: Constants.markerTextSize) + let timeAttributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: AstroContextMenuTheme.primaryText.currentMapThemeColor + ] + let altitudeAttributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: GraphColors.yLabel + ] + let azimuthAttributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: GraphColors.markerAzimuth + ] + let timeSize = (timeLabel as NSString).size(withAttributes: timeAttributes) + let altitudeSize = (altitudeLabel as NSString).size(withAttributes: altitudeAttributes) + let azimuthSize = (azimuthLabel as NSString).size(withAttributes: azimuthAttributes) + let section1 = timeSize.width + Constants.markerHorizontalPadding * 2 + let section2 = altitudeSize.width + Constants.markerHorizontalPadding * 2 + let section3 = azimuthSize.width + Constants.markerHorizontalPadding * 2 + let totalWidth = section1 + section2 + section3 + let left = (anchorX - totalWidth / 2).clamped(to: area.left...max(area.left, area.right - totalWidth)) + let rect = CGRect(x: left, + y: lineTop - Constants.markerHeight, + width: totalWidth, + height: Constants.markerHeight) + + context.saveGState() + context.setStrokeColor(GraphColors.markerBorder.cgColor) + context.setLineWidth(Constants.markerBorderStroke) + context.addPath(UIBezierPath(roundedRect: rect, cornerRadius: Constants.markerCorner).cgPath) + context.strokePath() + + context.setLineWidth(Constants.markerSeparatorStroke) + let separator1 = rect.minX + section1 + let separator2 = separator1 + section2 + let separatorTop = rect.minY + Constants.markerSeparatorInset + let separatorBottom = rect.maxY - Constants.markerSeparatorInset + context.move(to: CGPoint(x: separator1, y: separatorTop)) + context.addLine(to: CGPoint(x: separator1, y: separatorBottom)) + context.move(to: CGPoint(x: separator2, y: separatorTop)) + context.addLine(to: CGPoint(x: separator2, y: separatorBottom)) + context.strokePath() + context.restoreGState() + + let textY = rect.minY + (rect.height - font.lineHeight) / 2 + (timeLabel as NSString).draw(at: CGPoint(x: rect.minX + Constants.markerHorizontalPadding, y: textY), + withAttributes: timeAttributes) + (altitudeLabel as NSString).draw(at: CGPoint(x: separator1 + Constants.markerHorizontalPadding, y: textY), + withAttributes: altitudeAttributes) + (azimuthLabel as NSString).draw(at: CGPoint(x: separator2 + Constants.markerHorizontalPadding, y: textY), + withAttributes: azimuthAttributes) + } + + private func buildTrajectoryPath(area: PlotArea, model: AstroVisibilityGraphSnapshot) -> UIBezierPath? { + guard model.size >= 2 else { + return nil + } + let renderSampleCount = min(model.size, max(2, Int(area.width.rounded()))) + guard renderSampleCount >= 2 else { + return nil + } + let path = UIBezierPath() + for index in 0.. [ZeroCrossing] { + let altitudes = model.objectAltitudes + guard altitudes.count > 1 else { + return [] + } + var result: [ZeroCrossing] = [] + for index in 1.. 0 && current > 0) || (previous < 0 && current < 0) { + continue + } + let delta = current - previous + if delta == 0 { + continue + } + let t = ((0 - previous) / delta).clamped(to: 0...1) + let sampleIndex = Double(index - 1) + t + let fraction = sampleIndex / Double(altitudes.count - 1) + result.append(ZeroCrossing(x: area.left + area.width * CGFloat(fraction), + type: delta > 0 ? .sunrise : .sunset)) + } + return result + } + + private func syncCursorToReference(_ graph: AstroVisibilityGraphSnapshot) { + guard let area = getPlotArea(), + let referenceMillis = cursorReferenceTimeMillis else { + cursorVisible = false + return + } + let cursorMillis = resolveCursorMillis(referenceMillis, graph: graph) + cursorX = timeToX(cursorMillis, area: area, model: graph) + cursorVisible = true + } + + private func resolveCursorMillis(_ referenceMillis: Int64, graph: AstroVisibilityGraphSnapshot) -> Int64 { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = graph.timeZone + let startDate = date(fromMillis: graph.startMillis) + let endDate = date(fromMillis: graph.endMillis) + let referenceDate = date(fromMillis: referenceMillis) + var components = calendar.dateComponents([.year, .month, .day], from: startDate) + let referenceComponents = calendar.dateComponents([.hour, .minute, .second, .nanosecond], from: referenceDate) + components.hour = referenceComponents.hour + components.minute = referenceComponents.minute + components.second = referenceComponents.second + components.nanosecond = referenceComponents.nanosecond + var candidate = calendar.date(from: components) ?? startDate + if candidate < startDate, + let nextDay = calendar.date(byAdding: .day, value: 1, to: candidate) { + candidate = nextDay + } + if candidate > endDate { + candidate = endDate + } + return min(graph.endMillis, max(graph.startMillis, millis(from: candidate))) + } + + private func updateCursor(_ x: CGFloat, area: PlotArea, notifyCallback: Bool) { + guard let model else { + return + } + cursorX = x.clamped(to: area.left...area.right) + cursorVisible = true + if notifyCallback { + let fraction = Double(((cursorX - area.left) / area.width).clamped(to: 0...1)) + let reference = model.startMillis + Int64(Double(model.endMillis - model.startMillis) * fraction) + cursorReferenceTimeMillis = reference + onCursorTimeChanged?(reference) + } + setNeedsDisplay() + } + + private func yForAltitude(_ altitude: Double, area: PlotArea) -> CGFloat { + let clamped = altitude.clamped(to: Constants.minAltitudeRender...Constants.maxAltitudeRender) + let fraction = (clamped - Constants.minAltitudeRender) / (Constants.maxAltitudeRender - Constants.minAltitudeRender) + return area.bottom - area.height * CGFloat(fraction) + } + + private func timeToX(_ millis: Int64, area: PlotArea, model: AstroVisibilityGraphSnapshot) -> CGFloat { + let total = max(1, model.endMillis - model.startMillis) + let passed = min(total, max(0, millis - model.startMillis)) + return area.left + area.width * CGFloat(Double(passed) / Double(total)) + } + + private func interpolate(_ values: [Double], fraction: Double) -> Double { + guard !values.isEmpty else { + return 0 + } + guard values.count > 1 else { + return values[0] + } + let index = fraction.clamped(to: 0...1) * Double(values.count - 1) + let start = min(values.count - 1, max(0, Int(floor(index)))) + let end = min(values.count - 1, start + 1) + let t = index - Double(start) + return values[start] + (values[end] - values[start]) * t + } + + private func interpolateAzimuth(_ values: [Double], fraction: Double) -> Double { + guard !values.isEmpty else { + return 0 + } + guard values.count > 1 else { + return normalizeAzimuth(values[0]) + } + let index = fraction.clamped(to: 0...1) * Double(values.count - 1) + let start = min(values.count - 1, max(0, Int(floor(index)))) + let end = min(values.count - 1, start + 1) + let t = index - Double(start) + let delta = fmod(values[end] - values[start] + 540.0, 360.0) - 180.0 + return normalizeAzimuth(values[start] + delta * t) + } + + private func normalizeAzimuth(_ value: Double) -> Double { + var azimuth = value.truncatingRemainder(dividingBy: 360) + if azimuth < 0 { + azimuth += 360 + } + return azimuth + } + + private func getPlotArea() -> PlotArea? { + guard bounds.width > 90, bounds.height > 90 else { + return nil + } + let left = Constants.outerLeftPadding + let right = bounds.width - Constants.rightAxisOutset - Constants.outerRightPadding + let top = Constants.outerTopPadding + Constants.markerHeight + Constants.markerToGraphGap + let bottom = bounds.height - xAxisReservedHeight() - Constants.outerBottomPadding + guard right > left, bottom > top else { + return nil + } + return PlotArea(left: left, top: top, right: right, bottom: bottom) + } + + private func xAxisReservedHeight() -> CGFloat { + Constants.xLabelToGraphGap + UIFont.systemFont(ofSize: Constants.xLabelTextSize).lineHeight + } + + private func areaContains(_ point: CGPoint, area: PlotArea) -> Bool { + point.x >= area.left && point.x <= area.right && point.y >= area.top && point.y <= area.bottom + } + + private func createAxisTimeFormatter(timeZone: TimeZone) -> DateFormatter { + let formatter = DateFormatter() + formatter.timeZone = timeZone + formatter.locale = .current + formatter.dateFormat = OAUtilities.is12HourTimeFormat() ? "h a" : "HH:mm" + return formatter + } + + private func createMarkerTimeFormatter(timeZone: TimeZone) -> DateFormatter { + let formatter = DateFormatter() + formatter.timeZone = timeZone + formatter.locale = .current + formatter.dateFormat = OAUtilities.is12HourTimeFormat() ? "h:mm a" : "HH:mm" + return formatter + } + + private func cardinalDirection(for azimuth: Double) -> String { + let north = localizedString("north_abbreviation") + let east = localizedString("east_abbreviation") + let south = localizedString("south_abbreviation") + let west = localizedString("west_abbreviation") + let directions = [north, "\(north)\(east)", east, "\(south)\(east)", south, "\(south)\(west)", west, "\(north)\(west)"] + let index = Int(((normalizeAzimuth(azimuth) + 22.5) / 45.0).rounded(.down)) % directions.count + return directions[index] + } + + private func date(fromMillis millis: Int64) -> Date { + Date(timeIntervalSince1970: TimeInterval(millis) / 1000.0) + } + + private func millis(from date: Date) -> Int64 { + Int64((date.timeIntervalSince1970 * 1000.0).rounded()) + } + + private func setParentScrollEnabled(_ enabled: Bool) { + if enabled { + disabledParentScrollView?.isScrollEnabled = true + disabledParentScrollView = nil + return + } + if disabledParentScrollView == nil { + var parent = superview + while let current = parent { + if let scrollView = current as? UIScrollView { + disabledParentScrollView = scrollView + break + } + parent = current.superview + } + } + disabledParentScrollView?.isScrollEnabled = false + } +} + +private extension Comparable { + func clamped(to limits: ClosedRange) -> Self { + min(max(self, limits.lowerBound), limits.upperBound) + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/GetAstroImagesTask.swift b/Sources/Plugins/Astronomy/contextmenu/GetAstroImagesTask.swift new file mode 100644 index 0000000000..ed390ed6e6 --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/GetAstroImagesTask.swift @@ -0,0 +1,55 @@ +// +// GetAstroImagesTask.swift +// OsmAnd Maps +// +// Ported from Android GetAstroImagesTask.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared + +final class GetAstroImagesTask { + static let getImageCardThreadId = 10105 + + protocol GetImageCardsListener: AnyObject { + func onTaskStarted() + func onFinish(wikidataId: String, images: [OsmAndShared.WikiImage]?) + } + + let wikidataId: String + weak var getImageCardsListener: GetImageCardsListener? + private let networkResponseListener: WikiCoreHelperNetworkResponseListener? + private var workItem: DispatchWorkItem? + + init(wikidataId: String, + getImageCardsListener: GetImageCardsListener?, + networkResponseListener: WikiCoreHelperNetworkResponseListener? = nil) { + self.wikidataId = wikidataId + self.getImageCardsListener = getImageCardsListener + self.networkResponseListener = networkResponseListener + } + + func execute() { + getImageCardsListener?.onTaskStarted() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { + return + } + let images = WikiCoreHelper.shared.getAstroImageList(wikidataId: self.wikidataId, listener: self.networkResponseListener) + DispatchQueue.main.async { [weak self] in + guard let self, !(self.workItem?.isCancelled ?? true) else { + return + } + self.getImageCardsListener?.onFinish(wikidataId: self.wikidataId, images: images) + } + } + self.workItem = workItem + DispatchQueue.global(qos: .utility).async(execute: workItem) + } + + func cancel() { + workItem?.cancel() + workItem = nil + } +} diff --git a/Sources/Plugins/Astronomy/contextmenu/MetricsAdapter.swift b/Sources/Plugins/Astronomy/contextmenu/MetricsAdapter.swift new file mode 100644 index 0000000000..84a4c70aee --- /dev/null +++ b/Sources/Plugins/Astronomy/contextmenu/MetricsAdapter.swift @@ -0,0 +1,107 @@ +// +// MetricsAdapter.swift +// OsmAnd Maps +// +// Ported from Android MetricsAdapter.kt. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class MetricsAdapter { + struct MetricUi { + let value: String + let label: String + } + + private(set) var currentList: [MetricUi] = [] + + func submit(_ list: [MetricUi]) { + currentList = list + } + + func makeMetricsView() -> UIView { + let scrollView = UIScrollView() + scrollView.showsHorizontalScrollIndicator = false + scrollView.alwaysBounceHorizontal = true + + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .fill + stack.spacing = 0 + stack.translatesAutoresizingMaskIntoConstraints = false + + scrollView.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stack.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + + for (index, item) in currentList.enumerated() { + let view = MetricView() + view.bind(item, showDivider: index != currentList.indices.last) + stack.addArrangedSubview(view) + } + return scrollView + } +} + +private final class MetricView: UIView { + private let valueLabel = UILabel() + private let titleLabel = UILabel() + private let divider = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func bind(_ item: MetricsAdapter.MetricUi, showDivider: Bool) { + valueLabel.text = item.value + titleLabel.text = item.label + divider.isHidden = !showDivider + } + + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + widthAnchor.constraint(greaterThanOrEqualToConstant: 112).isActive = true + + let stack = UIStackView(arrangedSubviews: [valueLabel, titleLabel]) + stack.axis = .vertical + stack.alignment = .leading + stack.spacing = 2 + stack.translatesAutoresizingMaskIntoConstraints = false + + valueLabel.font = .systemFont(ofSize: 20, weight: .semibold) + valueLabel.textColor = AstroContextMenuTheme.activeText + valueLabel.adjustsFontSizeToFitWidth = true + valueLabel.minimumScaleFactor = 0.8 + + titleLabel.font = .systemFont(ofSize: 15, weight: .regular) + titleLabel.textColor = AstroContextMenuTheme.secondaryText + + divider.backgroundColor = AstroContextMenuTheme.separator + divider.translatesAutoresizingMaskIntoConstraints = false + + addSubview(stack) + addSubview(divider) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 7), + stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -7), + + divider.trailingAnchor.constraint(equalTo: trailingAnchor), + divider.centerYAnchor.constraint(equalTo: centerYAnchor), + divider.widthAnchor.constraint(equalToConstant: 1), + divider.heightAnchor.constraint(equalToConstant: 34) + ]) + } +} diff --git a/Sources/Plugins/Astronomy/search/StarMapCatalogsAdapter.swift b/Sources/Plugins/Astronomy/search/StarMapCatalogsAdapter.swift new file mode 100644 index 0000000000..98403a0eca --- /dev/null +++ b/Sources/Plugins/Astronomy/search/StarMapCatalogsAdapter.swift @@ -0,0 +1,160 @@ +// +// StarMapCatalogsAdapter.swift +// OsmAnd Maps +// +// Created by Codex on 06.06.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +struct StarMapCatalogEntry { + let catalog: Catalog + let displayName: String + let description: String? + let objectCount: Int +} + +final class StarMapCatalogsAdapter: NSObject, UITableViewDataSource, UITableViewDelegate { + struct Snapshot { + let entries: [StarMapCatalogEntry] + + static let empty = Snapshot(entries: []) + } + + private var snapshot: Snapshot + private let nightMode: Bool + private let onScroll: (UIScrollView) -> Void + private let onCatalogSelected: (StarMapCatalogEntry) -> Void + + init(nightMode: Bool, + snapshot: Snapshot, + onScroll: @escaping (UIScrollView) -> Void, + onCatalogSelected: @escaping (StarMapCatalogEntry) -> Void) { + self.nightMode = nightMode + self.snapshot = snapshot + self.onScroll = onScroll + self.onCatalogSelected = onCatalogSelected + super.init() + } + + func submitSnapshot(_ snapshot: Snapshot) { + self.snapshot = snapshot + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + snapshot.entries.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: Self.reuseIdentifier) as? StarMapCatalogCell + ?? StarMapCatalogCell(reuseIdentifier: Self.reuseIdentifier) + bind(cell, entry: snapshot.entries[indexPath.row], isLastItem: indexPath.row == snapshot.entries.count - 1) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard snapshot.entries.indices.contains(indexPath.row) else { + return + } + onCatalogSelected(snapshot.entries[indexPath.row]) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + onScroll(scrollView) + } + + private func bind(_ cell: StarMapCatalogCell, entry: StarMapCatalogEntry, isLastItem: Bool) { + cell.configure(icon: .icCustomBookInfo, + iconTintColor: StarMapSearchLightPalette.defaultIcon, + title: entry.displayName, + subtitle: entry.description?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? entry.description ?? "" + : formatCatalogObjectsCount(entry.objectCount), + showDivider: !isLastItem) + } + + private func formatCatalogObjectsCount(_ count: Int) -> String { + String.localizedStringWithFormat((localizedString("astro_catalog_objects_count") as NSString) as String, count) as String + } + + private static let reuseIdentifier = "StarMapCatalogCell" +} + +private final class StarMapCatalogCell: UITableViewCell { + private let rowIconView = UIImageView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let dividerView = UIView() + + init(reuseIdentifier: String) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + selectionStyle = .default + backgroundColor = StarMapSearchLightPalette.listBackground + contentView.backgroundColor = StarMapSearchLightPalette.listBackground + contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true + + rowIconView.contentMode = .scaleAspectFit + rowIconView.translatesAutoresizingMaskIntoConstraints = false + rowIconView.isUserInteractionEnabled = false + + titleLabel.textColor = StarMapSearchLightPalette.primaryText + titleLabel.font = UIFont.preferredFont(forTextStyle: .body) + titleLabel.numberOfLines = 1 + + subtitleLabel.textColor = StarMapSearchLightPalette.secondaryText + subtitleLabel.font = UIFont.preferredFont(forTextStyle: .footnote) + subtitleLabel.numberOfLines = 2 + + let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + textStack.axis = .vertical + textStack.spacing = 2 + textStack.translatesAutoresizingMaskIntoConstraints = false + textStack.isUserInteractionEnabled = false + + dividerView.backgroundColor = StarMapSearchLightPalette.separator + dividerView.translatesAutoresizingMaskIntoConstraints = false + dividerView.isUserInteractionEnabled = false + + contentView.addSubview(rowIconView) + contentView.addSubview(textStack) + contentView.addSubview(dividerView) + + NSLayoutConstraint.activate([ + rowIconView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + rowIconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + rowIconView.widthAnchor.constraint(equalToConstant: 24), + rowIconView.heightAnchor.constraint(equalToConstant: 24), + + textStack.leadingAnchor.constraint(equalTo: rowIconView.trailingAnchor, constant: 32), + textStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + textStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + textStack.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 8), + textStack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -8), + + dividerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 72), + dividerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + dividerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + dividerView.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale) + ]) + } + + func configure(icon: UIImage?, iconTintColor: UIColor, title: String, subtitle: String, showDivider: Bool) { + rowIconView.image = icon + rowIconView.tintColor = iconTintColor + titleLabel.text = title + subtitleLabel.text = subtitle + subtitleLabel.isHidden = subtitle.isEmpty + dividerView.isHidden = !showDivider + } +} diff --git a/Sources/Plugins/Astronomy/search/StarMapSearchHelper.swift b/Sources/Plugins/Astronomy/search/StarMapSearchHelper.swift new file mode 100644 index 0000000000..2891f1a797 --- /dev/null +++ b/Sources/Plugins/Astronomy/search/StarMapSearchHelper.swift @@ -0,0 +1,208 @@ +// +// StarMapSearchHelper.swift +// OsmAnd Maps +// +// Created by Codex on 06.06.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared +import UIKit + +final class StarMapSearchHelper { + private struct RiseSetCacheEntry { + let nextRise: Date? + let nextSet: Date? + } + + private var riseSetCache: [String: RiseSetCacheEntry] = [:] + private var visibleTonightCache: [String: Bool] = [:] + private let cacheLock = NSLock() + private var computationContext = StarMapSearchComputationContext(observer: Observer(latitude: 0.0, longitude: 0.0, height: 0.0), + now: Date(), + dusk: Date(), + dawn: Date().addingTimeInterval(12 * 60 * 60)) + + func updateComputationContext(_ computationContext: StarMapSearchComputationContext) { + cacheLock.lock() + defer { cacheLock.unlock() } + self.computationContext = computationContext + riseSetCache.removeAll() + visibleTonightCache.removeAll() + } + + func getVisibleTonight(_ entry: StarMapSearchEntry) -> Bool { + cacheLock.lock() + if entry.visibleTonightCalculated { + cacheLock.unlock() + return entry.isVisibleTonight + } + if let cachedVisibleTonight = visibleTonightCache[entry.objectRef.id] { + entry.isVisibleTonight = cachedVisibleTonight + entry.visibleTonightCalculated = true + cacheLock.unlock() + return cachedVisibleTonight + } + let context = computationContext + cacheLock.unlock() + + let riseSet = AstroUtils.nextRiseSet(object: entry.objectRef, + startSearch: context.dusk, + observer: context.observer, + windowStart: context.dusk, + windowEnd: context.dawn) + let visibleAtDusk = AstroUtils.altitude(entry.objectRef, at: context.dusk, observer: context.observer) > 0 + let isVisibleTonight = visibleAtDusk || riseSet.rise != nil || riseSet.set != nil + + cacheLock.lock() + defer { cacheLock.unlock() } + if let cachedVisibleTonight = visibleTonightCache[entry.objectRef.id] { + entry.isVisibleTonight = cachedVisibleTonight + entry.visibleTonightCalculated = true + return cachedVisibleTonight + } + entry.isVisibleTonight = isVisibleTonight + entry.visibleTonightCalculated = true + visibleTonightCache[entry.objectRef.id] = isVisibleTonight + return isVisibleTonight + } + + func getRiseSortValue(_ entry: StarMapSearchEntry) -> Int64 { + ensureRiseSet(entry) + return entry.nextRise.map(millisecondsSince1970) ?? Int64.max + } + + func getSetSortValue(_ entry: StarMapSearchEntry) -> Int64 { + ensureRiseSet(entry) + return entry.nextSet.map(millisecondsSince1970) ?? Int64.max + } + + func resolveEventText(_ entry: StarMapSearchEntry) -> NSAttributedString { + ensureRiseSet(entry) + let rise = entry.nextRise + let set = entry.nextSet + let eventText: String + if let rise, let set { + if rise < set { + eventText = formatEvent(rise, isRise: true) + } else { + eventText = formatEvent(set, isRise: false) + } + } else if let rise { + eventText = formatEvent(rise, isRise: true) + } else if let set { + eventText = formatEvent(set, isRise: false) + } else if entry.objectRef.altitude > 0 { + eventText = localizedString("astro_search_always_up") + } else { + eventText = localizedString("astro_search_never_rises") + } + return replaceEventArrowWithIcon(eventText) + } + + func preloadRiseSet(_ entries: ArraySlice) { + for entry in entries { + ensureRiseSet(entry) + } + } + + private func ensureRiseSet(_ entry: StarMapSearchEntry) { + cacheLock.lock() + if entry.riseSetCalculated { + cacheLock.unlock() + return + } + if let cachedRiseSet = riseSetCache[entry.objectRef.id] { + entry.nextRise = cachedRiseSet.nextRise + entry.nextSet = cachedRiseSet.nextSet + entry.riseSetCalculated = true + cacheLock.unlock() + return + } + let context = computationContext + cacheLock.unlock() + + let riseSet = AstroUtils.nextRiseSet(object: entry.objectRef, + startSearch: context.now, + observer: context.observer) + + cacheLock.lock() + defer { cacheLock.unlock() } + if entry.riseSetCalculated { + return + } + if let cachedRiseSet = riseSetCache[entry.objectRef.id] { + entry.nextRise = cachedRiseSet.nextRise + entry.nextSet = cachedRiseSet.nextSet + entry.riseSetCalculated = true + return + } + entry.nextRise = riseSet.rise + entry.nextSet = riseSet.set + entry.riseSetCalculated = true + riseSetCache[entry.objectRef.id] = RiseSetCacheEntry(nextRise: riseSet.rise, nextSet: riseSet.set) + } + + private func formatEvent(_ time: Date, isRise: Bool) -> String { + let formattedTime = AstroUtils.formatLocalTime(time) + let calendar = Calendar.current + let startDate = calendar.startOfDay(for: computationContext.now) + let eventDate = calendar.startOfDay(for: time) + let daysBetween = calendar.dateComponents([.day], from: startDate, to: eventDate).day ?? 0 + if daysBetween == 1 { + let tomorrow = localizedString("tomorrow") + if isRise { + return String(format: localizedString("astro_search_rises_tomorrow"), tomorrow, formattedTime) + } + return String(format: localizedString("astro_search_sets_tomorrow"), tomorrow, formattedTime) + } else if isRise { + return String(format: localizedString("astro_search_rises_at"), formattedTime) + } else { + return String(format: localizedString("astro_search_sets_at"), formattedTime) + } + } + + private func replaceEventArrowWithIcon(_ text: String) -> NSAttributedString { + let iconName: String + let arrow: String + if text.contains(Self.RISE_ARROW) { + arrow = Self.RISE_ARROW + iconName = "ic_action_arrow_top_right_16" + } else if text.contains(Self.SET_ARROW) { + arrow = Self.SET_ARROW + iconName = "ic_action_arrow_bottom_right_16" + } else if text.contains(Self.UP_ARROW) { + arrow = Self.UP_ARROW + iconName = "ic_action_arrow_up2_16" + } else if text.contains(Self.DOWN_ARROW) { + arrow = Self.DOWN_ARROW + iconName = "ic_action_arrow_down_16" + } else { + return NSAttributedString(string: text) + } + + guard let image = AstroIcon.template(iconName)?.withTintColor(.iconColorSecondary, renderingMode: .alwaysOriginal) else { + return NSAttributedString(string: text) + } + let attachment = NSTextAttachment() + attachment.image = image + attachment.bounds = CGRect(x: 0, y: -3, width: 16, height: 16) + let result = NSMutableAttributedString(string: text) + let nsText = text as NSString + let range = nsText.range(of: arrow) + if range.location != NSNotFound { + result.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment)) + } + return result + } + + private func millisecondsSince1970(_ date: Date) -> Int64 { + Int64((date.timeIntervalSince1970 * 1000.0).rounded()) + } + + private static let RISE_ARROW = "↗" + private static let SET_ARROW = "↘" + private static let UP_ARROW = "↑" + private static let DOWN_ARROW = "↓" +} diff --git a/Sources/Plugins/Astronomy/search/StarMapSearchPreparedData.swift b/Sources/Plugins/Astronomy/search/StarMapSearchPreparedData.swift new file mode 100644 index 0000000000..420b95abcf --- /dev/null +++ b/Sources/Plugins/Astronomy/search/StarMapSearchPreparedData.swift @@ -0,0 +1,96 @@ +// +// StarMapSearchPreparedData.swift +// OsmAnd Maps +// +// Created by Codex on 06.06.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import OsmAndShared +import UIKit + +struct StarMapSearchComputationContext { + let observer: Observer + let now: Date + let dusk: Date + let dawn: Date +} + +struct StarMapSearchPreparedData { + let entries: [StarMapSearchEntry] + let catalogEntries: [StarMapCatalogEntry] + let widToDisplayName: [String: String] + let computationContext: StarMapSearchComputationContext +} + +final class StarMapSearchPreparedDataFactory { + private let dataProvider: AstroDataProvider + private let nightMode: Bool + + init(dataProvider: AstroDataProvider, nightMode: Bool) { + self.dataProvider = dataProvider + self.nightMode = nightMode + } + + func create(parent: StarMapViewController?) -> StarMapSearchPreparedData { + let objects = parent?.getSearchableObjects() ?? [] + let observer = parent?.getSearchObserver() ?? Observer(latitude: 0.0, longitude: 0.0, height: 0.0) + let currentDate = parent?.getSearchCurrentDate() ?? Date() + let computationContext = createComputationContext(observer: observer, date: currentDate) + var widToDisplayName: [String: String] = [:] + let primaryIconColor = StarMapControlTheme.resolved(.iconColorDefault, nightMode: nightMode) + + let entries = objects.map { obj in + if !obj.wid.isEmpty { + widToDisplayName[obj.wid] = obj.niceName() + } + return StarMapSearchEntry(objectRef: obj, + displayName: obj.niceName(), + magnitude: obj.magnitude, + category: mapStarMapSearchCategory(obj), + iconRes: AstroUtils.getObjectTypeIcon(obj.type), + iconColor: obj.type.isSunSystem() ? obj.color : primaryIconColor, + catalogWids: Set(obj.catalogs.map(\.wid))) + } + + return StarMapSearchPreparedData(entries: entries, + catalogEntries: buildCatalogEntries(preparedEntries: entries), + widToDisplayName: widToDisplayName, + computationContext: computationContext) + } + + private func createComputationContext(observer: Observer, date: Date) -> StarMapSearchComputationContext { + let calendar = Calendar.current + let dayStart = calendar.startOfDay(for: date) + let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(24 * 60 * 60) + let twilight = AstroUtils.computeTwilight(startLocal: dayStart, + endLocal: dayEnd, + observer: observer, + timeZone: TimeZone.current) + let dusk = twilight.civilDusk ?? calendar.date(bySettingHour: 18, minute: 0, second: 0, of: dayStart) ?? dayStart.addingTimeInterval(18 * 60 * 60) + let dawnRaw = twilight.civilDawn + let dawn: Date + if let dawnRaw { + dawn = dawnRaw > dusk ? dawnRaw : calendar.date(byAdding: .day, value: 1, to: dawnRaw) ?? dawnRaw.addingTimeInterval(24 * 60 * 60) + } else { + dawn = calendar.date(byAdding: .hour, value: 12, to: dusk) ?? dusk.addingTimeInterval(12 * 60 * 60) + } + return StarMapSearchComputationContext(observer: observer, now: date, dusk: dusk, dawn: dawn) + } + + private func buildCatalogEntries(preparedEntries: [StarMapSearchEntry]) -> [StarMapCatalogEntry] { + var objectCountByCatalogWid: [String: Int] = [:] + for entry in preparedEntries { + for catalogWid in entry.catalogWids { + objectCountByCatalogWid[catalogWid, default: 0] += 1 + } + } + return dataProvider.getCatalogs().map { catalog in + StarMapCatalogEntry(catalog: catalog, + displayName: catalog.name, + description: dataProvider.getAstroArticle(wikidataId: catalog.wid)?.description, + objectCount: objectCountByCatalogWid[catalog.wid] ?? 0) + } + } +} diff --git a/Sources/Plugins/Astronomy/search/StarMapSearchResultsAdapter.swift b/Sources/Plugins/Astronomy/search/StarMapSearchResultsAdapter.swift new file mode 100644 index 0000000000..717bc39758 --- /dev/null +++ b/Sources/Plugins/Astronomy/search/StarMapSearchResultsAdapter.swift @@ -0,0 +1,483 @@ +// +// StarMapSearchResultsAdapter.swift +// OsmAnd Maps +// +// Created by Codex on 06.06.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class StarMapSearchResultsAdapter: NSObject, UITableViewDataSource, UITableViewDelegate { + struct Snapshot { + let entries: [StarMapSearchEntry] + let categoryPreset: StarMapSearchCategoryFilter? + let infoHeaderCategory: StarMapSearchCategoryFilter? + let useExploreRowLayout: Bool + + static let empty = Snapshot(entries: [], + categoryPreset: nil, + infoHeaderCategory: nil, + useExploreRowLayout: false) + } + + private var snapshot: Snapshot + private let nightMode: Bool + private let widToDisplayName: () -> [String: String] + private let eventTextProvider: (StarMapSearchEntry) -> NSAttributedString + private let onScroll: (UIScrollView) -> Void + private let onEntrySelected: (StarMapSearchEntry) -> Void + private lazy var resultFormatter = StarMapSearchResultFormatter(nightMode: nightMode, + widToDisplayName: widToDisplayName, + eventTextProvider: eventTextProvider) + + init(nightMode: Bool, + snapshot: Snapshot, + widToDisplayName: @escaping () -> [String: String], + eventTextProvider: @escaping (StarMapSearchEntry) -> NSAttributedString, + onScroll: @escaping (UIScrollView) -> Void, + onEntrySelected: @escaping (StarMapSearchEntry) -> Void) { + self.nightMode = nightMode + self.snapshot = snapshot + self.widToDisplayName = widToDisplayName + self.eventTextProvider = eventTextProvider + self.onScroll = onScroll + self.onEntrySelected = onEntrySelected + super.init() + } + + func submitSnapshot(_ snapshot: Snapshot) { + self.snapshot = snapshot + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + snapshot.entries.count + (snapshot.infoHeaderCategory != nil ? 1 : 0) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if let infoHeaderCategory = snapshot.infoHeaderCategory, indexPath.row == 0 { + let cell = tableView.dequeueReusableCell(withIdentifier: Self.infoReuseIdentifier) as? StarMapSearchInfoCell + ?? StarMapSearchInfoCell(reuseIdentifier: Self.infoReuseIdentifier) + bindInfo(cell, category: infoHeaderCategory) + return cell + } + let entry = getEntryForPosition(indexPath.row) + if snapshot.useExploreRowLayout { + let cell = tableView.dequeueReusableCell(withIdentifier: Self.exploreReuseIdentifier) as? StarMapSearchExploreCell + ?? StarMapSearchExploreCell(reuseIdentifier: Self.exploreReuseIdentifier) + bindExploreResult(cell, entry: entry) + return cell + } + let cell = tableView.dequeueReusableCell(withIdentifier: Self.itemReuseIdentifier) as? StarMapSearchObjectCell + ?? StarMapSearchObjectCell(reuseIdentifier: Self.itemReuseIdentifier) + bindResult(cell, entry: entry) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if snapshot.infoHeaderCategory != nil && indexPath.row == 0 { + return + } + guard let entry = getEntryForSelection(indexPath.row) else { + return + } + onEntrySelected(entry) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + onScroll(scrollView) + } + + private func getEntryForPosition(_ position: Int) -> StarMapSearchEntry { + snapshot.entries[getEntryIndexForPosition(position)] + } + + private func getEntryForSelection(_ position: Int) -> StarMapSearchEntry? { + let entryIndex = getEntryIndexForPosition(position) + guard snapshot.entries.indices.contains(entryIndex) else { + return nil + } + return snapshot.entries[entryIndex] + } + + private func getEntryIndexForPosition(_ position: Int) -> Int { + snapshot.infoHeaderCategory != nil ? position - 1 : position + } + + private func bindInfo(_ cell: StarMapSearchInfoCell, category: StarMapSearchCategoryFilter) { + cell.configure(icon: AstroIcon.template(getCategoryIconRes(category)), + iconTintColor: StarMapControlTheme.activeForeground(nightMode: nightMode), + text: localizedString(getCategoryInfoTextRes(category))) + } + + private func bindResult(_ cell: StarMapSearchObjectCell, entry: StarMapSearchEntry) { + cell.configure(title: entry.displayName, subtitle: resultFormatter.buildSubtitle(entry, categoryPreset: snapshot.categoryPreset)) + resultFormatter.bindIcon(cell.objectIconView, entry: entry, categoryPreset: snapshot.categoryPreset) + } + + private func bindExploreResult(_ cell: StarMapSearchExploreCell, entry: StarMapSearchEntry) { + cell.configure(title: entry.displayName, subtitle: resultFormatter.buildSubtitle(entry, categoryPreset: snapshot.categoryPreset)) + resultFormatter.bindIcon(cell.rowIconView, entry: entry, categoryPreset: snapshot.categoryPreset) + } + + private static let infoReuseIdentifier = "StarMapSearchInfoCell" + private static let itemReuseIdentifier = "StarMapSearchItemCell" + private static let exploreReuseIdentifier = "StarMapSearchExploreCell" +} + +private final class StarMapSearchInfoCell: UITableViewCell { + private let cardView = UIView() + private let infoIconView = UIImageView() + private let infoLabel = UILabel() + + init(reuseIdentifier: String) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + selectionStyle = .none + backgroundColor = StarMapSearchLightPalette.listBackground + contentView.backgroundColor = StarMapSearchLightPalette.listBackground + + cardView.backgroundColor = StarMapSearchLightPalette.appBarBackground + cardView.layer.cornerRadius = 10 + cardView.translatesAutoresizingMaskIntoConstraints = false + cardView.isUserInteractionEnabled = false + + infoIconView.contentMode = .scaleAspectFit + infoIconView.translatesAutoresizingMaskIntoConstraints = false + + infoLabel.textColor = StarMapSearchLightPalette.primaryText + infoLabel.font = UIFont.preferredFont(forTextStyle: .body) + infoLabel.numberOfLines = 0 + infoLabel.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(cardView) + cardView.addSubview(infoIconView) + cardView.addSubview(infoLabel) + + NSLayoutConstraint.activate([ + cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), + cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), + cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + + infoIconView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), + infoIconView.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 18), + infoIconView.widthAnchor.constraint(equalToConstant: 24), + infoIconView.heightAnchor.constraint(equalToConstant: 24), + + infoLabel.leadingAnchor.constraint(equalTo: infoIconView.trailingAnchor, constant: 24), + infoLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16), + infoLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 12), + infoLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -12) + ]) + } + + func configure(icon: UIImage?, iconTintColor: UIColor, text: String) { + infoIconView.image = icon + infoIconView.tintColor = iconTintColor + infoLabel.text = text + } +} + +private final class StarMapSearchObjectCell: UITableViewCell { + let objectIconView = UIImageView() + private let nameLabel = UILabel() + private let infoLabel = UILabel() + private let dividerView = UIView() + + init(reuseIdentifier: String) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + selectionStyle = .default + backgroundColor = StarMapSearchLightPalette.listBackground + contentView.backgroundColor = StarMapSearchLightPalette.listBackground + contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 72).isActive = true + + nameLabel.textColor = StarMapSearchLightPalette.primaryText + nameLabel.font = UIFont.preferredFont(forTextStyle: .title3) + nameLabel.adjustsFontForContentSizeCategory = true + nameLabel.numberOfLines = 1 + + infoLabel.textColor = StarMapSearchLightPalette.secondaryText + infoLabel.font = UIFont.preferredFont(forTextStyle: .body) + infoLabel.adjustsFontForContentSizeCategory = true + infoLabel.numberOfLines = 2 + + let textStack = UIStackView(arrangedSubviews: [nameLabel, infoLabel]) + textStack.axis = .vertical + textStack.spacing = 4 + textStack.isUserInteractionEnabled = false + textStack.translatesAutoresizingMaskIntoConstraints = false + + objectIconView.contentMode = .scaleAspectFit + objectIconView.translatesAutoresizingMaskIntoConstraints = false + objectIconView.isUserInteractionEnabled = false + + dividerView.backgroundColor = StarMapSearchLightPalette.separator + dividerView.translatesAutoresizingMaskIntoConstraints = false + dividerView.isUserInteractionEnabled = false + + contentView.addSubview(textStack) + contentView.addSubview(objectIconView) + contentView.addSubview(dividerView) + + NSLayoutConstraint.activate([ + textStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + textStack.trailingAnchor.constraint(equalTo: objectIconView.leadingAnchor, constant: -16), + textStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + textStack.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 8), + textStack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -8), + + objectIconView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -36), + objectIconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + objectIconView.widthAnchor.constraint(equalToConstant: 24), + objectIconView.heightAnchor.constraint(equalToConstant: 24), + + dividerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + dividerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + dividerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + dividerView.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale) + ]) + } + + func configure(title: String, subtitle: NSAttributedString) { + nameLabel.text = title + infoLabel.attributedText = subtitle.withSearchSecondaryTextColor() + infoLabel.isHidden = subtitle.string.isEmpty + } +} + +private final class StarMapSearchExploreCell: UITableViewCell { + let rowIconView = UIImageView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let dividerView = UIView() + + init(reuseIdentifier: String) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + selectionStyle = .default + backgroundColor = StarMapSearchLightPalette.listBackground + contentView.backgroundColor = StarMapSearchLightPalette.listBackground + contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true + + rowIconView.contentMode = .scaleAspectFit + rowIconView.translatesAutoresizingMaskIntoConstraints = false + rowIconView.isUserInteractionEnabled = false + + titleLabel.textColor = StarMapSearchLightPalette.primaryText + titleLabel.font = UIFont.preferredFont(forTextStyle: .body) + titleLabel.numberOfLines = 1 + + subtitleLabel.textColor = StarMapSearchLightPalette.secondaryText + subtitleLabel.font = UIFont.preferredFont(forTextStyle: .footnote) + subtitleLabel.numberOfLines = 2 + + let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + textStack.axis = .vertical + textStack.spacing = 2 + textStack.isUserInteractionEnabled = false + textStack.translatesAutoresizingMaskIntoConstraints = false + + dividerView.backgroundColor = StarMapSearchLightPalette.separator + dividerView.translatesAutoresizingMaskIntoConstraints = false + dividerView.isUserInteractionEnabled = false + + contentView.addSubview(rowIconView) + contentView.addSubview(textStack) + contentView.addSubview(dividerView) + + NSLayoutConstraint.activate([ + rowIconView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + rowIconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + rowIconView.widthAnchor.constraint(equalToConstant: 24), + rowIconView.heightAnchor.constraint(equalToConstant: 24), + + textStack.leadingAnchor.constraint(equalTo: rowIconView.trailingAnchor, constant: 32), + textStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + textStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + textStack.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 8), + textStack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -8), + + dividerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 72), + dividerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + dividerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + dividerView.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale) + ]) + } + + func configure(title: String, subtitle: NSAttributedString) { + titleLabel.text = title + subtitleLabel.attributedText = subtitle.withSearchSecondaryTextColor() + subtitleLabel.isHidden = subtitle.string.isEmpty + } +} + +private extension NSAttributedString { + func withSearchSecondaryTextColor() -> NSAttributedString { + let result = NSMutableAttributedString(attributedString: self) + result.addAttribute(.foregroundColor, + value: StarMapSearchLightPalette.secondaryText, + range: NSRange(location: 0, length: result.length)) + return result + } +} + +private final class StarMapSearchResultFormatter { + private let nightMode: Bool + private let widToDisplayName: () -> [String: String] + private let eventTextProvider: (StarMapSearchEntry) -> NSAttributedString + + init(nightMode: Bool, + widToDisplayName: @escaping () -> [String: String], + eventTextProvider: @escaping (StarMapSearchEntry) -> NSAttributedString) { + self.nightMode = nightMode + self.widToDisplayName = widToDisplayName + self.eventTextProvider = eventTextProvider + } + + func bindIcon(_ iconView: UIImageView?, entry: StarMapSearchEntry, categoryPreset: StarMapSearchCategoryFilter?) { + let iconCategory = categoryPreset ?? entry.category + iconView?.image = AstroIcon.template(getCategoryIconRes(iconCategory)) + iconView?.tintColor = StarMapSearchLightPalette.defaultIcon + } + + func buildSubtitle(_ entry: StarMapSearchEntry, categoryPreset: StarMapSearchCategoryFilter?) -> NSAttributedString { + let descriptorText = buildDescriptor(entry, categoryPreset: categoryPreset) + let result = NSMutableAttributedString(string: descriptorText) + if entry.objectRef.type == .CONSTELLATION { + result.append(NSAttributedString(string: " • ")) + result.append(eventTextProvider(entry)) + return result + } + let magnitudeText = String(format: localizedString("astro_search_magnitude_short"), entry.magnitude) + result.append(NSAttributedString(string: " • \(magnitudeText) • ")) + result.append(eventTextProvider(entry)) + return result + } + + private func buildDescriptor(_ entry: StarMapSearchEntry, categoryPreset: StarMapSearchCategoryFilter?) -> String { + let obj = entry.objectRef + let parentName = resolveParentName(obj) + switch categoryPreset { + case .CONSTELLATIONS: + return localizedString("astro_type_constellation") + case .STARS: + if parentName?.isEmpty != false { + return localizedString("astro_type_star") + } + return String(format: localizedString("astro_search_in_location"), parentName ?? "") + case .NEBULAS, .STAR_CLUSTERS, .DEEP_SKY: + let typeLabel = getSingularTypeLabel(obj.type) + if parentName?.isEmpty != false { + return typeLabel + } + return String(format: localizedString("astro_search_type_in_location"), typeLabel, parentName ?? "") + default: + return getSingularTypeLabel(obj.type) + } + } + + private func getSingularTypeLabel(_ type: SkyObjectType) -> String { + switch type { + case .SUN: + return localizedString("astro_name_sun") + case .MOON: + return localizedString("astro_name_moon") + case .PLANET: + return localizedString("astro_type_planet") + case .STAR: + return localizedString("astro_type_star") + case .GALAXY: + return localizedString("astro_type_galaxy") + case .NEBULA: + return localizedString("astro_type_nebula") + case .BLACK_HOLE: + return localizedString("astro_type_black_hole") + case .OPEN_CLUSTER: + return localizedString("astro_type_open_cluster") + case .GLOBULAR_CLUSTER: + return localizedString("astro_type_globular_cluster") + case .GALAXY_CLUSTER: + return localizedString("astro_type_galaxy_cluster") + case .CONSTELLATION: + return localizedString("astro_type_constellation") + } + } + + private func resolveParentName(_ obj: SkyObject) -> String? { + let centerWid = obj.centerWId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if centerWid.isEmpty { + return nil + } + let mappedName = widToDisplayName()[centerWid] + if mappedName?.isEmpty == false { + return mappedName + } + let fallback = centerWid.replacingOccurrences(of: "_", with: " ") + return fallback.isEmpty ? nil : fallback + } +} + +func getCategoryIconRes(_ category: StarMapSearchCategoryFilter) -> String { + switch category { + case .SOLAR_SYSTEM: + return "ic_custom_planet_outlined" + case .CONSTELLATIONS: + return "ic_custom_constellations" + case .STARS: + return "ic_custom_star_shine" + case .NEBULAS: + return "ic_custom_nebulas" + case .STAR_CLUSTERS: + return "ic_custom_star_clusters" + case .DEEP_SKY: + return "ic_custom_galaxy" + case .ALL: + return "ic_custom_search" + } +} + +func getCategoryInfoTextRes(_ category: StarMapSearchCategoryFilter) -> String { + switch category { + case .SOLAR_SYSTEM: + return "astro_search_info_solar_system" + case .CONSTELLATIONS: + return "astro_search_info_constellations" + case .STARS: + return "astro_search_info_stars" + case .NEBULAS: + return "astro_search_info_nebulas" + case .STAR_CLUSTERS: + return "astro_search_info_star_clusters" + case .DEEP_SKY: + return "astro_search_info_deep_sky" + case .ALL: + return "astro_search_info_solar_system" + } +} diff --git a/Sources/Plugins/Astronomy/search/StarMapSearchState.swift b/Sources/Plugins/Astronomy/search/StarMapSearchState.swift new file mode 100644 index 0000000000..b0a51aade6 --- /dev/null +++ b/Sources/Plugins/Astronomy/search/StarMapSearchState.swift @@ -0,0 +1,538 @@ +// +// StarMapSearchState.swift +// OsmAnd Maps +// +// Created by Codex on 06.06.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import Foundation +import UIKit + +enum StarMapSearchSortMode: String { + case NEWEST_FIRST + case OLDEST_FIRST + case NAME_ASC + case NAME_DESC + case BRIGHTEST_FIRST + case FAINTEST_FIRST + case RISES_SOONEST + case SETS_SOONEST +} + +enum StarMapSearchTypeFilter: String { + case SHOW_ALL + case VISIBLE_NOW + case VISIBLE_TONIGHT +} + +enum StarMapSearchCategoryFilter: String, CaseIterable, Hashable { + case ALL + case SOLAR_SYSTEM + case CONSTELLATIONS + case STARS + case NEBULAS + case STAR_CLUSTERS + case DEEP_SKY +} + +enum StarMapSearchQuickPresetType: String { + case NONE + case WATCH_NOW + case CATALOGS + case CATEGORY_SOLAR_SYSTEM + case CATEGORY_CONSTELLATIONS + case CATEGORY_STARS + case CATEGORY_NEBULAS + case CATEGORY_STAR_CLUSTERS + case CATEGORY_DEEP_SKY + case MY_DATA_FAVORITES + case MY_DATA_DAILY_PATH + case MY_DATA_DIRECTIONS + case CATALOG_WID + + var categoryPreset: StarMapSearchCategoryFilter? { + switch self { + case .CATEGORY_SOLAR_SYSTEM: + return .SOLAR_SYSTEM + case .CATEGORY_CONSTELLATIONS: + return .CONSTELLATIONS + case .CATEGORY_STARS: + return .STARS + case .CATEGORY_NEBULAS: + return .NEBULAS + case .CATEGORY_STAR_CLUSTERS: + return .STAR_CLUSTERS + case .CATEGORY_DEEP_SKY: + return .DEEP_SKY + default: + return nil + } + } + + var opensInBrowseMode: Bool { + self != .NONE + } + + var isMyData: Bool { + myDataTabIndex != nil + } + + var myDataTabIndex: Int? { + switch self { + case .MY_DATA_FAVORITES: + return 0 + case .MY_DATA_DAILY_PATH: + return 1 + case .MY_DATA_DIRECTIONS: + return 2 + default: + return nil + } + } + + func matches(_ entry: StarMapSearchEntry, catalogWid: String?) -> Bool { + switch self { + case .NONE, .WATCH_NOW: + return true + case .CATALOGS: + return false + case .CATEGORY_SOLAR_SYSTEM: + return entry.category == .SOLAR_SYSTEM + case .CATEGORY_CONSTELLATIONS: + return entry.category == .CONSTELLATIONS + case .CATEGORY_STARS: + return entry.category == .STARS + case .CATEGORY_NEBULAS: + return entry.category == .NEBULAS + case .CATEGORY_STAR_CLUSTERS: + return entry.category == .STAR_CLUSTERS + case .CATEGORY_DEEP_SKY: + return entry.category == .DEEP_SKY + case .MY_DATA_FAVORITES: + return entry.objectRef.isFavorite + case .MY_DATA_DAILY_PATH: + return entry.objectRef.showCelestialPath + case .MY_DATA_DIRECTIONS: + return entry.objectRef.showDirection + case .CATALOG_WID: + guard let catalogWid, !catalogWid.isEmpty else { + return false + } + return entry.catalogWids.contains(catalogWid) + } + } +} + +final class StarMapSearchEntry { + let objectRef: SkyObject + let displayName: String + let magnitude: Double + let category: StarMapSearchCategoryFilter + let iconRes: String + let iconColor: UIColor + let catalogWids: Set + var nextRise: Date? + var nextSet: Date? + var isVisibleTonight = false + var riseSetCalculated = false + var visibleTonightCalculated = false + + init(objectRef: SkyObject, + displayName: String, + magnitude: Double, + category: StarMapSearchCategoryFilter, + iconRes: String, + iconColor: UIColor, + catalogWids: Set = []) { + self.objectRef = objectRef + self.displayName = displayName + self.magnitude = magnitude + self.category = category + self.iconRes = iconRes + self.iconColor = iconColor + self.catalogWids = catalogWids + } + + func copy() -> StarMapSearchEntry { + let entry = StarMapSearchEntry(objectRef: objectRef, + displayName: displayName, + magnitude: magnitude, + category: category, + iconRes: iconRes, + iconColor: iconColor, + catalogWids: catalogWids) + entry.nextRise = nextRise + entry.nextSet = nextSet + entry.isVisibleTonight = isVisibleTonight + entry.riseSetCalculated = riseSetCalculated + entry.visibleTonightCalculated = visibleTonightCalculated + return entry + } +} + +struct StarMapRecentChip: Equatable { + let label: String + let objectId: String? + + init(label: String, objectId: String? = nil) { + self.label = label + self.objectId = objectId + } +} + +struct StarMapSearchStateSnapshot { + let query: String + let sortMode: StarMapSearchSortMode + let typeFilter: StarMapSearchTypeFilter + let nakedEyeOnly: Bool + let quickPresetType: StarMapSearchQuickPresetType + let quickPresetCatalogWid: String? + let selectedCategories: Set + + func filterAndSort(preparedEntries: [StarMapSearchEntry], + visibleTonightProvider: (StarMapSearchEntry) -> Bool, + riseSortValueProvider: (StarMapSearchEntry) -> Int64, + setSortValueProvider: (StarMapSearchEntry) -> Int64, + insertionOrderProvider: (StarMapSearchEntry) -> Int?) -> [StarMapSearchEntry] { + var filteredEntries: [StarMapSearchEntry] = [] + let queryLower = query.lowercased(with: Locale.current) + let specificCategories = selectedCategories.filter { $0 != .ALL } + + for entry in preparedEntries { + if !matchesQuickPreset(entry) { + continue + } + if !queryLower.isEmpty && !matchesQuery(entry, queryLower: queryLower) { + continue + } + if !matchesTypeFilter(entry, visibleTonightProvider: visibleTonightProvider) { + continue + } + if nakedEyeOnly && entry.magnitude > 6.0 { + continue + } + if !specificCategories.isEmpty && !specificCategories.contains(entry.category) { + continue + } + filteredEntries.append(entry) + } + + filteredEntries.sort { lhs, rhs in + compare(lhs, + rhs, + riseSortValueProvider: riseSortValueProvider, + setSortValueProvider: setSortValueProvider, + insertionOrderProvider: insertionOrderProvider) + } + return filteredEntries + } + + private func matchesQuickPreset(_ entry: StarMapSearchEntry) -> Bool { + quickPresetType.matches(entry, catalogWid: quickPresetCatalogWid) + } + + private func matchesQuery(_ entry: StarMapSearchEntry, queryLower: String) -> Bool { + let display = entry.displayName.lowercased(with: Locale.current) + let localized = (entry.objectRef.localizedName ?? "").lowercased(with: Locale.current) + let original = entry.objectRef.name.lowercased(with: Locale.current) + return display.contains(queryLower) || localized.contains(queryLower) || original.contains(queryLower) + } + + private func matchesTypeFilter(_ entry: StarMapSearchEntry, + visibleTonightProvider: (StarMapSearchEntry) -> Bool) -> Bool { + switch typeFilter { + case .SHOW_ALL: + return true + case .VISIBLE_NOW: + return entry.objectRef.altitude > 0 + case .VISIBLE_TONIGHT: + return visibleTonightProvider(entry) + } + } + + private func compare(_ lhs: StarMapSearchEntry, + _ rhs: StarMapSearchEntry, + riseSortValueProvider: (StarMapSearchEntry) -> Int64, + setSortValueProvider: (StarMapSearchEntry) -> Int64, + insertionOrderProvider: (StarMapSearchEntry) -> Int?) -> Bool { + let lhsName = lhs.displayName.lowercased(with: Locale.current) + let rhsName = rhs.displayName.lowercased(with: Locale.current) + switch sortMode { + case .NEWEST_FIRST: + let lhsOrder = insertionOrderProvider(lhs) + let rhsOrder = insertionOrderProvider(rhs) + if (lhsOrder == nil) != (rhsOrder == nil) { + return lhsOrder != nil + } + if (lhsOrder ?? Int.min) != (rhsOrder ?? Int.min) { + return (lhsOrder ?? Int.min) > (rhsOrder ?? Int.min) + } + return lhsName < rhsName + case .OLDEST_FIRST: + let lhsOrder = insertionOrderProvider(lhs) + let rhsOrder = insertionOrderProvider(rhs) + if (lhsOrder == nil) != (rhsOrder == nil) { + return lhsOrder != nil + } + if (lhsOrder ?? Int.max) != (rhsOrder ?? Int.max) { + return (lhsOrder ?? Int.max) < (rhsOrder ?? Int.max) + } + return lhsName < rhsName + case .NAME_ASC: + return lhsName < rhsName + case .NAME_DESC: + return lhsName > rhsName + case .BRIGHTEST_FIRST: + return lhs.magnitude < rhs.magnitude + case .FAINTEST_FIRST: + return lhs.magnitude > rhs.magnitude + case .RISES_SOONEST: + let lhsRise = riseSortValueProvider(lhs) + let rhsRise = riseSortValueProvider(rhs) + if lhsRise != rhsRise { + return lhsRise < rhsRise + } + return lhsName < rhsName + case .SETS_SOONEST: + let lhsSet = setSortValueProvider(lhs) + let rhsSet = setSortValueProvider(rhs) + if lhsSet != rhsSet { + return lhsSet < rhsSet + } + return lhsName < rhsName + } + } +} + +final class StarMapSearchState { + private static let KEY_QUERY = "query" + private static let KEY_SORT = "sort" + private static let KEY_TYPE_FILTER = "type_filter" + private static let KEY_NAKED_EYE = "naked_eye" + private static let KEY_CATEGORIES = "categories" + private static let KEY_QUICK_PRESET = "quick_preset" + private static let KEY_QUICK_CATALOG = "quick_catalog" + private static let KEY_RECENT_CHIPS = "recent_chips" + private static let KEY_RECENT_CHIP_LABELS = "recent_chip_labels" + private static let KEY_RECENT_CHIP_IDS = "recent_chip_ids" + private static let MAX_RECENT_CHIPS = 8 + + var query = "" + var sortMode: StarMapSearchSortMode = .NAME_ASC + var typeFilter: StarMapSearchTypeFilter = .SHOW_ALL + var nakedEyeOnly = false + var quickPresetType: StarMapSearchQuickPresetType = .NONE + var quickPresetCatalogWid: String? + var selectedCategories: [StarMapSearchCategoryFilter] = [.ALL] + var recentChips: [StarMapRecentChip] = [] + + init(savedInstanceState: [String: Any]? = nil) { + restore(savedInstanceState) + } + + func save(outState: inout [String: Any]) { + outState[Self.KEY_QUERY] = query + outState[Self.KEY_SORT] = sortMode.rawValue + outState[Self.KEY_TYPE_FILTER] = typeFilter.rawValue + outState[Self.KEY_NAKED_EYE] = nakedEyeOnly + outState[Self.KEY_CATEGORIES] = selectedCategories.map(\.rawValue) + outState[Self.KEY_QUICK_PRESET] = quickPresetType.rawValue + outState[Self.KEY_QUICK_CATALOG] = quickPresetCatalogWid + outState[Self.KEY_RECENT_CHIP_LABELS] = recentChips.map(\.label) + outState[Self.KEY_RECENT_CHIP_IDS] = recentChips.map { $0.objectId ?? "" } + } + + func restore(_ savedInstanceState: [String: Any]?) { + guard let savedInstanceState else { + return + } + query = savedInstanceState[Self.KEY_QUERY] as? String ?? "" + sortMode = (savedInstanceState[Self.KEY_SORT] as? String).flatMap(StarMapSearchSortMode.init(rawValue:)) ?? .NAME_ASC + typeFilter = (savedInstanceState[Self.KEY_TYPE_FILTER] as? String).flatMap(StarMapSearchTypeFilter.init(rawValue:)) ?? .SHOW_ALL + nakedEyeOnly = savedInstanceState[Self.KEY_NAKED_EYE] as? Bool ?? false + quickPresetType = (savedInstanceState[Self.KEY_QUICK_PRESET] as? String).flatMap(StarMapSearchQuickPresetType.init(rawValue:)) ?? .NONE + quickPresetCatalogWid = savedInstanceState[Self.KEY_QUICK_CATALOG] as? String + + selectedCategories.removeAll() + if let categories = savedInstanceState[Self.KEY_CATEGORIES] as? [String] { + for category in categories { + if let value = StarMapSearchCategoryFilter(rawValue: category) { + selectedCategories.append(value) + } + } + } + if selectedCategories.isEmpty { + selectedCategories.append(.ALL) + } + + recentChips.removeAll() + let recentChipLabels = savedInstanceState[Self.KEY_RECENT_CHIP_LABELS] as? [String] + let recentChipIds = savedInstanceState[Self.KEY_RECENT_CHIP_IDS] as? [String] + if let recentChipLabels { + for (index, label) in recentChipLabels.enumerated() { + let normalizedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedLabel.isEmpty { + let objectId = recentChipIds?.indices.contains(index) == true ? recentChipIds?[index] : nil + recentChips.append(StarMapRecentChip(label: normalizedLabel, + objectId: objectId?.isEmpty == false ? objectId : nil)) + } + } + } else if let legacyLabels = savedInstanceState[Self.KEY_RECENT_CHIPS] as? [String] { + for label in legacyLabels { + let normalizedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedLabel.isEmpty { + recentChips.append(StarMapRecentChip(label: normalizedLabel)) + } + } + } + } + + func selectQuickPreset(_ quickPresetType: StarMapSearchQuickPresetType, catalogWid: String?) { + self.quickPresetType = quickPresetType + quickPresetCatalogWid = catalogWid + query = "" + if quickPresetType.isMyData { + sortMode = defaultSortModeForPreset(quickPresetType) + } + } + + func prepareForExploreEntry(_ quickPresetType: StarMapSearchQuickPresetType, catalogWid: String?) { + query = "" + sortMode = defaultSortModeForPreset(quickPresetType) + typeFilter = quickPresetType == .WATCH_NOW ? .VISIBLE_TONIGHT : .SHOW_ALL + nakedEyeOnly = false + self.quickPresetType = quickPresetType + quickPresetCatalogWid = catalogWid + selectedCategories.removeAll() + selectedCategories.append(categoryPreset() ?? .ALL) + } + + func shouldOpenInBrowseMode() -> Bool { + quickPresetType.opensInBrowseMode + } + + func hasBrowseContext() -> Bool { + quickPresetType != .NONE + } + + func isCategoryPreset() -> Bool { + quickPresetType.categoryPreset != nil + } + + func categoryPreset() -> StarMapSearchCategoryFilter? { + quickPresetType.categoryPreset + } + + func snapshot() -> StarMapSearchStateSnapshot { + StarMapSearchStateSnapshot(query: query, + sortMode: sortMode, + typeFilter: typeFilter, + nakedEyeOnly: nakedEyeOnly, + quickPresetType: quickPresetType, + quickPresetCatalogWid: quickPresetCatalogWid, + selectedCategories: Set(selectedCategories)) + } + + func filterAndSort(preparedEntries: [StarMapSearchEntry], + visibleTonightProvider: (StarMapSearchEntry) -> Bool, + riseSortValueProvider: (StarMapSearchEntry) -> Int64, + setSortValueProvider: (StarMapSearchEntry) -> Int64, + insertionOrderProvider: (StarMapSearchEntry) -> Int?) -> [StarMapSearchEntry] { + snapshot().filterAndSort(preparedEntries: preparedEntries, + visibleTonightProvider: visibleTonightProvider, + riseSortValueProvider: riseSortValueProvider, + setSortValueProvider: setSortValueProvider, + insertionOrderProvider: insertionOrderProvider) + } + + func calculateFilterCount() -> Int { + var count = 0 + if quickPresetType != .NONE && + quickPresetType != .CATALOGS && + quickPresetType != .WATCH_NOW && + quickPresetType.categoryPreset == nil { + count += 1 + } + let defaultTypeFilter: StarMapSearchTypeFilter = quickPresetType == .WATCH_NOW ? .VISIBLE_TONIGHT : .SHOW_ALL + if typeFilter != defaultTypeFilter { + count += 1 + } + if nakedEyeOnly { + count += 1 + } + if selectedCategories.contains(where: { $0 != .ALL }) { + count += 1 + } + return count + } + + func reset() { + query = "" + sortMode = .NAME_ASC + typeFilter = .SHOW_ALL + nakedEyeOnly = false + quickPresetType = .NONE + quickPresetCatalogWid = nil + selectedCategories.removeAll() + selectedCategories.append(.ALL) + } + + private func defaultSortModeForPreset(_ quickPresetType: StarMapSearchQuickPresetType) -> StarMapSearchSortMode { + quickPresetType == .WATCH_NOW ? .BRIGHTEST_FIRST : .NAME_ASC + } + + func addRecentChip(label: String, objectId: String) { + let normalizedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + if normalizedLabel.isEmpty { + return + } + recentChips.removeAll { + $0.objectId == objectId || $0.label.caseInsensitiveCompare(normalizedLabel) == .orderedSame + } + recentChips.insert(StarMapRecentChip(label: normalizedLabel, objectId: objectId), at: 0) + while recentChips.count > Self.MAX_RECENT_CHIPS { + recentChips.removeLast() + } + } + + func replaceRecentChips(_ chips: [StarMapRecentChip]) { + recentChips.removeAll() + recentChips.append(contentsOf: chips.prefix(Self.MAX_RECENT_CHIPS)) + } + + func toggleCategoryFilter(_ categoryFilter: StarMapSearchCategoryFilter) { + if categoryFilter == .ALL { + selectedCategories.removeAll() + selectedCategories.append(.ALL) + return + } + + selectedCategories.removeAll { $0 == .ALL } + if selectedCategories.contains(categoryFilter) { + selectedCategories.removeAll { $0 == categoryFilter } + } else { + selectedCategories.append(categoryFilter) + } + if selectedCategories.isEmpty { + selectedCategories.append(.ALL) + } + } +} + +func mapStarMapSearchCategory(_ obj: SkyObject) -> StarMapSearchCategoryFilter { + switch obj.type { + case .SUN, .MOON, .PLANET: + return .SOLAR_SYSTEM + case .CONSTELLATION: + return .CONSTELLATIONS + case .STAR: + return .STARS + case .NEBULA: + return .NEBULAS + case .OPEN_CLUSTER, .GLOBULAR_CLUSTER: + return .STAR_CLUSTERS + default: + return .DEEP_SKY + } +} diff --git a/Sources/Plugins/Astronomy/search/StarMapSearchViewController.swift b/Sources/Plugins/Astronomy/search/StarMapSearchViewController.swift new file mode 100644 index 0000000000..2bef1dce80 --- /dev/null +++ b/Sources/Plugins/Astronomy/search/StarMapSearchViewController.swift @@ -0,0 +1,1809 @@ +// +// StarMapSearchViewController.swift +// OsmAnd Maps +// +// Created by Codex on 06.06.2026. +// Copyright (c) 2026 OsmAnd. All rights reserved. +// + +import UIKit + +enum StarMapSearchLightPalette { + static let appBarBackground = UIColor(red: 222.0 / 255.0, green: 235.0 / 255.0, blue: 255.0 / 255.0, alpha: 1.0) + static let listBackground = UIColor.white + static let groupedBackground = UIColor(red: 239.0 / 255.0, green: 239.0 / 255.0, blue: 244.0 / 255.0, alpha: 1.0) + static let primaryText = UIColor.black + static let secondaryText = UIColor(red: 128.0 / 255.0, green: 119.0 / 255.0, blue: 143.0 / 255.0, alpha: 1.0) + static let toolbarIcon = UIColor(red: 102.0 / 255.0, green: 102.0 / 255.0, blue: 102.0 / 255.0, alpha: 1.0) + static let defaultIcon = UIColor(red: 188.0 / 255.0, green: 184.0 / 255.0, blue: 197.0 / 255.0, alpha: 1.0) + static let separator = UIColor(red: 224.0 / 255.0, green: 224.0 / 255.0, blue: 224.0 / 255.0, alpha: 1.0) + static let secondaryButtonBackground = UIColor(red: 238.0 / 255.0, green: 238.0 / 255.0, blue: 241.0 / 255.0, alpha: 1.0) +} + +final class StarMapSearchViewController: UIViewController, UITextFieldDelegate { + private enum ScreenMode { + case EXPLORE + case FULL_SEARCH + } + + private enum FullSearchMode { + case BROWSE + case INPUT + } + + private enum InputPresentation { + case EXPLORE_BAR + case STANDALONE + } + + private enum HideTarget { + case EXPLORE + case BROWSE + } + + private struct CatalogsBackState { + let query: String + let sortMode: StarMapSearchSortMode + let scrollOffset: CGPoint + } + + private struct ExploreRowConfig { + let quickPresetType: StarMapSearchQuickPresetType + let iconRes: String + let titleRes: String + let subtitleRes: String? + } + + private enum Layout { + static let contentPadding: CGFloat = 16 + static let smallPadding: CGFloat = 8 + static let rowMinHeight: CGFloat = 56 + static let resultRowMinHeight: CGFloat = 72 + static let buttonHeight: CGFloat = 44 + static let iconSize: CGFloat = 24 + static let toolbarHeight: CGFloat = 56 + static let toolbarButtonTouchTarget: CGFloat = 56 + static let toolbarButtonLeadingInset: CGFloat = 0 + static let toolbarButtonTrailingInset: CGFloat = 16 + static let browseTitleExpandedHeight: CGFloat = 64 + static let browseTitleCollapseDistance: CGFloat = 56 + static let inputHeaderHeight: CGFloat = 88 + static let myDataTabsHeight: CGFloat = 56 + } + + private weak var parentStarMapController: StarMapViewController? + private let plugin: AstronomyPlugin + private let dataProvider: AstroDataProvider + private let nightMode: Bool + + private lazy var searchAdapter = StarMapSearchResultsAdapter( + nightMode: nightMode, + snapshot: .empty, + widToDisplayName: { [weak self] in self?.widToDisplayName ?? [:] }, + eventTextProvider: { [weak self] entry in self?.searchHelper.resolveEventText(entry) ?? NSAttributedString(string: "") }, + onScroll: { [weak self] scrollView in self?.onResultsScrolled(scrollView) }, + onEntrySelected: { [weak self] entry in self?.onSearchEntrySelected(entry) } + ) + private lazy var catalogsAdapter = StarMapCatalogsAdapter( + nightMode: nightMode, + snapshot: .empty, + onScroll: { [weak self] scrollView in self?.onResultsScrolled(scrollView) }, + onCatalogSelected: { [weak self] entry in self?.onCatalogSelected(entry) } + ) + private lazy var searchPreparedDataFactory = StarMapSearchPreparedDataFactory(dataProvider: dataProvider, nightMode: nightMode) + private lazy var searchHelper = StarMapSearchHelper() + + private var searchState = StarMapSearchState() + private var preparedEntries: [StarMapSearchEntry] = [] + private var visibleEntries: [StarMapSearchEntry] = [] + private var preparedCatalogEntries: [StarMapCatalogEntry] = [] + private var visibleCatalogEntries: [StarMapCatalogEntry] = [] + private var widToDisplayName: [String: String] = [:] + + private let mainStack = UIStackView() + private let exploreContainer = UIView() + private let exploreHeaderStack = UIStackView() + private let exploreScrollView = UIScrollView() + private let exploreContentStack = UIStackView() + private let fullSearchContainer = UIView() + private let fullSearchStack = UIStackView() + private let headerStack = UIStackView() + private let browseToolbar = UIView() + private let inputToolbar = UIView() + private let toolbarTitleLabel = UILabel() + private let browseTitleContainer = UIView() + private let titleLabel = UILabel() + private let backButton = UIButton(type: .system) + private let inputBackButton = UIButton(type: .system) + private let browseSearchButton = UIButton(type: .system) + private let exploreSearchBar = UITextField() + private let fullSearchBar = UITextField() + private let myDataTabs = UIStackView() + private var myDataTabButtons: [UIButton] = [] + private var myDataTabIndicators: [UIView] = [] + private let searchRecycler = UITableView(frame: .zero, style: .plain) + private let sortFilterBar = UIStackView() + private let sortButton = UIButton(type: .system) + private let filterButton = UIButton(type: .system) + private let sortProgress = UIActivityIndicatorView(style: .medium) + private let resultsContainer = UIView() + private let emptyStateContainer = UIStackView() + private let emptyStateIcon = UIImageView() + private let emptyStateTitle = UILabel() + private let emptyStateDescription = UILabel() + private let emptyStateResetButton = UIButton(type: .system) + private let recentChipsScroll = UIScrollView() + private let recentChipsContainer = UIStackView() + private let watchNowRow = UIControl() + private let categoriesContainer = UIStackView() + private let myDataContainer = UIStackView() + private let catalogsContainer = UIStackView() + private let catalogsViewAllRow = UIControl() + private let catalogsViewAllCount = UILabel() + + private var filterAndSortRequestId = 0 + private var currentMode: ScreenMode = .EXPLORE + private var currentFullSearchMode: FullSearchMode = .INPUT + private var currentInputPresentation: InputPresentation = .EXPLORE_BAR + private var wasInfoHeaderVisible = false + private var suppressQueryDispatch = false + private var pendingSearchQueryRestore = false + private var pendingSearchHideTarget: HideTarget? + private var catalogsBackState: CatalogsBackState? + private var dismissOnBrowseBack = false + private var pendingInitialCatalogWid: String? + private var redFilterEnabled = false + private var browseTitleContainerHeightConstraint: NSLayoutConstraint? + private var pendingBrowseScrollOffsetRestore: CGPoint? + private var isFilteringResults = false + + var onObjectSelected: ((SkyObject) -> Void)? + + static let TAG = "StarMapSearchDialog" + private static let FEATURED_CATALOGS_COUNT = 5 + private static let EXPLORE_SECTION_BACKGROUND_TAG = 0xA571 + private static let RISE_SET_PRELOAD_COUNT = 32 + private static let FEATURED_CATALOG_WIDS = [ + "Q14530", + "Q857461", + "Q2661779", + "Q55712879", + "Q3247327", + "Q91442269", + "Q4999741" + ] + + static func newInstance(initialCatalogWid: String? = nil, + parent: StarMapViewController, + plugin: AstronomyPlugin) -> StarMapSearchViewController { + let controller = StarMapSearchViewController(parent: parent, plugin: plugin) + controller.pendingInitialCatalogWid = initialCatalogWid?.isEmpty == false ? initialCatalogWid : nil + controller.dismissOnBrowseBack = controller.pendingInitialCatalogWid != nil + return controller + } + + private init(parent: StarMapViewController, plugin: AstronomyPlugin) { + parentStarMapController = parent + self.plugin = plugin + dataProvider = plugin.dataProvider + nightMode = OADayNightHelper.instance().isNightMode() + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = StarMapSearchLightPalette.groupedBackground + bindViews() + refreshPreparedEntries() + setupSearchRecycler() + setupExploreContent() + setupListeners() + renderRecentChips() + applyRedFilter(enabled: redFilterEnabled) + if let initialCatalogWid = pendingInitialCatalogWid { + pendingInitialCatalogWid = nil + clearCatalogsBackState() + openFullSearch(.CATALOG_WID, catalogWid: initialCatalogWid) + return + } + applyMode(currentMode, requestKeyboard: currentMode == .FULL_SEARCH && currentFullSearchMode == .INPUT) + applyFiltersAndSort(scrollToTop: false) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + syncDialogVisibilityWithFragmentState() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isBeingDismissed || navigationController?.isBeingDismissed == true { + filterAndSortRequestId += 1 + } + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + .darkContent + } + + func applyRedFilter(enabled: Bool) { + redFilterEnabled = enabled + guard isViewLoaded else { + return + } + AstroRedFilter.apply(enabled, to: view) + } + + private func bindViews() { + mainStack.axis = .vertical + mainStack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(mainStack) + + exploreContainer.translatesAutoresizingMaskIntoConstraints = false + exploreContainer.backgroundColor = StarMapSearchLightPalette.groupedBackground + exploreHeaderStack.axis = .vertical + exploreHeaderStack.spacing = Layout.smallPadding + exploreHeaderStack.backgroundColor = StarMapSearchLightPalette.listBackground + exploreHeaderStack.layoutMargins = UIEdgeInsets(top: Layout.contentPadding, left: Layout.contentPadding, bottom: Layout.contentPadding, right: Layout.contentPadding) + exploreHeaderStack.isLayoutMarginsRelativeArrangement = true + exploreHeaderStack.translatesAutoresizingMaskIntoConstraints = false + exploreScrollView.translatesAutoresizingMaskIntoConstraints = false + exploreScrollView.backgroundColor = StarMapSearchLightPalette.groupedBackground + exploreContentStack.axis = .vertical + exploreContentStack.spacing = Layout.contentPadding + exploreContentStack.translatesAutoresizingMaskIntoConstraints = false + exploreContainer.addSubview(exploreHeaderStack) + exploreContainer.addSubview(exploreScrollView) + exploreScrollView.addSubview(exploreContentStack) + + fullSearchContainer.translatesAutoresizingMaskIntoConstraints = false + fullSearchContainer.backgroundColor = StarMapSearchLightPalette.listBackground + fullSearchStack.axis = .vertical + fullSearchStack.backgroundColor = StarMapSearchLightPalette.listBackground + fullSearchStack.translatesAutoresizingMaskIntoConstraints = false + fullSearchContainer.addSubview(fullSearchStack) + resultsContainer.translatesAutoresizingMaskIntoConstraints = false + resultsContainer.backgroundColor = StarMapSearchLightPalette.listBackground + + NSLayoutConstraint.activate([ + mainStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + mainStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + mainStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + mainStack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + exploreHeaderStack.leadingAnchor.constraint(equalTo: exploreContainer.leadingAnchor), + exploreHeaderStack.trailingAnchor.constraint(equalTo: exploreContainer.trailingAnchor), + exploreHeaderStack.topAnchor.constraint(equalTo: exploreContainer.topAnchor), + + exploreScrollView.leadingAnchor.constraint(equalTo: exploreContainer.leadingAnchor), + exploreScrollView.trailingAnchor.constraint(equalTo: exploreContainer.trailingAnchor), + exploreScrollView.topAnchor.constraint(equalTo: exploreHeaderStack.bottomAnchor), + exploreScrollView.bottomAnchor.constraint(equalTo: exploreContainer.bottomAnchor), + + exploreContentStack.leadingAnchor.constraint(equalTo: exploreScrollView.contentLayoutGuide.leadingAnchor, constant: Layout.contentPadding), + exploreContentStack.trailingAnchor.constraint(equalTo: exploreScrollView.contentLayoutGuide.trailingAnchor, constant: -Layout.contentPadding), + exploreContentStack.topAnchor.constraint(equalTo: exploreScrollView.contentLayoutGuide.topAnchor, constant: Layout.contentPadding), + exploreContentStack.bottomAnchor.constraint(equalTo: exploreScrollView.contentLayoutGuide.bottomAnchor, constant: -Layout.contentPadding), + exploreContentStack.widthAnchor.constraint(equalTo: exploreScrollView.frameLayoutGuide.widthAnchor, constant: -2 * Layout.contentPadding), + + fullSearchStack.leadingAnchor.constraint(equalTo: fullSearchContainer.leadingAnchor), + fullSearchStack.trailingAnchor.constraint(equalTo: fullSearchContainer.trailingAnchor), + fullSearchStack.topAnchor.constraint(equalTo: fullSearchContainer.topAnchor), + fullSearchStack.bottomAnchor.constraint(equalTo: fullSearchContainer.bottomAnchor) + ]) + + mainStack.addArrangedSubview(exploreContainer) + mainStack.addArrangedSubview(fullSearchContainer) + setupFullSearchHeader() + setupExploreHeader() + setupEmptyState() + } + + private func setupFullSearchHeader() { + headerStack.axis = .vertical + headerStack.alignment = .fill + headerStack.spacing = 0 + headerStack.backgroundColor = appBarBackgroundColor() + + backButton.setImage(AstroIcon.template("ic_custom_arrow_back"), for: .normal) + backButton.setTitle(nil, for: .normal) + backButton.accessibilityLabel = localizedString("shared_string_back") + backButton.addTarget(self, action: #selector(backPressed), for: .touchUpInside) + backButton.tintColor = StarMapSearchLightPalette.toolbarIcon + + inputBackButton.setImage(AstroIcon.template("ic_custom_arrow_back"), for: .normal) + inputBackButton.setTitle(nil, for: .normal) + inputBackButton.accessibilityLabel = localizedString("shared_string_back") + inputBackButton.addTarget(self, action: #selector(backPressed), for: .touchUpInside) + inputBackButton.tintColor = StarMapSearchLightPalette.toolbarIcon + + browseSearchButton.setImage(.icCustomSearch, for: .normal) + browseSearchButton.setTitle(nil, for: .normal) + browseSearchButton.accessibilityLabel = localizedString("shared_string_search") + browseSearchButton.addTarget(self, action: #selector(switchToInputModeAction), for: .touchUpInside) + browseSearchButton.tintColor = StarMapSearchLightPalette.toolbarIcon + + titleLabel.font = UIFont.preferredFont(forTextStyle: .title1) + titleLabel.textColor = StarMapSearchLightPalette.primaryText + titleLabel.numberOfLines = 1 + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + browseTitleContainer.translatesAutoresizingMaskIntoConstraints = false + browseTitleContainer.backgroundColor = appBarBackgroundColor() + browseTitleContainer.clipsToBounds = true + browseTitleContainer.addSubview(titleLabel) + let titleContainerHeight = browseTitleContainer.heightAnchor.constraint(equalToConstant: Layout.browseTitleExpandedHeight) + browseTitleContainerHeightConstraint = titleContainerHeight + NSLayoutConstraint.activate([ + titleContainerHeight, + titleLabel.leadingAnchor.constraint(equalTo: browseTitleContainer.leadingAnchor, constant: Layout.contentPadding), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: browseTitleContainer.trailingAnchor, constant: -Layout.contentPadding), + titleLabel.topAnchor.constraint(equalTo: browseTitleContainer.topAnchor, constant: Layout.smallPadding) + ]) + + setupBrowseToolbar() + setupInputToolbar() + setupMyDataTabs() + headerStack.addArrangedSubview(browseToolbar) + headerStack.addArrangedSubview(inputToolbar) + headerStack.addArrangedSubview(browseTitleContainer) + headerStack.addArrangedSubview(myDataTabs) + + fullSearchBar.delegate = self + fullSearchBar.placeholder = localizedString("astro_search_input_hint") + fullSearchBar.attributedPlaceholder = NSAttributedString( + string: localizedString("astro_search_input_hint"), + attributes: [.foregroundColor: StarMapSearchLightPalette.secondaryText] + ) + fullSearchBar.borderStyle = .none + fullSearchBar.backgroundColor = .clear + fullSearchBar.font = UIFont.preferredFont(forTextStyle: .title3) + fullSearchBar.textColor = StarMapSearchLightPalette.primaryText + fullSearchBar.returnKeyType = .search + fullSearchBar.clearButtonMode = .whileEditing + fullSearchBar.addTarget(self, action: #selector(searchTextChanged), for: .editingChanged) + + searchRecycler.keyboardDismissMode = .onDrag + searchRecycler.rowHeight = UITableView.automaticDimension + searchRecycler.estimatedRowHeight = Layout.resultRowMinHeight + searchRecycler.separatorStyle = .none + searchRecycler.backgroundColor = StarMapSearchLightPalette.listBackground + + sortFilterBar.axis = .horizontal + sortFilterBar.alignment = .center + sortFilterBar.distribution = .fill + sortFilterBar.spacing = 0 + sortFilterBar.backgroundColor = StarMapSearchLightPalette.listBackground + sortFilterBar.layoutMargins = UIEdgeInsets(top: 0, left: Layout.contentPadding, bottom: 0, right: Layout.contentPadding) + sortFilterBar.isLayoutMarginsRelativeArrangement = true + sortFilterBar.heightAnchor.constraint(equalToConstant: Layout.toolbarHeight).isActive = true + + configureMenuButton(sortButton) + configureMenuButton(filterButton) + let spacer = UIView() + sortProgress.hidesWhenStopped = true + sortProgress.color = .systemBlue + sortFilterBar.addArrangedSubview(sortButton) + sortFilterBar.addArrangedSubview(sortProgress) + sortFilterBar.addArrangedSubview(spacer) + sortFilterBar.addArrangedSubview(filterButton) + + searchRecycler.translatesAutoresizingMaskIntoConstraints = false + emptyStateContainer.translatesAutoresizingMaskIntoConstraints = false + resultsContainer.addSubview(searchRecycler) + resultsContainer.addSubview(emptyStateContainer) + NSLayoutConstraint.activate([ + searchRecycler.leadingAnchor.constraint(equalTo: resultsContainer.leadingAnchor), + searchRecycler.trailingAnchor.constraint(equalTo: resultsContainer.trailingAnchor), + searchRecycler.topAnchor.constraint(equalTo: resultsContainer.topAnchor), + searchRecycler.bottomAnchor.constraint(equalTo: resultsContainer.bottomAnchor), + + emptyStateContainer.leadingAnchor.constraint(equalTo: resultsContainer.leadingAnchor, constant: Layout.contentPadding), + emptyStateContainer.trailingAnchor.constraint(equalTo: resultsContainer.trailingAnchor, constant: -Layout.contentPadding), + emptyStateContainer.centerYAnchor.constraint(equalTo: resultsContainer.centerYAnchor) + ]) + + fullSearchStack.addArrangedSubview(headerStack) + fullSearchStack.addArrangedSubview(sortFilterBar) + fullSearchStack.addArrangedSubview(resultsContainer) + } + + private func setupBrowseToolbar() { + browseToolbar.translatesAutoresizingMaskIntoConstraints = false + browseToolbar.backgroundColor = appBarBackgroundColor() + browseToolbar.heightAnchor.constraint(equalToConstant: Layout.toolbarHeight).isActive = true + + toolbarTitleLabel.font = UIFont.preferredFont(forTextStyle: .title2) + toolbarTitleLabel.textColor = StarMapSearchLightPalette.primaryText + toolbarTitleLabel.numberOfLines = 1 + toolbarTitleLabel.adjustsFontForContentSizeCategory = true + toolbarTitleLabel.translatesAutoresizingMaskIntoConstraints = false + + backButton.translatesAutoresizingMaskIntoConstraints = false + browseSearchButton.translatesAutoresizingMaskIntoConstraints = false + browseToolbar.addSubview(backButton) + browseToolbar.addSubview(toolbarTitleLabel) + browseToolbar.addSubview(browseSearchButton) + NSLayoutConstraint.activate([ + backButton.leadingAnchor.constraint(equalTo: browseToolbar.leadingAnchor, constant: Layout.toolbarButtonLeadingInset), + backButton.centerYAnchor.constraint(equalTo: browseToolbar.centerYAnchor), + backButton.widthAnchor.constraint(equalToConstant: Layout.toolbarButtonTouchTarget), + backButton.heightAnchor.constraint(equalToConstant: Layout.toolbarButtonTouchTarget), + + browseSearchButton.trailingAnchor.constraint(equalTo: browseToolbar.trailingAnchor, constant: -Layout.toolbarButtonTrailingInset), + browseSearchButton.centerYAnchor.constraint(equalTo: browseToolbar.centerYAnchor), + browseSearchButton.widthAnchor.constraint(equalToConstant: Layout.toolbarButtonTouchTarget), + browseSearchButton.heightAnchor.constraint(equalToConstant: Layout.toolbarButtonTouchTarget), + + toolbarTitleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor), + toolbarTitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: browseSearchButton.leadingAnchor, constant: -Layout.smallPadding), + toolbarTitleLabel.centerYAnchor.constraint(equalTo: browseToolbar.centerYAnchor) + ]) + + headerStack.layoutMargins = .zero + headerStack.isLayoutMarginsRelativeArrangement = false + } + + private func setupInputToolbar() { + inputToolbar.translatesAutoresizingMaskIntoConstraints = false + inputToolbar.backgroundColor = appBarBackgroundColor() + inputToolbar.heightAnchor.constraint(equalToConstant: Layout.inputHeaderHeight).isActive = true + + inputBackButton.translatesAutoresizingMaskIntoConstraints = false + fullSearchBar.translatesAutoresizingMaskIntoConstraints = false + inputToolbar.addSubview(inputBackButton) + inputToolbar.addSubview(fullSearchBar) + NSLayoutConstraint.activate([ + inputBackButton.leadingAnchor.constraint(equalTo: inputToolbar.leadingAnchor, constant: Layout.toolbarButtonLeadingInset), + inputBackButton.centerYAnchor.constraint(equalTo: inputToolbar.centerYAnchor), + inputBackButton.widthAnchor.constraint(equalToConstant: Layout.toolbarButtonTouchTarget), + inputBackButton.heightAnchor.constraint(equalToConstant: Layout.toolbarButtonTouchTarget), + + fullSearchBar.leadingAnchor.constraint(equalTo: inputBackButton.trailingAnchor), + fullSearchBar.trailingAnchor.constraint(equalTo: inputToolbar.trailingAnchor, constant: -Layout.contentPadding), + fullSearchBar.centerYAnchor.constraint(equalTo: inputToolbar.centerYAnchor), + fullSearchBar.heightAnchor.constraint(equalToConstant: Layout.toolbarHeight) + ]) + } + + private func setupMyDataTabs() { + myDataTabs.axis = .horizontal + myDataTabs.alignment = .fill + myDataTabs.distribution = .fillEqually + myDataTabs.spacing = 0 + myDataTabs.backgroundColor = appBarBackgroundColor() + myDataTabs.heightAnchor.constraint(equalToConstant: Layout.myDataTabsHeight).isActive = true + myDataTabs.isHidden = true + + let tabTitles = [ + localizedString("favorites_item"), + localizedString("astro_daily_path"), + localizedString("astro_directions") + ] + myDataTabButtons.removeAll() + myDataTabIndicators.removeAll() + for (index, title) in tabTitles.enumerated() { + let tabContainer = UIControl() + tabContainer.tag = index + tabContainer.addTarget(self, action: #selector(myDataTabPressed(_:)), for: .touchUpInside) + + let button = UIButton(type: .system) + button.tag = index + button.setTitle(title, for: .normal) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .headline) + button.isUserInteractionEnabled = false + button.translatesAutoresizingMaskIntoConstraints = false + + let indicator = UIView() + indicator.backgroundColor = .systemBlue + indicator.translatesAutoresizingMaskIntoConstraints = false + + tabContainer.addSubview(button) + tabContainer.addSubview(indicator) + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: tabContainer.leadingAnchor), + button.trailingAnchor.constraint(equalTo: tabContainer.trailingAnchor), + button.topAnchor.constraint(equalTo: tabContainer.topAnchor), + button.bottomAnchor.constraint(equalTo: indicator.topAnchor), + indicator.leadingAnchor.constraint(equalTo: tabContainer.leadingAnchor), + indicator.trailingAnchor.constraint(equalTo: tabContainer.trailingAnchor), + indicator.bottomAnchor.constraint(equalTo: tabContainer.bottomAnchor), + indicator.heightAnchor.constraint(equalToConstant: 3) + ]) + + myDataTabs.addArrangedSubview(tabContainer) + myDataTabButtons.append(button) + myDataTabIndicators.append(indicator) + } + } + + private func appBarBackgroundColor() -> UIColor { + StarMapSearchLightPalette.appBarBackground + } + + private func setupExploreHeader() { + let searchSurface = UIControl() + searchSurface.backgroundColor = appBarBackgroundColor() + searchSurface.layer.cornerRadius = 10 + searchSurface.addTarget(self, action: #selector(openExploreInputSearch), for: .touchUpInside) + + let closeButton = UIButton(type: .system) + closeButton.setImage(.icCustomClose, for: .normal) + closeButton.tintColor = StarMapSearchLightPalette.toolbarIcon + closeButton.accessibilityLabel = localizedString("shared_string_close") + closeButton.addTarget(self, action: #selector(close), for: .touchUpInside) + + let title = UILabel() + title.text = localizedString("shared_string_search") + title.font = UIFont.preferredFont(forTextStyle: .title2) + title.textColor = StarMapSearchLightPalette.primaryText + + let searchButton = UIButton(type: .system) + searchButton.setImage(.icCustomSearch, for: .normal) + searchButton.tintColor = StarMapSearchLightPalette.toolbarIcon + searchButton.accessibilityLabel = localizedString("shared_string_search") + searchButton.addTarget(self, action: #selector(openExploreInputSearch), for: .touchUpInside) + + closeButton.translatesAutoresizingMaskIntoConstraints = false + title.translatesAutoresizingMaskIntoConstraints = false + searchButton.translatesAutoresizingMaskIntoConstraints = false + searchSurface.translatesAutoresizingMaskIntoConstraints = false + searchSurface.addSubview(closeButton) + searchSurface.addSubview(title) + searchSurface.addSubview(searchButton) + NSLayoutConstraint.activate([ + searchSurface.heightAnchor.constraint(equalToConstant: 64), + closeButton.leadingAnchor.constraint(equalTo: searchSurface.leadingAnchor, constant: Layout.contentPadding), + closeButton.centerYAnchor.constraint(equalTo: searchSurface.centerYAnchor), + closeButton.widthAnchor.constraint(equalToConstant: 44), + closeButton.heightAnchor.constraint(equalToConstant: 44), + searchButton.trailingAnchor.constraint(equalTo: searchSurface.trailingAnchor, constant: -Layout.contentPadding), + searchButton.centerYAnchor.constraint(equalTo: searchSurface.centerYAnchor), + searchButton.widthAnchor.constraint(equalToConstant: 44), + searchButton.heightAnchor.constraint(equalToConstant: 44), + title.leadingAnchor.constraint(equalTo: closeButton.trailingAnchor, constant: Layout.smallPadding), + title.trailingAnchor.constraint(lessThanOrEqualTo: searchButton.leadingAnchor, constant: -Layout.smallPadding), + title.centerYAnchor.constraint(equalTo: searchSurface.centerYAnchor) + ]) + exploreHeaderStack.addArrangedSubview(searchSurface) + + exploreSearchBar.delegate = self + exploreSearchBar.placeholder = localizedString("astro_search_input_hint") + exploreSearchBar.attributedPlaceholder = NSAttributedString( + string: localizedString("astro_search_input_hint"), + attributes: [.foregroundColor: StarMapSearchLightPalette.secondaryText] + ) + exploreSearchBar.textColor = StarMapSearchLightPalette.primaryText + + recentChipsContainer.axis = .horizontal + recentChipsContainer.spacing = Layout.smallPadding + recentChipsContainer.translatesAutoresizingMaskIntoConstraints = false + recentChipsScroll.addSubview(recentChipsContainer) + NSLayoutConstraint.activate([ + recentChipsContainer.leadingAnchor.constraint(equalTo: recentChipsScroll.contentLayoutGuide.leadingAnchor), + recentChipsContainer.trailingAnchor.constraint(equalTo: recentChipsScroll.contentLayoutGuide.trailingAnchor), + recentChipsContainer.topAnchor.constraint(equalTo: recentChipsScroll.contentLayoutGuide.topAnchor), + recentChipsContainer.bottomAnchor.constraint(equalTo: recentChipsScroll.contentLayoutGuide.bottomAnchor), + recentChipsContainer.heightAnchor.constraint(equalTo: recentChipsScroll.frameLayoutGuide.heightAnchor) + ]) + recentChipsScroll.heightAnchor.constraint(equalToConstant: 38).isActive = true + exploreHeaderStack.addArrangedSubview(recentChipsScroll) + } + + private func setupEmptyState() { + emptyStateContainer.axis = .vertical + emptyStateContainer.alignment = .center + emptyStateContainer.spacing = Layout.smallPadding + emptyStateContainer.backgroundColor = .clear + emptyStateContainer.layoutMargins = UIEdgeInsets(top: 0, left: Layout.contentPadding, bottom: 0, right: Layout.contentPadding) + emptyStateContainer.isLayoutMarginsRelativeArrangement = true + + emptyStateIcon.contentMode = .scaleAspectFit + emptyStateIcon.tintColor = StarMapSearchLightPalette.defaultIcon + emptyStateIcon.heightAnchor.constraint(equalToConstant: 64).isActive = true + emptyStateIcon.widthAnchor.constraint(equalToConstant: 64).isActive = true + emptyStateTitle.font = UIFont.preferredFont(forTextStyle: .headline) + emptyStateTitle.textColor = StarMapSearchLightPalette.primaryText + emptyStateDescription.font = UIFont.preferredFont(forTextStyle: .subheadline) + emptyStateDescription.textColor = StarMapSearchLightPalette.secondaryText + emptyStateDescription.numberOfLines = 0 + emptyStateDescription.textAlignment = .center + emptyStateResetButton.heightAnchor.constraint(equalToConstant: Layout.buttonHeight).isActive = true + emptyStateResetButton.translatesAutoresizingMaskIntoConstraints = false + emptyStateResetButton.addTarget(self, action: #selector(emptyStateAction), for: .touchUpInside) + applyEmptyStateButtonStyle() + + emptyStateContainer.addArrangedSubview(emptyStateIcon) + emptyStateContainer.addArrangedSubview(emptyStateTitle) + emptyStateContainer.addArrangedSubview(emptyStateDescription) + emptyStateContainer.addArrangedSubview(emptyStateResetButton) + emptyStateResetButton.widthAnchor.constraint(equalTo: emptyStateContainer.widthAnchor, constant: -2 * Layout.contentPadding).isActive = true + emptyStateContainer.isHidden = true + } + + private func configureMenuButton(_ button: UIButton) { + button.showsMenuAsPrimaryAction = true + button.changesSelectionAsPrimaryAction = false + var configuration = UIButton.Configuration.plain() + configuration.baseForegroundColor = .systemBlue + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + configuration.imagePadding = Layout.smallPadding + button.configuration = configuration + } + + private func setupSearchRecycler() { + searchRecycler.dataSource = searchAdapter + searchRecycler.delegate = searchAdapter + } + + private func setupExploreContent() { + setupWatchNowRow() + setupCategoryRows() + setupMyDataRows() + setupCatalogRows() + } + + private func setupWatchNowRow() { + configureExploreRow(watchNowRow, + iconName: "ic_custom_telescope", + title: localizedString("astro_explore_watch_now"), + subtitle: localizedString("astro_explore_watch_now_subtitle"), + count: nil) + watchNowRow.backgroundColor = StarMapSearchLightPalette.listBackground + watchNowRow.layer.cornerRadius = 10 + watchNowRow.layer.masksToBounds = true + watchNowRow.addTarget(self, action: #selector(watchNowPressed), for: .touchUpInside) + exploreContentStack.addArrangedSubview(watchNowRow) + } + + private func setupListeners() { + syncRecentChipsWithSession() + } + + private func updateBrowseToolbarAppearance() { + titleLabel.textColor = StarMapSearchLightPalette.primaryText + toolbarTitleLabel.textColor = StarMapSearchLightPalette.primaryText + backButton.tintColor = StarMapSearchLightPalette.toolbarIcon + inputBackButton.tintColor = StarMapSearchLightPalette.toolbarIcon + browseSearchButton.tintColor = StarMapSearchLightPalette.toolbarIcon + } + + private func attachSearchResultsPanel() { + updateResultsAdapter() + } + + private func getSearchView(_ presentation: InputPresentation) -> UITextField { + presentation == .EXPLORE_BAR ? exploreSearchBar : fullSearchBar + } + + private func getActiveSearchView() -> UITextField? { + currentMode == .FULL_SEARCH ? fullSearchBar : exploreSearchBar + } + + private func applySearchSoftInputMode() {} + + private func restoreSearchSoftInputMode() {} + + private func syncDialogVisibilityWithFragmentState() { + applyRedFilter(enabled: parentStarMapController?.isSearchRedFilterEnabled() ?? redFilterEnabled) + } + + private func restoreUiState(_ savedInstanceState: [String: Any]?) { + searchState.restore(savedInstanceState) + } + + private func setupCategoryRows() { + categoriesContainer.axis = .vertical + categoriesContainer.spacing = 0 + categoriesContainer.removeArrangedSubviews() + configureExploreSectionCard(categoriesContainer) + let categories = [ + ExploreRowConfig(quickPresetType: .CATEGORY_SOLAR_SYSTEM, iconRes: "ic_custom_planet_outlined", titleRes: "astro_solar_system", subtitleRes: nil), + ExploreRowConfig(quickPresetType: .CATEGORY_CONSTELLATIONS, iconRes: "ic_custom_constellations", titleRes: "astro_constellations", subtitleRes: nil), + ExploreRowConfig(quickPresetType: .CATEGORY_STARS, iconRes: "ic_custom_star_shine", titleRes: "astro_stars", subtitleRes: nil), + ExploreRowConfig(quickPresetType: .CATEGORY_NEBULAS, iconRes: "ic_custom_nebulas", titleRes: "astro_nebulas", subtitleRes: nil), + ExploreRowConfig(quickPresetType: .CATEGORY_STAR_CLUSTERS, iconRes: "ic_custom_star_clusters", titleRes: "astro_star_clusters", subtitleRes: nil), + ExploreRowConfig(quickPresetType: .CATEGORY_DEEP_SKY, iconRes: "ic_custom_galaxy", titleRes: "astro_deep_sky", subtitleRes: "astro_explore_deep_sky_subtitle") + ] + for (index, config) in categories.enumerated() { + addExploreRow(container: categoriesContainer, + iconRes: config.iconRes, + title: localizedString(config.titleRes), + subtitle: config.subtitleRes.map(localizedString), + count: nil, + showDivider: index != categories.count - 1) { [weak self] in + self?.openFullSearch(config.quickPresetType, catalogWid: nil) + } + } + exploreContentStack.addArrangedSubview(categoriesContainer) + } + + private func bindExploreSearchBarListeners() {} + + private func setupMyDataRows() { + myDataContainer.axis = .vertical + myDataContainer.spacing = 0 + myDataContainer.removeArrangedSubviews() + configureExploreSectionCard(myDataContainer) + myDataContainer.addArrangedSubview(sectionHeaderView(localizedString("astro_explore_my_data"))) + let config = parentStarMapController?.getSearchStarMapConfig() ?? AstronomyPluginSettings.load().starMap + let items: [(ExploreRowConfig, Int)] = [ + (ExploreRowConfig(quickPresetType: .MY_DATA_FAVORITES, iconRes: "ic_custom_bookmark", titleRes: "favorites_item", subtitleRes: nil), config.favorites.count), + (ExploreRowConfig(quickPresetType: .MY_DATA_DAILY_PATH, iconRes: "ic_custom_target_path_on", titleRes: "astro_daily_path", subtitleRes: nil), config.celestialPaths.count), + (ExploreRowConfig(quickPresetType: .MY_DATA_DIRECTIONS, iconRes: "ic_custom_target_direction_on", titleRes: "astro_directions", subtitleRes: nil), config.directions.count) + ] + for (index, item) in items.enumerated() { + addExploreRow(container: myDataContainer, + iconRes: item.0.iconRes, + title: localizedString(item.0.titleRes), + subtitle: nil, + count: item.1, + showDivider: index != items.count - 1) { [weak self] in + self?.openFullSearch(item.0.quickPresetType, catalogWid: nil) + } + } + exploreContentStack.addArrangedSubview(myDataContainer) + } + + private func setupCatalogRows() { + catalogsContainer.axis = .vertical + catalogsContainer.spacing = 0 + catalogsContainer.removeArrangedSubviews() + configureExploreSectionCard(catalogsContainer) + catalogsContainer.addArrangedSubview(sectionHeaderView(localizedString("astro_catalogs"))) + let featuredCatalogs = getFeaturedCatalogEntries() + for entry in featuredCatalogs { + addExploreRow(container: catalogsContainer, + iconRes: "ic_custom_book_info", + iconColor: StarMapSearchLightPalette.defaultIcon, + title: entry.displayName, + subtitle: nil, + count: nil, + showDivider: true) { [weak self] in + self?.clearCatalogsBackState() + self?.openFullSearch(.CATALOG_WID, catalogWid: entry.catalog.wid) + } + } + configureCatalogsViewAllRow() + catalogsViewAllRow.addTarget(self, action: #selector(catalogsViewAllPressed), for: .touchUpInside) + catalogsContainer.addArrangedSubview(catalogsViewAllRow) + exploreContentStack.addArrangedSubview(catalogsContainer) + } + + private func getBrowsableCatalogEntries() -> [StarMapCatalogEntry] { + preparedCatalogEntries.filter { $0.objectCount > 0 } + } + + private func getFeaturedCatalogEntries() -> [StarMapCatalogEntry] { + var entriesByWid: [String: StarMapCatalogEntry] = [:] + for entry in preparedCatalogEntries { + entriesByWid[entry.catalog.wid] = entry + } + let prioritizedEntries = Self.FEATURED_CATALOG_WIDS.compactMap { entriesByWid[$0] } + if prioritizedEntries.count >= Self.FEATURED_CATALOGS_COUNT { + return Array(prioritizedEntries.prefix(Self.FEATURED_CATALOGS_COUNT)) + } + let selectedWids = Set(prioritizedEntries.map { $0.catalog.wid }) + let fallbackEntries = preparedCatalogEntries.filter { !selectedWids.contains($0.catalog.wid) } + return Array((prioritizedEntries + fallbackEntries).prefix(Self.FEATURED_CATALOGS_COUNT)) + } + + private func addExploreRow(container: UIStackView, + iconRes: String, + iconColor: UIColor = .systemBlue, + title: String, + subtitle: String?, + count: Int?, + showDivider: Bool = true, + onClick: (() -> Void)?) { + let row = UIControl() + configureExploreRow(row, + iconName: iconRes, + iconColor: iconColor, + title: title, + subtitle: subtitle, + count: count, + showDivider: showDivider) + if let onClick { + row.addAction(UIAction { _ in onClick() }, for: .touchUpInside) + } + container.addArrangedSubview(row) + } + + private func configureExploreRow(_ row: UIControl, + iconName: String, + iconColor: UIColor = .systemBlue, + title: String, + subtitle: String?, + count: Int?, + showDivider: Bool = false) { + row.subviews.forEach { $0.removeFromSuperview() } + row.backgroundColor = .clear + row.layer.cornerRadius = 0 + row.isUserInteractionEnabled = true + + let iconView = UIImageView(image: AstroIcon.template(iconName)) + iconView.tintColor = iconColor + iconView.contentMode = .scaleAspectFit + iconView.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.textColor = StarMapSearchLightPalette.primaryText + titleLabel.font = UIFont.preferredFont(forTextStyle: .body) + + let subtitleLabel = UILabel() + subtitleLabel.text = subtitle + subtitleLabel.textColor = StarMapSearchLightPalette.secondaryText + subtitleLabel.font = UIFont.preferredFont(forTextStyle: .footnote) + subtitleLabel.numberOfLines = 2 + subtitleLabel.isHidden = subtitle?.isEmpty != false + + let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + textStack.axis = .vertical + textStack.spacing = 2 + + let countLabel = UILabel() + countLabel.text = count.map(String.init) + countLabel.textColor = StarMapSearchLightPalette.secondaryText + countLabel.font = UIFont.preferredFont(forTextStyle: .subheadline) + countLabel.isHidden = count == nil + + let stack = UIStackView(arrangedSubviews: [iconView, textStack, countLabel]) + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = Layout.contentPadding + stack.translatesAutoresizingMaskIntoConstraints = false + stack.isUserInteractionEnabled = false + row.addSubview(stack) + let divider = UIView() + divider.backgroundColor = StarMapSearchLightPalette.separator + divider.translatesAutoresizingMaskIntoConstraints = false + divider.isUserInteractionEnabled = false + divider.isHidden = !showDivider + row.addSubview(divider) + + NSLayoutConstraint.activate([ + row.heightAnchor.constraint(greaterThanOrEqualToConstant: Layout.rowMinHeight), + iconView.widthAnchor.constraint(equalToConstant: Layout.iconSize), + iconView.heightAnchor.constraint(equalToConstant: Layout.iconSize), + stack.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: Layout.contentPadding), + stack.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -Layout.contentPadding), + stack.topAnchor.constraint(equalTo: row.topAnchor, constant: Layout.smallPadding), + stack.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -Layout.smallPadding), + divider.leadingAnchor.constraint(equalTo: textStack.leadingAnchor), + divider.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -Layout.contentPadding), + divider.bottomAnchor.constraint(equalTo: row.bottomAnchor), + divider.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale) + ]) + } + + private func configureExploreSectionCard(_ stack: UIStackView) { + stack.subviews.filter { $0.tag == Self.EXPLORE_SECTION_BACKGROUND_TAG }.forEach { $0.removeFromSuperview() } + let backgroundView = UIView() + backgroundView.tag = Self.EXPLORE_SECTION_BACKGROUND_TAG + backgroundView.backgroundColor = StarMapSearchLightPalette.listBackground + backgroundView.isUserInteractionEnabled = false + backgroundView.translatesAutoresizingMaskIntoConstraints = false + stack.layer.cornerRadius = 10 + stack.layer.masksToBounds = true + stack.insertSubview(backgroundView, at: 0) + NSLayoutConstraint.activate([ + backgroundView.leadingAnchor.constraint(equalTo: stack.leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: stack.trailingAnchor), + backgroundView.topAnchor.constraint(equalTo: stack.topAnchor), + backgroundView.bottomAnchor.constraint(equalTo: stack.bottomAnchor) + ]) + } + + private func sectionHeaderView(_ text: String) -> UIView { + let container = UIView() + container.backgroundColor = .clear + container.isUserInteractionEnabled = false + container.translatesAutoresizingMaskIntoConstraints = false + container.heightAnchor.constraint(greaterThanOrEqualToConstant: 52).isActive = true + + let label = UILabel() + label.text = text + label.textColor = StarMapSearchLightPalette.primaryText + label.font = UIFont.preferredFont(forTextStyle: .headline) + label.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Layout.contentPadding), + label.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -Layout.contentPadding), + label.topAnchor.constraint(equalTo: container.topAnchor, constant: Layout.contentPadding), + label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -Layout.smallPadding) + ]) + return container + } + + private func configureCatalogsViewAllRow() { + catalogsViewAllRow.subviews.forEach { $0.removeFromSuperview() } + catalogsViewAllRow.backgroundColor = .clear + catalogsViewAllRow.isUserInteractionEnabled = true + + let titleLabel = UILabel() + titleLabel.text = localizedString("shared_string_view_all") + titleLabel.textColor = .systemBlue + titleLabel.font = UIFont.preferredFont(forTextStyle: .body) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + catalogsViewAllCount.text = String(getBrowsableCatalogEntries().count) + catalogsViewAllCount.textColor = StarMapSearchLightPalette.secondaryText + catalogsViewAllCount.font = UIFont.preferredFont(forTextStyle: .body) + catalogsViewAllCount.translatesAutoresizingMaskIntoConstraints = false + + catalogsViewAllRow.addSubview(titleLabel) + catalogsViewAllRow.addSubview(catalogsViewAllCount) + NSLayoutConstraint.activate([ + catalogsViewAllRow.heightAnchor.constraint(greaterThanOrEqualToConstant: Layout.rowMinHeight), + titleLabel.leadingAnchor.constraint(equalTo: catalogsViewAllRow.leadingAnchor, constant: Layout.contentPadding), + titleLabel.centerYAnchor.constraint(equalTo: catalogsViewAllRow.centerYAnchor), + catalogsViewAllCount.trailingAnchor.constraint(equalTo: catalogsViewAllRow.trailingAnchor, constant: -Layout.contentPadding), + catalogsViewAllCount.centerYAnchor.constraint(equalTo: catalogsViewAllRow.centerYAnchor), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: catalogsViewAllCount.leadingAnchor, constant: -Layout.smallPadding) + ]) + } + + private func openFullSearch(_ quickPresetType: StarMapSearchQuickPresetType, catalogWid: String?) { + searchState.prepareForExploreEntry(quickPresetType, catalogWid: catalogWid) + currentFullSearchMode = searchState.shouldOpenInBrowseMode() ? .BROWSE : .INPUT + prepareForFreshResultLoad() + applyMode(.FULL_SEARCH, requestKeyboard: currentFullSearchMode == .INPUT) + applyFiltersAndSort(scrollToTop: true) + } + + private func applyMode(_ mode: ScreenMode, requestKeyboard: Bool) { + currentMode = mode + switch mode { + case .EXPLORE: + showExploreMode() + case .FULL_SEARCH: + if currentFullSearchMode == .BROWSE { + showBrowseMode() + } else { + showInputMode(.STANDALONE, requestKeyboard: requestKeyboard) + } + } + } + + private func renderBrowseHeader() { + let title = getBrowseTitle() + titleLabel.text = title + toolbarTitleLabel.text = title + updateBrowseHeaderLayout() + } + + private func updateBrowseHeaderLayout() { + let compactHeader = isMyDataMode() + let inputMode = currentFullSearchMode == .INPUT + let collapsibleHeader = shouldUseCollapsibleBrowseTitle() + browseTitleContainer.isHidden = inputMode || !collapsibleHeader + toolbarTitleLabel.isHidden = inputMode || (!compactHeader && !collapsibleHeader) + browseSearchButton.isHidden = inputMode || compactHeader + if compactHeader { + toolbarTitleLabel.alpha = 1 + titleLabel.alpha = 0 + titleLabel.transform = .identity + browseTitleContainerHeightConstraint?.constant = 0 + } else if collapsibleHeader { + updateBrowseTitleCollapse(scrollOffset: currentSearchScrollOffset(), animated: false) + } + updateMyDataTabs() + } + + private func shouldUseCollapsibleBrowseTitle() -> Bool { + currentMode == .FULL_SEARCH && currentFullSearchMode == .BROWSE && !isMyDataMode() + } + + private func currentSearchScrollOffset() -> CGFloat { + max(0, searchRecycler.contentOffset.y + searchRecycler.adjustedContentInset.top) + } + + private func onResultsScrolled(_ scrollView: UIScrollView) { + guard shouldUseCollapsibleBrowseTitle() else { + return + } + let offset = max(0, scrollView.contentOffset.y + scrollView.adjustedContentInset.top) + updateBrowseTitleCollapse(scrollOffset: offset, animated: false) + } + + private func updateBrowseTitleCollapse(scrollOffset: CGFloat, animated: Bool) { + guard shouldUseCollapsibleBrowseTitle() else { + return + } + let progress = min(1, max(0, scrollOffset / Layout.browseTitleCollapseDistance)) + let largeTitleAlpha = max(0, 1 - progress * 1.35) + let toolbarTitleAlpha = min(1, max(0, (progress - 0.2) / 0.8)) + let titleTranslation = -Layout.browseTitleExpandedHeight * 0.35 * progress + let applyChanges = { [self] in + browseTitleContainerHeightConstraint?.constant = Layout.browseTitleExpandedHeight * (1 - progress) + titleLabel.alpha = largeTitleAlpha + titleLabel.transform = CGAffineTransform(translationX: 0, y: titleTranslation) + toolbarTitleLabel.alpha = toolbarTitleAlpha + view.layoutIfNeeded() + } + if animated { + UIView.animate(withDuration: 0.18, delay: 0, options: [.beginFromCurrentState, .curveEaseOut], animations: applyChanges) + } else { + applyChanges() + } + } + + private func resetBrowseTitleCollapseState(scrollToTop: Bool) { + pendingBrowseScrollOffsetRestore = nil + if scrollToTop { + resetSearchRecyclerScrollPosition() + } + browseTitleContainerHeightConstraint?.constant = Layout.browseTitleExpandedHeight + titleLabel.alpha = 1 + titleLabel.transform = .identity + toolbarTitleLabel.alpha = isMyDataMode() ? 1 : 0 + } + + private func resetSearchRecyclerScrollPosition() { + let topOffset = CGPoint(x: 0, y: -searchRecycler.adjustedContentInset.top) + restoreSearchRecyclerScrollPosition(topOffset) + } + + private func restoreSearchRecyclerScrollPosition(_ contentOffset: CGPoint) { + let topOffset = CGPoint(x: contentOffset.x, y: max(contentOffset.y, -searchRecycler.adjustedContentInset.top)) + if searchRecycler.contentOffset != topOffset { + searchRecycler.setContentOffset(topOffset, animated: false) + } + updateBrowseTitleCollapse(scrollOffset: currentSearchScrollOffset(), animated: false) + } + + private func isMyDataMode() -> Bool { + searchState.quickPresetType.isMyData + } + + private func getSelectedMyDataTabIndex() -> Int? { + searchState.quickPresetType.myDataTabIndex + } + + private func updateMyDataTabs() { + let visible = currentMode == .FULL_SEARCH && currentFullSearchMode == .BROWSE && isMyDataMode() + myDataTabs.isHidden = !visible + guard visible, let selectedIndex = getSelectedMyDataTabIndex() else { + return + } + for (index, button) in myDataTabButtons.enumerated() { + let selected = index == selectedIndex + button.setTitleColor(selected ? .systemBlue : StarMapSearchLightPalette.secondaryText, for: .normal) + if index < myDataTabIndicators.count { + myDataTabIndicators[index].isHidden = !selected + } + } + } + + private func showExploreMode() { + resetBrowseTitleCollapseState(scrollToTop: true) + view.backgroundColor = StarMapSearchLightPalette.listBackground + exploreContainer.isHidden = false + fullSearchContainer.isHidden = true + exploreSearchBar.text = nil + view.endEditing(true) + } + + private func showBrowseMode(resetCollapseState: Bool = true) { + if resetCollapseState { + resetBrowseTitleCollapseState(scrollToTop: true) + } + exploreContainer.isHidden = true + fullSearchContainer.isHidden = false + currentFullSearchMode = .BROWSE + currentInputPresentation = searchState.hasBrowseContext() ? .STANDALONE : .EXPLORE_BAR + view.backgroundColor = appBarBackgroundColor() + browseToolbar.isHidden = false + inputToolbar.isHidden = true + headerStack.layoutMargins = .zero + renderBrowseHeader() + syncSearchQuery() + fullSearchBar.resignFirstResponder() + updateResultsAdapter() + updateSortControls() + updateFilterControls() + updateEmptyStateContent() + updateEmptyStateVisibility() + } + + private func switchToInputMode() { + showInputMode(.STANDALONE, requestKeyboard: true) + } + + private func showInputMode(_ presentation: InputPresentation, requestKeyboard: Bool) { + resetBrowseTitleCollapseState(scrollToTop: true) + exploreContainer.isHidden = true + fullSearchContainer.isHidden = false + currentFullSearchMode = .INPUT + currentInputPresentation = presentation + view.backgroundColor = appBarBackgroundColor() + browseToolbar.isHidden = true + inputToolbar.isHidden = false + headerStack.layoutMargins = .zero + browseTitleContainer.isHidden = true + toolbarTitleLabel.isHidden = true + myDataTabs.isHidden = true + syncSearchQuery() + updateResultsAdapter() + updateSortControls() + updateFilterControls() + updateEmptyStateContent() + updateEmptyStateVisibility() + if requestKeyboard { + fullSearchBar.becomeFirstResponder() + } + } + + private func handleBackPressedInternal() -> Bool { + if currentMode == .FULL_SEARCH { + if currentFullSearchMode == .BROWSE { + if dismissOnBrowseBack { + dismiss(animated: true) + return true + } + if restoreCatalogsListIfNeeded() { + return true + } + handleBrowseBackNavigation() + return true + } + if searchState.hasBrowseContext() { + showBrowseMode() + } else { + applyMode(.EXPLORE, requestKeyboard: false) + } + return true + } + return false + } + + private func handleBrowseBackNavigation() { + searchState.reset() + clearCatalogsBackState() + applyMode(.EXPLORE, requestKeyboard: false) + applyFiltersAndSort(scrollToTop: false) + } + + private func restoreCatalogsListIfNeeded() -> Bool { + guard let backState = catalogsBackState else { + return false + } + clearCatalogsBackState() + searchState.prepareForExploreEntry(.CATALOGS, catalogWid: nil) + searchState.query = backState.query + searchState.sortMode = backState.sortMode + currentFullSearchMode = .BROWSE + pendingBrowseScrollOffsetRestore = backState.scrollOffset + showBrowseMode(resetCollapseState: false) + restoreSearchRecyclerScrollPosition(backState.scrollOffset) + applyFiltersAndSort(scrollToTop: false) + return true + } + + private func clearCatalogsBackState() { + catalogsBackState = nil + } + + private func configureSearchView(_ presentation: InputPresentation, requestKeyboard: Bool) { + showInputMode(presentation, requestKeyboard: requestKeyboard) + } + + private func handleSearchViewShown(_ presentation: InputPresentation) { + currentInputPresentation = presentation + } + + private func handleSearchViewHidden(_ presentation: InputPresentation) { + pendingSearchHideTarget = searchState.hasBrowseContext() ? .BROWSE : .EXPLORE + } + + private func syncSearchQuery() { + suppressQueryDispatch = true + fullSearchBar.text = searchState.query + suppressQueryDispatch = false + } + + private func refreshPreparedEntries() { + let preparedData = searchPreparedDataFactory.create(parent: parentStarMapController) + preparedEntries = preparedData.entries + preparedCatalogEntries = preparedData.catalogEntries + widToDisplayName = preparedData.widToDisplayName + searchHelper.updateComputationContext(preparedData.computationContext) + } + + private func shouldShowInfoHeader() -> Bool { + currentMode == .FULL_SEARCH && + currentFullSearchMode == .BROWSE && + !shouldShowCatalogEntries() && + searchState.categoryPreset() != nil + } + + private func getBrowseTitle() -> String { + switch searchState.quickPresetType { + case .WATCH_NOW: + return localizedString("astro_explore_watch_now") + case .CATALOGS: + return localizedString("astro_catalogs") + case .CATEGORY_SOLAR_SYSTEM: + return localizedString("astro_solar_system") + case .CATEGORY_CONSTELLATIONS: + return localizedString("astro_constellations") + case .CATEGORY_STARS: + return localizedString("astro_stars") + case .CATEGORY_NEBULAS: + return localizedString("astro_nebulas") + case .CATEGORY_STAR_CLUSTERS: + return localizedString("astro_star_clusters") + case .CATEGORY_DEEP_SKY: + return localizedString("astro_deep_sky") + case .MY_DATA_FAVORITES, .MY_DATA_DAILY_PATH, .MY_DATA_DIRECTIONS: + return localizedString("astro_explore_my_data") + case .CATALOG_WID: + return dataProvider.getCatalogs().first { $0.wid == searchState.quickPresetCatalogWid }?.name ?? localizedString("shared_string_search") + case .NONE: + return localizedString("shared_string_search") + } + } + + private func shouldShowCatalogEntries() -> Bool { + searchState.quickPresetType == .CATALOGS + } + + private func updateResultsAdapter() { + if shouldShowCatalogEntries() { + catalogsAdapter.submitSnapshot(StarMapCatalogsAdapter.Snapshot(entries: visibleCatalogEntries)) + searchRecycler.dataSource = catalogsAdapter + searchRecycler.delegate = catalogsAdapter + } else { + let categoryPreset = searchState.categoryPreset() + searchAdapter.submitSnapshot(StarMapSearchResultsAdapter.Snapshot(entries: visibleEntries, + categoryPreset: categoryPreset, + infoHeaderCategory: shouldShowInfoHeader() ? categoryPreset : nil, + useExploreRowLayout: isMyDataMode())) + searchRecycler.dataSource = searchAdapter + searchRecycler.delegate = searchAdapter + } + searchRecycler.reloadData() + } + + private func prepareForFreshResultLoad() { + isFilteringResults = true + if shouldShowCatalogEntries() { + visibleCatalogEntries.removeAll() + } else { + visibleEntries.removeAll() + } + updateResultsAdapter() + } + + private func getCurrentResultsCount() -> Int { + shouldShowCatalogEntries() ? visibleCatalogEntries.count : visibleEntries.count + } + + private func updateInfoCard() { + let isInfoHeaderVisible = shouldShowInfoHeader() + wasInfoHeaderVisible = isInfoHeaderVisible + } + + private func applyFiltersAndSort(scrollToTop: Bool) { + normalizeTypeFilterForCurrentPreset() + let requestId = filterAndSortRequestId + 1 + filterAndSortRequestId = requestId + isFilteringResults = true + let stateSnapshot = searchState.snapshot() + let isCatalogsMode = shouldShowCatalogEntries() + let preparedEntriesSnapshot = preparedEntries + let preparedCatalogEntriesSnapshot = preparedCatalogEntries + updateResultsAdapter() + updateSortControls() + updateFilterControls() + updateEmptyStateContent() + updateSortProgressVisibility(true) + emptyStateContainer.isHidden = true + searchRecycler.isHidden = false + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { + return + } + if isCatalogsMode { + let filteredCatalogs = self.filterAndSortCatalogs(stateSnapshot: stateSnapshot, + preparedCatalogEntries: preparedCatalogEntriesSnapshot) + DispatchQueue.main.async { [weak self] in + guard let self, + viewIfLoaded?.window != nil, + requestId == filterAndSortRequestId else { + return + } + visibleCatalogEntries = filteredCatalogs + updateResultsAdapter() + finishApplyFilters(scrollToTop: scrollToTop, requestId: requestId) + } + } else { + let insertionOrderById = self.getMyDataInsertionOrderMap(stateSnapshot.quickPresetType) + let filteredEntries = stateSnapshot.filterAndSort( + preparedEntries: preparedEntriesSnapshot.map { $0.copy() }, + visibleTonightProvider: self.searchHelper.getVisibleTonight, + riseSortValueProvider: self.searchHelper.getRiseSortValue, + setSortValueProvider: self.searchHelper.getSetSortValue, + insertionOrderProvider: { entry in insertionOrderById[entry.objectRef.id] } + ) + self.searchHelper.preloadRiseSet(filteredEntries.prefix(Self.RISE_SET_PRELOAD_COUNT)) + DispatchQueue.main.async { [weak self] in + guard let self, + viewIfLoaded?.window != nil, + requestId == filterAndSortRequestId else { + return + } + visibleEntries = filteredEntries + updateResultsAdapter() + finishApplyFilters(scrollToTop: scrollToTop, requestId: requestId) + } + } + } + } + + private func finishApplyFilters(scrollToTop: Bool, requestId: Int) { + if let restoredScrollOffset = pendingBrowseScrollOffsetRestore { + pendingBrowseScrollOffsetRestore = nil + restoreSearchRecyclerScrollPosition(restoredScrollOffset) + } else if scrollToTop { + resetSearchRecyclerScrollPosition() + } + isFilteringResults = false + updateEmptyStateVisibility() + updateBrowseTitleCollapse(scrollOffset: currentSearchScrollOffset(), animated: false) + if requestId == filterAndSortRequestId { + updateSortProgressVisibility(false) + } + } + + private func filterAndSortCatalogs(stateSnapshot: StarMapSearchStateSnapshot, + preparedCatalogEntries: [StarMapCatalogEntry]) -> [StarMapCatalogEntry] { + let queryLower = stateSnapshot.query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(with: Locale.current) + let filteredEntries = preparedCatalogEntries.filter { entry in + if entry.objectCount <= 0 { + return false + } + if queryLower.isEmpty { + return true + } + return entry.displayName.lowercased(with: Locale.current).contains(queryLower) || + (entry.description ?? "").lowercased(with: Locale.current).contains(queryLower) + } + switch stateSnapshot.sortMode { + case .NAME_DESC: + return filteredEntries.sorted { $0.displayName.lowercased(with: Locale.current) > $1.displayName.lowercased(with: Locale.current) } + default: + return filteredEntries.sorted { $0.displayName.lowercased(with: Locale.current) < $1.displayName.lowercased(with: Locale.current) } + } + } + + private func updateSortProgressVisibility(_ isVisible: Bool) { + if isVisible { + sortProgress.startAnimating() + } else { + sortProgress.stopAnimating() + } + } + + private func updateSortControls() { + let text: String + let iconName: String + switch searchState.sortMode { + case .NEWEST_FIRST: + text = localizedString("astro_sort_newest_first") + iconName = "ic_custom_sort_date_newest" + case .OLDEST_FIRST: + text = localizedString("astro_sort_oldest_first") + iconName = "ic_custom_sort_date_oldest" + case .NAME_ASC: + text = localizedString("sort_name_ascending") + iconName = "ic_custom_sort_name_ascending" + case .NAME_DESC: + text = localizedString("sort_name_descending") + iconName = "ic_custom_sort_name_descending" + case .BRIGHTEST_FIRST: + text = localizedString("astro_sort_brightest_first") + iconName = "ic_custom_sort_brightest" + case .FAINTEST_FIRST: + text = localizedString("astro_sort_faintest_first") + iconName = "ic_custom_sort_faintest" + case .RISES_SOONEST: + text = localizedString("astro_sort_rises_soonest") + iconName = "ic_custom_sort_rises" + case .SETS_SOONEST: + text = localizedString("astro_sort_sets_soonest") + iconName = "ic_custom_sort_sets" + } + var configuration = sortButton.configuration ?? UIButton.Configuration.plain() + configuration.title = text + configuration.image = AstroIcon.template(iconName) + configuration.imagePlacement = .leading + configuration.baseForegroundColor = .systemBlue + configuration.imagePadding = Layout.smallPadding + sortButton.configuration = configuration + sortButton.menu = createSortMenu() + } + + private func updateFilterControls() { + filterButton.isHidden = shouldShowCatalogEntries() + var configuration = filterButton.configuration ?? UIButton.Configuration.plain() + configuration.title = String(format: localizedString("filter_tracks_count"), searchState.calculateFilterCount()) + configuration.image = .icCustomFilter + configuration.imagePlacement = .trailing + configuration.baseForegroundColor = .systemBlue + configuration.imagePadding = Layout.smallPadding + filterButton.configuration = configuration + filterButton.menu = createFilterMenu() + } + + private func shouldHideShowAllTypeFilter() -> Bool { + searchState.quickPresetType == .WATCH_NOW + } + + private func normalizeTypeFilterForCurrentPreset() { + if shouldHideShowAllTypeFilter() && searchState.typeFilter == .SHOW_ALL { + searchState.typeFilter = .VISIBLE_TONIGHT + } + } + + private func shouldShowWatchNowClearFiltersAction() -> Bool { + searchState.quickPresetType == .WATCH_NOW && searchState.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func updateEmptyStateContent() { + if isMyDataMode() { + let iconName: String + let titleKey: String + let descriptionKey: String + switch searchState.quickPresetType { + case .MY_DATA_DIRECTIONS: + iconName = "ic_custom_bookmark_outlined" + titleKey = "astro_my_data_no_directions_title" + descriptionKey = "astro_my_data_no_directions_description" + case .MY_DATA_DAILY_PATH: + iconName = "ic_custom_target_path_off" + titleKey = "astro_my_data_no_daily_paths_title" + descriptionKey = "astro_my_data_no_daily_paths_description" + default: + iconName = "ic_custom_bookmark_outlined" + titleKey = "astro_my_data_no_favorites_title" + descriptionKey = "astro_my_data_no_favorites_description" + } + emptyStateIcon.image = AstroIcon.template(iconName) + emptyStateTitle.text = localizedString(titleKey) + emptyStateDescription.text = localizedString(descriptionKey) + emptyStateResetButton.setTitle(localizedString("astro_go_to_map"), for: .normal) + } else { + emptyStateIcon.image = AstroIcon.template("ic_action_ufo") + emptyStateTitle.text = localizedString("nothing_found") + emptyStateDescription.text = localizedString("astro_search_empty_description") + emptyStateResetButton.setTitle(localizedString(shouldShowWatchNowClearFiltersAction() ? "shared_string_clear_filters" : "shared_string_reset"), for: .normal) + } + } + + private func applyEmptyStateButtonStyle() { + var configuration = UIButton.Configuration.filled() + configuration.cornerStyle = .small + configuration.baseBackgroundColor = StarMapSearchLightPalette.secondaryButtonBackground + configuration.baseForegroundColor = .systemBlue + emptyStateResetButton.configuration = configuration + } + + private func updateEmptyStateVisibility() { + if isFilteringResults { + emptyStateContainer.isHidden = true + searchRecycler.isHidden = false + return + } + let shouldShowEmptyState = currentMode == .FULL_SEARCH && getCurrentResultsCount() == 0 + emptyStateContainer.isHidden = !shouldShowEmptyState + searchRecycler.isHidden = shouldShowEmptyState + } + + private func handleEmptyStateAction() { + if isMyDataMode() { + dismiss(animated: true) + } else if shouldShowWatchNowClearFiltersAction() { + resetWatchNowFilters() + } else { + resetAllSearchParams() + } + } + + private func resetWatchNowFilters() { + searchState.typeFilter = .VISIBLE_TONIGHT + searchState.nakedEyeOnly = false + searchState.selectedCategories.removeAll() + searchState.selectedCategories.append(.ALL) + applyFiltersAndSort(scrollToTop: true) + } + + private func resetAllSearchParams() { + if shouldShowCatalogEntries() { + searchState.query = "" + searchState.sortMode = .NAME_ASC + currentFullSearchMode = currentMode == .FULL_SEARCH && currentFullSearchMode == .BROWSE ? .BROWSE : .INPUT + syncSearchQuery() + if currentMode == .FULL_SEARCH && currentFullSearchMode == .INPUT { + showInputMode(.STANDALONE, requestKeyboard: false) + } else if currentMode == .FULL_SEARCH { + showBrowseMode() + } + } else { + searchState.reset() + currentFullSearchMode = .INPUT + syncSearchQuery() + if currentMode == .FULL_SEARCH { + showInputMode(.STANDALONE, requestKeyboard: false) + } + } + applyFiltersAndSort(scrollToTop: true) + } + + private func addRecentChip(_ entry: StarMapSearchEntry) { + searchState.addRecentChip(label: entry.displayName, objectId: entry.objectRef.id) + plugin.recentSearchChips.removeAll() + plugin.recentSearchChips.append(contentsOf: searchState.recentChips) + renderRecentChips() + } + + private func syncRecentChipsWithSession() { + if plugin.recentSearchChips.isEmpty { + plugin.recentSearchChips.append(contentsOf: searchState.recentChips) + } else { + searchState.replaceRecentChips(plugin.recentSearchChips) + } + } + + private func renderRecentChips() { + recentChipsContainer.removeArrangedSubviews() + recentChipsScroll.isHidden = searchState.recentChips.isEmpty + if searchState.recentChips.isEmpty { + return + } + for recentChip in searchState.recentChips { + var configuration = UIButton.Configuration.filled() + configuration.cornerStyle = .capsule + configuration.title = recentChip.label + configuration.baseBackgroundColor = UIColor.systemBlue.withAlphaComponent(0.12) + configuration.baseForegroundColor = .systemBlue + let chipButton = UIButton(configuration: configuration) + chipButton.addAction(UIAction { [weak self] _ in + guard let self else { + return + } + let selectedEntry = recentChip.objectId.flatMap { objectId in + self.preparedEntries.first { $0.objectRef.id == objectId } + } ?? self.preparedEntries.first { + $0.displayName.caseInsensitiveCompare(recentChip.label) == .orderedSame || + $0.objectRef.name.caseInsensitiveCompare(recentChip.label) == .orderedSame + } + if let selectedEntry { + self.onSearchEntrySelected(selectedEntry) + } else { + self.searchState.selectQuickPreset(.NONE, catalogWid: nil) + self.currentFullSearchMode = .INPUT + self.searchState.query = recentChip.label + self.showInputMode(.EXPLORE_BAR, requestKeyboard: true) + self.applyFiltersAndSort(scrollToTop: true) + } + }, for: .touchUpInside) + recentChipsContainer.addArrangedSubview(chipButton) + } + } + + private func onSearchEntrySelected(_ entry: StarMapSearchEntry) { + addRecentChip(entry) + dismiss(animated: true) { [weak self] in + self?.onObjectSelected?(entry.objectRef) + } + } + + private func onCatalogSelected(_ entry: StarMapCatalogEntry) { + catalogsBackState = CatalogsBackState(query: searchState.query, + sortMode: searchState.sortMode, + scrollOffset: searchRecycler.contentOffset) + openFullSearch(.CATALOG_WID, catalogWid: entry.catalog.wid) + } + + private func createSortMenu() -> UIMenu { + if shouldShowCatalogEntries() { + return UIMenu(title: localizedString("sort_by"), children: [ + sortAction(title: localizedString("sort_name_ascending"), mode: .NAME_ASC), + sortAction(title: localizedString("sort_name_descending"), mode: .NAME_DESC) + ]) + } + var actions = [ + sortAction(title: localizedString("sort_name_ascending"), mode: .NAME_ASC), + sortAction(title: localizedString("sort_name_descending"), mode: .NAME_DESC), + sortAction(title: localizedString("astro_sort_brightest_first"), mode: .BRIGHTEST_FIRST), + sortAction(title: localizedString("astro_sort_faintest_first"), mode: .FAINTEST_FIRST), + sortAction(title: localizedString("astro_sort_rises_soonest"), mode: .RISES_SOONEST), + sortAction(title: localizedString("astro_sort_sets_soonest"), mode: .SETS_SOONEST) + ] + if isMyDataMode() { + actions.append(sortAction(title: localizedString("astro_sort_newest_first"), mode: .NEWEST_FIRST)) + actions.append(sortAction(title: localizedString("astro_sort_oldest_first"), mode: .OLDEST_FIRST)) + } + return UIMenu(title: localizedString("sort_by"), children: actions) + } + + private func sortAction(title: String, mode: StarMapSearchSortMode) -> UIAction { + UIAction(title: title, state: searchState.sortMode == mode ? .on : .off) { [weak self] _ in + self?.searchState.sortMode = mode + self?.updateSortControls() + self?.applyFiltersAndSort(scrollToTop: true) + } + } + + private func getMyDataInsertionOrderMap(_ quickPresetType: StarMapSearchQuickPresetType) -> [String: Int] { + let config = parentStarMapController?.getSearchStarMapConfig() ?? AstronomyPluginSettings.load().starMap + let ids: [String] + switch quickPresetType { + case .MY_DATA_FAVORITES: + ids = config.favorites.map(\.id) + case .MY_DATA_DAILY_PATH: + ids = config.celestialPaths.map(\.id) + case .MY_DATA_DIRECTIONS: + ids = config.directions.map(\.id) + default: + ids = [] + } + var result: [String: Int] = [:] + for (index, id) in ids.enumerated() { + result[id] = index + } + return result + } + + private func createFilterMenu() -> UIMenu { + guard !shouldShowCatalogEntries() else { + return UIMenu(children: []) + } + normalizeTypeFilterForCurrentPreset() + var children: [UIMenuElement] = [] + if !shouldHideShowAllTypeFilter() { + children.append(typeFilterAction(title: localizedString("astro_filter_show_all"), filter: .SHOW_ALL)) + } + children.append(typeFilterAction(title: localizedString("astro_filter_visible_now"), filter: .VISIBLE_NOW)) + children.append(typeFilterAction(title: localizedString("astro_filter_visible_tonight"), filter: .VISIBLE_TONIGHT)) + children.append(UIAction(title: localizedString("astro_filter_naked_eye"), state: searchState.nakedEyeOnly ? .on : .off) { [weak self] _ in + guard let self else { + return + } + searchState.nakedEyeOnly.toggle() + applyFiltersAndSort(scrollToTop: true) + }) + if !searchState.isCategoryPreset() { + let categoryActions = [ + categoryFilterAction(title: localizedString("shared_string_all"), category: .ALL), + categoryFilterAction(title: localizedString("astro_solar_system"), category: .SOLAR_SYSTEM), + categoryFilterAction(title: localizedString("astro_constellations"), category: .CONSTELLATIONS), + categoryFilterAction(title: localizedString("astro_stars"), category: .STARS), + categoryFilterAction(title: localizedString("astro_nebulas"), category: .NEBULAS), + categoryFilterAction(title: localizedString("astro_star_clusters"), category: .STAR_CLUSTERS), + categoryFilterAction(title: localizedString("astro_deep_sky"), category: .DEEP_SKY) + ] + children.append(UIMenu(title: localizedString("favourites_edit_dialog_category"), options: .displayInline, children: categoryActions)) + } + return UIMenu(title: localizedString("shared_string_type"), children: children) + } + + private func typeFilterAction(title: String, filter: StarMapSearchTypeFilter) -> UIAction { + UIAction(title: title, state: searchState.typeFilter == filter ? .on : .off) { [weak self] _ in + self?.searchState.typeFilter = filter + self?.applyFiltersAndSort(scrollToTop: true) + } + } + + private func categoryFilterAction(title: String, category: StarMapSearchCategoryFilter) -> UIAction { + UIAction(title: title, state: searchState.selectedCategories.contains(category) ? .on : .off) { [weak self] _ in + self?.searchState.toggleCategoryFilter(category) + self?.applyFiltersAndSort(scrollToTop: true) + } + } + + private func createPopupDisplayData() {} + + private func createPopupHeaderItem() {} + + private func createRadioPopupItem() {} + + private func createCheckPopupItem() {} + + private func dismissSortPopup() {} + + private func dismissFilterPopup() {} + + private func dismissPopups() {} + + func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + if textField === exploreSearchBar { + openFullSearch(.NONE, catalogWid: nil) + return false + } + if currentFullSearchMode == .BROWSE { + switchToInputMode() + } + return true + } + + @objc private func searchTextChanged() { + guard !suppressQueryDispatch else { + return + } + searchState.query = fullSearchBar.text ?? "" + applyFiltersAndSort(scrollToTop: true) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + + @objc private func backPressed() { + if !handleBackPressedInternal() { + dismiss(animated: true) + } + } + + @objc private func close() { + dismiss(animated: true) + } + + @objc private func watchNowPressed() { + openFullSearch(.WATCH_NOW, catalogWid: nil) + } + + @objc private func openExploreInputSearch() { + openFullSearch(.NONE, catalogWid: nil) + } + + @objc private func switchToInputModeAction() { + switchToInputMode() + } + + @objc private func catalogsViewAllPressed() { + openFullSearch(.CATALOGS, catalogWid: nil) + } + + @objc private func myDataTabPressed(_ sender: UIControl) { + let preset: StarMapSearchQuickPresetType + switch sender.tag { + case 0: + preset = .MY_DATA_FAVORITES + case 1: + preset = .MY_DATA_DAILY_PATH + case 2: + preset = .MY_DATA_DIRECTIONS + default: + return + } + searchState.prepareForExploreEntry(preset, catalogWid: nil) + showBrowseMode() + applyFiltersAndSort(scrollToTop: true) + } + + @objc private func emptyStateAction() { + handleEmptyStateAction() + } +} + +private extension UIStackView { + func removeArrangedSubviews() { + for view in arrangedSubviews { + removeArrangedSubview(view) + view.removeFromSuperview() + } + } +} diff --git a/Sources/Plugins/OAPluginsHelper.h b/Sources/Plugins/OAPluginsHelper.h index 125bac5391..d6a8e02fc0 100644 --- a/Sources/Plugins/OAPluginsHelper.h +++ b/Sources/Plugins/OAPluginsHelper.h @@ -12,6 +12,9 @@ NS_ASSUME_NONNULL_BEGIN static NSString * const ONLINE_PLUGINS_URL = @"https://osmand.net/api/plugins/list"; static NSString * const OSMAND_URL = @"https://osmand.net"; +FOUNDATION_EXPORT NSNotificationName const OAPluginsHelperPluginStateChangedNotification; +FOUNDATION_EXPORT NSString * const OAPluginsHelperPluginIdKey; +FOUNDATION_EXPORT NSString * const OAPluginsHelperPluginEnabledKey; @class OAPlugin, OACustomPlugin, OAWorldRegion, QuickActionType, OAApplicationMode, OAPOIUIFilter, OASGpxTrackAnalysis, OASPointAttributes, OAWidgetType, OABaseWidgetView, OAOnlinePlugin; diff --git a/Sources/Plugins/OAPluginsHelper.mm b/Sources/Plugins/OAPluginsHelper.mm index 49d3be8d90..4777bf8f7a 100644 --- a/Sources/Plugins/OAPluginsHelper.mm +++ b/Sources/Plugins/OAPluginsHelper.mm @@ -36,6 +36,10 @@ #import "OAOnlinePlugin.h" #import "OsmAnd_Maps-Swift.h" +NSNotificationName const OAPluginsHelperPluginStateChangedNotification = @"OAPluginsHelperPluginStateChangedNotification"; +NSString * const OAPluginsHelperPluginIdKey = @"pluginId"; +NSString * const OAPluginsHelperPluginEnabledKey = @"enabled"; + @implementation OAPluginsHelper static NSMutableArray *allPlugins; @@ -74,6 +78,10 @@ + (BOOL) enablePlugin:(OAPlugin *)plugin enable:(BOOL)enable recreateControls:(B if (recreateControls) [OARootViewController.instance.mapPanel.hudViewController.mapInfoController recreateAllControls]; [plugin updateLayers]; + [[NSNotificationCenter defaultCenter] postNotificationName:OAPluginsHelperPluginStateChangedNotification + object:plugin + userInfo:@{ OAPluginsHelperPluginIdKey : [plugin getId] ?: @"", + OAPluginsHelperPluginEnabledKey : @(enable) }]; return YES; } @@ -99,6 +107,7 @@ + (void) initPlugins [allPlugins addObject:[[OAOsmEditingPlugin alloc] init]]; [allPlugins addObject:[[OAMapillaryPlugin alloc] init]]; [allPlugins addObject:[[OAWeatherPlugin alloc] init]]; + [allPlugins addObject:[AstronomyPlugin new]]; [allPlugins addObject:[[OAExternalSensorsPlugin alloc] init]]; [allPlugins addObject:[VehicleMetricsPlugin new]]; [allPlugins addObject:[[OAOsmandDevelopmentPlugin alloc] init]]; diff --git a/Sources/Purchases/OAIAPHelper.h b/Sources/Purchases/OAIAPHelper.h index 642bf17890..a439888a3e 100644 --- a/Sources/Purchases/OAIAPHelper.h +++ b/Sources/Purchases/OAIAPHelper.h @@ -97,6 +97,7 @@ typedef NS_ENUM(NSInteger, EOASubscriptionDuration) { @property (nonatomic, readonly) OAProduct *weather; @property (nonatomic, readonly) OAProduct *sensors; @property (nonatomic, readonly) OAProduct *vehicleMetrics; +@property (nonatomic, readonly) OAProduct *astronomy; @property (nonatomic, readonly) OAProduct *carplay; @property (nonatomic, readonly) OAProduct *osmandDevelopment; diff --git a/Sources/Purchases/OAIAPHelper.mm b/Sources/Purchases/OAIAPHelper.mm index b3c8cf2ad6..72715f753c 100644 --- a/Sources/Purchases/OAIAPHelper.mm +++ b/Sources/Purchases/OAIAPHelper.mm @@ -494,6 +494,11 @@ - (OAProduct *)vehicleMetrics return _products.vehicleMetrics; } +- (OAProduct *)astronomy +{ + return _products.astronomy; +} + - (OAProduct *) carplay { return _products.carplay; diff --git a/Sources/Purchases/OAProducts.h b/Sources/Purchases/OAProducts.h index 4471a0b99b..ec75bca8f9 100644 --- a/Sources/Purchases/OAProducts.h +++ b/Sources/Purchases/OAProducts.h @@ -50,6 +50,7 @@ NS_ASSUME_NONNULL_BEGIN #define kInAppId_Addon_OsmandDevelopment @"net.osmand.maps.inapp.addon.development" #define kInAppId_Addon_External_Sensors @"net.osmand.maps.inapp.addon.external_sensors" #define kInAppId_Addon_Vehicle_Metrics @"net.osmand.maps.inapp.addon.vehicle_metrics" +#define kInAppId_Addon_Astronomy @"net.osmand.maps.inapp.addon.astronomy" // Addons default prices (EUR) #define kInApp_Addon_SkiMap_Default_Price 0.0 @@ -353,6 +354,9 @@ typedef NS_ENUM(NSUInteger, OAProductDiscountType) @interface OAVehicleMetricsProduct : OAProduct @end +@interface OAAstronomyProduct : OAProduct +@end + @interface OACarPlayProduct : OAProduct @end @@ -407,6 +411,7 @@ typedef NS_ENUM(NSUInteger, OAProductDiscountType) @property (nonatomic, readonly) OAProduct *weather; @property (nonatomic, readonly) OAProduct *sensors; @property (nonatomic, readonly) OAProduct *vehicleMetrics; +@property (nonatomic, readonly) OAProduct *astronomy; @property (nonatomic, readonly) OAProduct *carplay; @property (nonatomic, readonly) OAProduct *osmandDevelopment; diff --git a/Sources/Purchases/OAProducts.mm b/Sources/Purchases/OAProducts.mm index 9276be75ad..556a439e59 100644 --- a/Sources/Purchases/OAProducts.mm +++ b/Sources/Purchases/OAProducts.mm @@ -2530,6 +2530,43 @@ - (NSString *)localizedDescriptionExt @end +@implementation OAAstronomyProduct + +- (instancetype)init +{ + self = [super initWithIdentifier:kInAppId_Addon_Astronomy]; + return self; +} + +- (OAFeature *)feature +{ + return OAFeature.ASTRONOMY; +} + +- (NSString *)productIconName +{ + return @"ic_custom_telescope"; +} + +- (NSString *)localizedTitle +{ + return [NSString stringWithFormat:OALocalizedString(@"ltr_or_rtl_combine_with_brackets"), + OALocalizedString(@"astronomy_plugin_name"), + OALocalizedString(@"shared_string_beta")]; +} + +- (NSString *)localizedDescription +{ + return OALocalizedString(@"purchases_feature_desc_astronomy"); +} + +- (NSString *)localizedDescriptionExt +{ + return OALocalizedString(@"purchases_feature_desc_astronomy"); +} + +@end + @implementation OACarPlayProduct @@ -2859,6 +2896,7 @@ @interface OAProducts() @property (nonatomic) OAProduct *weather; @property (nonatomic) OAProduct *sensors; @property (nonatomic) OAProduct *vehicleMetrics; +@property (nonatomic) OAProduct *astronomy; @property (nonatomic) OAProduct *carplay; @property (nonatomic) OAProduct *osmandDevelopment; @@ -2910,6 +2948,7 @@ - (instancetype) init self.weather = [[OAWeatherProduct alloc] init]; self.sensors = [[OAExternalSensorsProduct alloc] init]; self.vehicleMetrics = [OAVehicleMetricsProduct new]; + self.astronomy = [OAAstronomyProduct new]; self.carplay = [[OACarPlayProduct alloc] init]; self.osmandDevelopment = [[OAOsmandDevelopmentProduct alloc] init]; @@ -2935,6 +2974,7 @@ - (instancetype) init self.weather, self.sensors, self.vehicleMetrics, + self.astronomy, self.osmandDevelopment ]; From 6890296cccfc3790ead9a90bbb31b969152f2ab6 Mon Sep 17 00:00:00 2001 From: Karlo Kostanjevec Date: Mon, 22 Jun 2026 15:12:04 +0200 Subject: [PATCH 34/47] Translated using Weblate (Croatian) Currently translated at 25.9% (1047 of 4028 strings) --- .../hr.lproj/Localizable.strings | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Resources/Localizations/hr.lproj/Localizable.strings b/Resources/Localizations/hr.lproj/Localizable.strings index dd98f5a1d9..537ed9ee52 100644 --- a/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Resources/Localizations/hr.lproj/Localizable.strings @@ -420,7 +420,7 @@ "vector_data" = "Offline vektorske karte"; "configure_map" = "Konfiguracija karte"; "parking_place" = "Parkirno mjesto"; -"favorite_home_category" = "Doma"; +"favorite_home_category" = "Dom"; "map_widget_parking" = "Parkiralište"; "gpx_tags_txt" = "Oznake"; "delete_waypoints" = "Obriši putne točke"; @@ -1117,3 +1117,21 @@ "routing_attr_motor_type_diesel_name" = "Dizel"; "obd_fuel_type_diesel" = "Dizel"; "routing_attr_motor_type_petrol_name" = "Benzin"; +"map_markers" = "Oznake na karti"; +"appearance_on_the_map" = "Izgled na karti"; +"arrows_direction_to_markers" = "Strelice pokazuju smjer prema aktivnoj oznaci ako se nalazi izvan ekrana. Linije smjerova prikazuju liniju prema aktivnoj oznaci."; +"travel_card_download_descr" = "Preuzmite Wikivoyageove turističke vodiče da biste mogli pregledavati članke o mjestima diljem svijeta bez internetske veze."; +"travel_guides_beta" = "Turistički vodiči (Beta)"; +"shared_string_navigation" = "Navigacija"; +"add_home" = "Dodaj dom"; +"shared_string_sound" = "Zvuk"; +"show_along_the_route" = "Prikaži uz rutu"; +"routing_attr_prefer_unpaved_name" = "Preferiraj neasfaltirane ceste"; +"routing_attr_driving_style_prefer_unpaved_name" = "Preferiraj neasfaltirane ceste"; +"routing_attr_allow_private_name" = "Dozvoli privatne puteve"; +"routing_attr_short_way_name" = "Ekonomična ruta"; +"temporary_conditional_routing" = "Poštuj privremena ograničenja"; +"follow_track" = "Prati stazu"; +"select_track_to_follow" = "Odaberi stazu za pratiti"; +"customize_route_line" = "Prilagodi liniju rute"; +"simulate_navigation" = "Simuliraj navigaciju"; From 79f409b946d0e35a5215166802ae4fc2d53a3449 Mon Sep 17 00:00:00 2001 From: DmitrySvetlichny <111898301+DmitrySvetlichny@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:54:21 +0300 Subject: [PATCH 35/47] Palette editor u ireview (#5483) * Add showMediumToLargeSheetViewController * Add preferredAction * Add context menu and Warnings fix --- .../Cells/Handlers/OAColorCollectionHandler.m | 11 +- .../Handlers/PaletteCollectionHandler.swift | 150 +++++++-- ...sBuildings3DParametersViewController.swift | 2 +- ...SettingsTerrainParametersViewController.mm | 2 +- .../GradientEditorViewController.swift | 39 +-- .../ItemsCollectionViewController.swift | 286 +++++++++--------- ...TracksChangeAppearanceViewController.swift | 4 +- .../OARouteLineAppearanceHudViewController.mm | 2 +- .../OATrackMenuAppearanceHudViewController.mm | 2 +- .../Appearance/GradientPaletteHelper.swift | 16 +- .../UIViewController+Extension.swift | 14 + 11 files changed, 314 insertions(+), 214 deletions(-) diff --git a/Sources/Controllers/Cells/Handlers/OAColorCollectionHandler.m b/Sources/Controllers/Cells/Handlers/OAColorCollectionHandler.m index f7d306b87e..3f7d532e2e 100644 --- a/Sources/Controllers/Cells/Handlers/OAColorCollectionHandler.m +++ b/Sources/Controllers/Cells/Handlers/OAColorCollectionHandler.m @@ -10,7 +10,6 @@ #import "OAColorsCollectionViewCell.h" #import "OAColorsPaletteCell.h" #import "OACollectionSingleLineTableViewCell.h" -#import "OASuperViewController.h" #import "OAGPXAppearanceCollection.h" #import "OAUtilities.h" #import "OAColors.h" @@ -479,15 +478,7 @@ - (void)openAllColorsScreen [[ItemsCollectionViewController alloc] initWithCollectionType:ColorCollectionTypeColorItems items:_data[0] selectedItem:[self getSelectedItem]]; colorCollectionViewController.delegate = self; colorCollectionViewController.hostColorHandler = self; - if ([_hostVC isKindOfClass:OASuperViewController.class]) - { - [(OASuperViewController *)_hostVC showModalViewController:colorCollectionViewController]; - } - else - { - UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:colorCollectionViewController]; - [_hostVC presentViewController:navigationController animated:YES completion:nil]; - } + [_hostVC showMediumToLargeSheetViewController:colorCollectionViewController]; } } diff --git a/Sources/Controllers/Cells/Handlers/PaletteCollectionHandler.swift b/Sources/Controllers/Cells/Handlers/PaletteCollectionHandler.swift index acba0040cb..a418961cdf 100644 --- a/Sources/Controllers/Cells/Handlers/PaletteCollectionHandler.swift +++ b/Sources/Controllers/Cells/Handlers/PaletteCollectionHandler.swift @@ -13,6 +13,37 @@ final class PaletteCollectionHandler: OABaseCollectionHandler { private var selectedIndexPath: IndexPath? private var defaultIndexPath: IndexPath? private var data = [[PaletteItemGradient]]() + private var hostViewController: (UIViewController & ColorCollectionViewControllerDelegate)? { + delegate as? (UIViewController & ColorCollectionViewControllerDelegate) + } + + @objc static func applyGradient(to imageView: UIImageView, with colorPalette: OsmAndShared.ColorPalette) { + imageView.gradated(Self.createGradientPoints(colorPalette)) + } + + @objc static func createDescriptionForPalette(_ paletteItem: PaletteItemGradient) -> String { + let fileType = paletteItem.properties.fileType + return paletteItem.points.map { + GradientFormatter.formatSimpleValue(value: $0.value, fileType: fileType) + }.joined(separator: " • ") + } + + private static func createGradientPoints(_ colorPalette: OsmAndShared.ColorPalette) -> [GradientPoint] { + let colorValues = colorPalette.colors.compactMap { $0 as? OsmAndShared.ColorPalette.ColorValue } + guard !colorValues.isEmpty else { return [] } + if colorValues.count == 1 { + let color = UIColor(argb: Int(colorValues[0].clr)) + return [GradientPoint(location: 0, color: color), GradientPoint(location: 1, color: color)] + } + + var gradientPoints = [GradientPoint]() + let step = 1.0 / CGFloat(colorValues.count - 1) + for (index, colorValue) in colorValues.enumerated() { + gradientPoints.append(GradientPoint(location: CGFloat(index) * step, color: UIColor(argb: Int(colorValue.clr)))) + } + + return gradientPoints + } override func getCellIdentifier() -> String { PaletteCollectionViewCell.reuseIdentifier @@ -30,11 +61,6 @@ final class PaletteCollectionHandler: OABaseCollectionHandler { self.selectedIndexPath = selectedIndexPath } - func setSelectionItem(_ item: PaletteItemGradient?) { - guard let indexPath = indexPath(for: item) else { return } - selectedIndexPath = indexPath - } - override func generateData(_ data: [[Any]]) { var newData = [[PaletteItemGradient]]() defaultIndexPath = nil @@ -164,32 +190,102 @@ final class PaletteCollectionHandler: OABaseCollectionHandler { data.count } - @objc static func applyGradient(to imageView: UIImageView, with colorPalette: OsmAndShared.ColorPalette) { - imageView.gradated(Self.createGradientPoints(colorPalette)) + override func getMenuForItem(_ indexPath: IndexPath, collectionView _: UICollectionView) -> UIMenu? { + guard hostViewController != nil, data.indices.contains(indexPath.section), data[indexPath.section].indices.contains(indexPath.row) else { return nil } + return contextMenu(for: data[indexPath.section][indexPath.row]) } - - @objc static func createDescriptionForPalette(_ paletteItem: PaletteItemGradient) -> String { - let fileType = paletteItem.properties.fileType - return paletteItem.points.map { - GradientFormatter.formatSimpleValue(value: $0.value, fileType: fileType) - }.joined(separator: " • ") + + private func contextMenu(for paletteItem: PaletteItemGradient) -> UIMenu { + let canEditPalette = !paletteItem.isDefault && paletteItem.properties.fileType.category != .terrainHillshade && paletteItem.isEditable + var menuElements = [UIMenuElement]() + if canEditPalette { + let renameAction = UIAction(title: localizedString("shared_string_rename"), image: .icCustomEdit) { [weak self] _ in + self?.showRenamePaletteAlert(for: paletteItem) + } + menuElements.append(UIMenu(options: .displayInline, children: [renameAction])) + } + + var editDuplicateActions = [UIMenuElement]() + if canEditPalette { + let editAction = UIAction(title: localizedString("shared_string_edit"), image: .icCustomAppearanceOutlined) { [weak self] _ in + self?.editPaletteItem(paletteItem) + } + editDuplicateActions.append(editAction) + } + + let duplicateAction = UIAction(title: localizedString("shared_string_duplicate"), image: .icCustomCopy) { [weak self] _ in + self?.duplicatePaletteItem(paletteItem) + } + editDuplicateActions.append(duplicateAction) + menuElements.append(UIMenu(options: .displayInline, children: editDuplicateActions)) + + if !paletteItem.isDefault { + let deleteAction = UIAction(title: localizedString("shared_string_delete"), image: .icCustomTrashOutlined, attributes: .destructive) { [weak self] _ in + self?.showDeletePaletteAlert(for: paletteItem) + } + menuElements.append(UIMenu(options: .displayInline, children: [deleteAction])) + } + + return UIMenu(children: menuElements) } - - private static func createGradientPoints(_ colorPalette: OsmAndShared.ColorPalette) -> [GradientPoint] { - let colorValues = colorPalette.colors.compactMap { $0 as? OsmAndShared.ColorPalette.ColorValue } - guard !colorValues.isEmpty else { return [] } - if colorValues.count == 1 { - let color = UIColor(argb: Int(colorValues[0].clr)) - return [GradientPoint(location: 0, color: color), GradientPoint(location: 1, color: color)] + + private func showRenamePaletteAlert(for paletteItem: PaletteItemGradient) { + guard let hostViewController else { return } + let alert = UIAlertController(title: localizedString("shared_string_rename"), message: localizedString("enter_new_name"), preferredStyle: .alert) + alert.addTextField { $0.text = paletteItem.displayName } + let applyAction = UIAlertAction(title: localizedString("shared_string_apply"), style: .default) { [weak self, weak alert] _ in + guard let self, let newName = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines) else { return } + guard !newName.isEmpty else { + if let view = self.hostViewController?.view { + OAUtilities.showToast(localizedString("empty_name"), details: nil, duration: 4, in: view) + } + return + } + guard let renamedItem = GradientPaletteHelper.shared.renamePaletteItem(paletteItem, newName: newName) else { return } + self.notifyPaletteReplaced(paletteItem, with: renamedItem) } - - var gradientPoints = [GradientPoint]() - let step = 1.0 / CGFloat(colorValues.count - 1) - for (index, colorValue) in colorValues.enumerated() { - gradientPoints.append(GradientPoint(location: CGFloat(index) * step, color: UIColor(argb: Int(colorValue.clr)))) + + alert.addAction(applyAction) + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + alert.preferredAction = applyAction + hostViewController.present(alert, animated: true) + } + + private func editPaletteItem(_ paletteItem: PaletteItemGradient) { + guard let hostViewController else { return } + GradientPaletteHelper.shared.showEditPaletteEditor(from: hostViewController, paletteItem: paletteItem) { [weak self] editedItem in + self?.notifyPaletteReplaced(paletteItem, with: editedItem) } - - return gradientPoints + } + + private func duplicatePaletteItem(_ paletteItem: PaletteItemGradient) { + guard GradientPaletteHelper.shared.duplicatePaletteItem(paletteItem) != nil else { return } + hostViewController?.reloadData?() + } + + private func showDeletePaletteAlert(for paletteItem: PaletteItemGradient) { + guard let hostViewController else { return } + let alert = UIAlertController(title: "\(localizedString("delete_palette"))?", message: String(format: localizedString("delete_colors_palette_dialog_summary"), paletteItem.displayName), preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: localizedString("shared_string_delete"), style: .destructive) { [weak self] _ in + guard let self, GradientPaletteHelper.shared.deletePaletteItem(paletteItem) else { return } + self.hostViewController?.reloadData?() + }) + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + if let indexPath = indexPath(for: paletteItem), let cell = getCollectionView()?.cellForItem(at: indexPath) { + alert.popoverPresentationController?.sourceView = cell + alert.popoverPresentationController?.sourceRect = cell.bounds + } + + hostViewController.present(alert, animated: true) + } + + private func notifyPaletteReplaced(_ oldItem: PaletteItemGradient, with newItem: PaletteItemGradient) { + guard let hostViewController else { return } + if indexPath(for: oldItem) == selectedIndexPath { + hostViewController.selectPaletteItem?(newItem) + } + + hostViewController.reloadData?() } private func indexPath(for item: PaletteItemGradient?) -> IndexPath? { diff --git a/Sources/Controllers/DashboardOnMap/MapSettings/MapSettingsBuildings3DParametersViewController.swift b/Sources/Controllers/DashboardOnMap/MapSettings/MapSettingsBuildings3DParametersViewController.swift index abfa3f80fd..02e397ad65 100644 --- a/Sources/Controllers/DashboardOnMap/MapSettings/MapSettingsBuildings3DParametersViewController.swift +++ b/Sources/Controllers/DashboardOnMap/MapSettings/MapSettingsBuildings3DParametersViewController.swift @@ -682,7 +682,7 @@ extension MapSettingsBuildings3DParametersViewController: UITableViewDelegate { colorCollectionViewController.hostColorHandler = colorHandler } - navigationController?.pushViewController(colorCollectionViewController, animated: true) + showMediumToLargeSheetViewController(colorCollectionViewController) } } } diff --git a/Sources/Controllers/DashboardOnMap/MapSettings/OAMapSettingsTerrainParametersViewController.mm b/Sources/Controllers/DashboardOnMap/MapSettings/OAMapSettingsTerrainParametersViewController.mm index 93dff64722..cca3c1a554 100644 --- a/Sources/Controllers/DashboardOnMap/MapSettings/OAMapSettingsTerrainParametersViewController.mm +++ b/Sources/Controllers/DashboardOnMap/MapSettings/OAMapSettingsTerrainParametersViewController.mm @@ -1161,7 +1161,7 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath } colorCollectionViewController.delegate = self; - [self.navigationController pushViewController:colorCollectionViewController animated:YES]; + [self showMediumToLargeSheetViewController:colorCollectionViewController]; } } diff --git a/Sources/Controllers/ItemsCollections/GradientEditorViewController.swift b/Sources/Controllers/ItemsCollections/GradientEditorViewController.swift index 7ccf9f84ac..c23d7601c2 100644 --- a/Sources/Controllers/ItemsCollections/GradientEditorViewController.swift +++ b/Sources/Controllers/ItemsCollections/GradientEditorViewController.swift @@ -37,6 +37,13 @@ final class GradientEditorViewController: OABaseNavbarViewController { private let initialDraft: GradientDraft private let editorBehaviour: GradientEditorBehaviour + private lazy var valueInputToolbar: UIToolbar = { + let toolbar = UIToolbar() + toolbar.sizeToFit() + toolbar.items = [UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(onValueInputDonePressed))] + return toolbar + }() + private var dataState: EditorDataState private var sortedColorItems = [PaletteItemSolid]() private var selectedColorItem: PaletteItemSolid? @@ -50,13 +57,6 @@ final class GradientEditorViewController: OABaseNavbarViewController { dataState.selectedIndex == dataState.draft.points.count } - private lazy var valueInputToolbar: UIToolbar = { - let toolbar = UIToolbar() - toolbar.sizeToFit() - toolbar.items = [UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(onValueInputDonePressed))] - return toolbar - }() - init(originalId: String? = nil, fileType: GradientFileType, onSave: @escaping (GradientDraft, String?) -> Bool) { self.originalId = originalId self.fileType = fileType @@ -78,6 +78,12 @@ final class GradientEditorViewController: OABaseNavbarViewController { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidLoad() { + super.viewDidLoad() + updateSelectedColorItem() + tableView.keyboardDismissMode = .onDrag + } override func getTitle() -> String { localizedString(originalId == nil ? "add_palette" : "edit_palette") @@ -95,19 +101,13 @@ final class GradientEditorViewController: OABaseNavbarViewController { override func systemRightBarButtonItems() -> [UIBarButtonItem]? { [UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(onDonePressed)), - UIBarButtonItem(barButtonSystemItem: .undo, target: self, action: #selector(onUndoPressed))] + UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.backward"), style: .plain, target: self, action: #selector(onUndoPressed))] } override func tableStyle() -> UITableView.Style { .insetGrouped } - override func viewDidLoad() { - super.viewDidLoad() - updateSelectedColorItem() - tableView.keyboardDismissMode = .onDrag - } - override func registerCells() { addCell(GradientChartCell.reuseIdentifier) addCell(OAFoldersCell.reuseIdentifier) @@ -207,6 +207,7 @@ final class GradientEditorViewController: OABaseNavbarViewController { cell.descriptionVisibility(false) cell.clearButtonVisibility(false) cell.inputFieldVisibility(true) + cell.inputField.clearButtonMode = .whileEditing cell.inputField.textAlignment = .left cell.inputField.keyboardType = .numbersAndPunctuation cell.inputField.isEnabled = isEditable @@ -271,6 +272,8 @@ final class GradientEditorViewController: OABaseNavbarViewController { } else if item.cellType == OASearchMoreCell.reuseIdentifier { let cell = tableView.dequeueReusableCell(withIdentifier: OASearchMoreCell.reuseIdentifier, for: indexPath) as! OASearchMoreCell cell.selectionStyle = editorBehaviour.isRemoveEnabled(dataState.draft, selectedIndex: dataState.selectedIndex) ? .default : .none + cell.contentView.backgroundColor = .clear + cell.textView.backgroundColor = .clear cell.textView.font = UIFont.preferredFont(forTextStyle: .body) cell.textView.textColor = .textColorDisruptive cell.textView.text = item.title @@ -289,7 +292,7 @@ final class GradientEditorViewController: OABaseNavbarViewController { if let colorsCollectionIndexPath, let cell = tableView.cellForRow(at: colorsCollectionIndexPath) as? OACollectionSingleLineTableViewCell, let handler = cell.getCollectionHandler() as? OAColorCollectionHandler { colorCollectionVC.hostColorHandler = handler } - navigationController?.pushViewController(colorCollectionVC, animated: true) + showMediumToLargeSheetViewController(colorCollectionVC) } else if item.key == GradientEditorRow.removeStep.rawValue, let state = GradientEditorAlgorithms.removeStep(dataState, behaviour: editorBehaviour) { updateSelectedColorItem(for: state) @@ -429,7 +432,7 @@ final class GradientEditorViewController: OABaseNavbarViewController { let alert = UIAlertController(title: localizedString("access_hint_enter_name"), message: nil, preferredStyle: .alert) let suggestedName = GradientPaletteHelper.shared.suggestedPaletteName(for: dataState.draft) alert.addTextField { $0.text = suggestedName } - alert.addAction(UIAlertAction(title: localizedString("shared_string_save"), style: .default) { [weak self, weak alert] _ in + let saveAction = UIAlertAction(title: localizedString("shared_string_save"), style: .default) { [weak self, weak alert] _ in guard let self, let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines) else { return } guard !name.isEmpty else { OAUtilities.showToast(localizedString("empty_name"), details: nil, duration: 4, in: self.view) @@ -440,8 +443,10 @@ final class GradientEditorViewController: OABaseNavbarViewController { } else { OAUtilities.showToast(localizedString("gpx_already_exsists"), details: nil, duration: 4, in: self.view) } - }) + } + alert.addAction(saveAction) alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + alert.preferredAction = saveAction present(alert, animated: true) } } diff --git a/Sources/Controllers/ItemsCollections/ItemsCollectionViewController.swift b/Sources/Controllers/ItemsCollections/ItemsCollectionViewController.swift index 48c8f4a2e8..ac95cace25 100644 --- a/Sources/Controllers/ItemsCollections/ItemsCollectionViewController.swift +++ b/Sources/Controllers/ItemsCollections/ItemsCollectionViewController.swift @@ -39,40 +39,34 @@ import UIKit @objcMembers final class ItemsCollectionViewController: OABaseNavbarViewController { - - private let iconNamesKey = "iconNamesKey" - private let poiCategoryNameKey = "poiCategoryNameKey" - private let chipsTitlesKey = "chipsTitlesKey" - private let chipsSelectedIndexKey = "chipsSelectedIndexKey" - - private let poiTypeNoIconValue = "ic_action_categories_search" - - weak var delegate: ColorCollectionViewControllerDelegate? - weak var iconsDelegate: IconsCollectionViewControllerDelegate? - weak var hostColorHandler: OAColorCollectionHandler? - var customTitle: String = "" var selectedIconColor: UIColor? var regularIconColor: UIColor? - var iconImages = [UIImage]() var iconCategories = [IconsAppearanceCategory]() + + weak var delegate: ColorCollectionViewControllerDelegate? + weak var iconsDelegate: IconsCollectionViewControllerDelegate? + weak var hostColorHandler: OAColorCollectionHandler? + + private let iconNamesKey = "iconNamesKey" + private let poiCategoryNameKey = "poiCategoryNameKey" + private let chipsTitlesKey = "chipsTitlesKey" + private let chipsSelectedIndexKey = "chipsSelectedIndexKey" + private let poiTypeNoIconValue = "ic_action_categories_search" + private var iconItems = [String]() private var selectedIconItem: String? private var baseIconHandlers = [IndexPath: BaseAppearanceIconCollectionHandler]() - private var chipsCell: OAFoldersCell? private var chipsCellScrollState: OACollectionViewCellState? private var selectedChipsIndex = 0 - private var settings: OAAppSettings private var data: OATableDataModel - private var searchController: UISearchController? private var lastSearchResults = [OAPOIType]() private var inSearchMode = false private var searchCancelled = false - private var collectionType: ColorCollectionType private var selectedPaletteItem: PaletteItemGradient? private var paletteItems: OAConcurrentArray? @@ -99,7 +93,6 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { } init(collectionType: ColorCollectionType, items: Any, selectedItem: Any) { - self.collectionType = collectionType settings = OAAppSettings.sharedManager() data = OATableDataModel() @@ -137,28 +130,6 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { fatalError("init(coder:) has not been implemented") } - override func tableStyle() -> UITableView.Style { - .insetGrouped - } - - override func registerCells() { - switch collectionType { - case .colorItems, .iconItems, .bigIconItems, .poiIconCategories, .profileIconCategories, .baseAppearanceCategories: - tableView.register(UINib(nibName: OACollectionSingleLineTableViewCell.reuseIdentifier, bundle: nil), - forCellReuseIdentifier: OACollectionSingleLineTableViewCell.reuseIdentifier) - tableView.register(UINib(nibName: OASimpleTableViewCell.reuseIdentifier, bundle: nil), - forCellReuseIdentifier: OASimpleTableViewCell.reuseIdentifier) - tableView.register(UINib(nibName: OADividerCell.reuseIdentifier, bundle: nil), - forCellReuseIdentifier: OADividerCell.reuseIdentifier) - tableView.register(UINib(nibName: OAFoldersCell.reuseIdentifier, bundle: nil), - forCellReuseIdentifier: OAFoldersCell.reuseIdentifier) - - case .colorizationPaletteItems, .terrainPaletteItems: - tableView.register(UINib(nibName: OATwoIconsButtonTableViewCell.reuseIdentifier, bundle: nil), - forCellReuseIdentifier: OATwoIconsButtonTableViewCell.reuseIdentifier) - } - } - // MARK: - UIViewController override func viewDidLoad() { @@ -166,7 +137,9 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { tableView.backgroundColor = collectionType == .colorItems ? .groupBg : .viewBg tableView.keyboardDismissMode = .onDrag - tableView.separatorStyle = .none + if collectionType != .colorizationPaletteItems && collectionType != .terrainPaletteItems { + tableView.separatorStyle = .none + } chipsCellScrollState = OACollectionViewCellState() tableView.reloadData() @@ -190,7 +163,28 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { } // MARK: - Base UI - + + override func tableStyle() -> UITableView.Style { + .insetGrouped + } + + override func registerCells() { + switch collectionType { + case .colorItems, .iconItems, .bigIconItems, .poiIconCategories, .profileIconCategories, .baseAppearanceCategories: + tableView.register(UINib(nibName: OACollectionSingleLineTableViewCell.reuseIdentifier, bundle: nil), + forCellReuseIdentifier: OACollectionSingleLineTableViewCell.reuseIdentifier) + tableView.register(UINib(nibName: OASimpleTableViewCell.reuseIdentifier, bundle: nil), + forCellReuseIdentifier: OASimpleTableViewCell.reuseIdentifier) + tableView.register(UINib(nibName: OADividerCell.reuseIdentifier, bundle: nil), + forCellReuseIdentifier: OADividerCell.reuseIdentifier) + tableView.register(UINib(nibName: OAFoldersCell.reuseIdentifier, bundle: nil), + forCellReuseIdentifier: OAFoldersCell.reuseIdentifier) + case .colorizationPaletteItems, .terrainPaletteItems: + tableView.register(UINib(nibName: OATwoIconsButtonTableViewCell.reuseIdentifier, bundle: nil), + forCellReuseIdentifier: OATwoIconsButtonTableViewCell.reuseIdentifier) + } + } + override func getTitle() -> String { switch collectionType { case .colorItems, .colorizationPaletteItems, .terrainPaletteItems: @@ -203,7 +197,7 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { } override func getLeftNavbarButtonTitle() -> String { - localizedString("shared_string_cancel") + localizedString("shared_string_close") } override func getRightNavbarButtons() -> [UIBarButtonItem] { @@ -297,16 +291,7 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { override func getCustomHeight(forHeader section: Int) -> CGFloat { iconsDelegate is BaseAppearanceIconCollectionHandler && section == 0 ? 14 : super.getCustomHeight(forHeader: section) } - - private func generateRowData(for paletteItem: PaletteItemGradient) -> OATableRowData { - let paletteColorRow = OATableRowData() - paletteColorRow.cellType = OATwoIconsButtonTableViewCell.reuseIdentifier - paletteColorRow.key = "paletteColor" - paletteColorRow.title = paletteItem.displayName - paletteColorRow.setObj(paletteItem, forKey: "palette") - return paletteColorRow - } - + override func getRow(_ indexPath: IndexPath) -> UITableViewCell { let item = data.item(for: indexPath) @@ -329,29 +314,24 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { cell.contentView.layer.cornerRadius = 32 cell.contentView.layer.masksToBounds = true cell.backgroundColor = .clear - cell.rightActionButtonVisibility(false) cell.collectionView.reloadData() cell.layoutIfNeeded() return cell } } else if item.cellType == OATwoIconsButtonTableViewCell.reuseIdentifier { - if let cell = tableView.dequeueReusableCell(withIdentifier: OATwoIconsButtonTableViewCell.reuseIdentifier, for: indexPath) as? OATwoIconsButtonTableViewCell { - if let palette = item.obj(forKey: "palette") as? PaletteItemGradient { cell.titleLabel.text = item.title cell.descriptionLabel.text = PaletteCollectionHandler.createDescriptionForPalette(palette) cell.descriptionLabel.numberOfLines = 1 PaletteCollectionHandler.applyGradient(to: cell.secondLeftIconView, with: palette.getColorPalette()) - cell.secondLeftIconView.layer.cornerRadius = 3 cell.leftIconView.image = palette.id == selectedPaletteItem?.id ? UIImage(named: "ic_checkmark_default") : nil cell.button.setTitle(nil, for: .normal) cell.button.setImage(UIImage(named: "ic_navbar_overflow_menu_outlined")?.withRenderingMode(.alwaysTemplate), for: .normal) cell.button.menu = createPaletteMenu(for: indexPath) cell.button.showsMenuAsPrimaryAction = true - return cell } } @@ -390,13 +370,103 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { } cell.collectionView.contentInset = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 20) cell.collectionView.reloadData() - return cell } return UITableViewCell() } + + override func onRowSelected(_ indexPath: IndexPath) { + super.onRowSelected(indexPath) + let item = data.item(for: indexPath) + if item.key == "paletteColor" { + if let palette = item.obj(forKey: "palette") as? PaletteItemGradient { + selectedPaletteItem = palette + delegate?.selectPaletteItem?(palette) + delegate?.reloadData?() + } + dismissWith(animated: true) + } else if (collectionType == .poiIconCategories || collectionType == .profileIconCategories || collectionType == .baseAppearanceCategories) && inSearchMode { + if let searchIconName = item.iconName, + let poiIconsDelegate = iconsDelegate as? BaseAppearanceIconCollectionHandler { + selectedIconItem = searchIconName + poiIconsDelegate.setIconName(searchIconName) + poiIconsDelegate.selectIconName(searchIconName) + poiIconsDelegate.allIconsVCDelegate = nil + } + searchController?.dismiss(animated: true) + dismiss(animated: true) + } + } + + override func sectionsCount() -> Int { + Int(data.sectionCount()) + } + + override func rowsCount(_ section: Int) -> Int { + Int(data.rowCount(UInt(section))) + } + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let item = data.item(for: indexPath) + if item.cellType == OADividerCell.reuseIdentifier { + return 1.0 / UIScreen.main.scale + } else if item.obj(forKey: chipsTitlesKey) is [[String: String]] { + return 52 + } + return UITableView.automaticDimension + } + + override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + let item = data.item(for: indexPath) + if item.obj(forKey: chipsTitlesKey) is [[String: String]] { + return ChipsCollectionHandler.folderCellHeight + } + if let iconsHandler = iconsDelegate as? IconCollectionHandler { + return iconsHandler.getItemSize().height + } else if let colorCollectionHandler { + return colorCollectionHandler.getItemSize().height + } + return UITableView.automaticDimension + } + + // MARK: - Selectors + + override func onRightNavbarButtonPressed() { + switch collectionType { + case .colorItems: + isStartedNewColorAdding = true + if let selectedColorItem { + openColorPicker(with: selectedColorItem) + } + case .colorizationPaletteItems, .terrainPaletteItems: + GradientPaletteHelper.shared.showAddPaletteEditor(from: self, paletteCategory: paletteCategory, sourceView: navigationItem.rightBarButtonItem?.customView) + default: + break + } + } + + func applyPaletteEditorResult(_ paletteItem: PaletteItemGradient, replacing originalId: String?) { + guard let paletteItems else { return } + paletteItems.replaceAll(withObjectsSync: GradientPaletteHelper.shared.paletteItems(category: paletteItem.properties.fileType.category, sortMode: .lastUsedTime)) + if originalId == nil || selectedPaletteItem?.id == originalId { + selectedPaletteItem = paletteItem + delegate?.selectPaletteItem?(paletteItem) + } + + delegate?.reloadData?() + reloadData() + } + private func generateRowData(for paletteItem: PaletteItemGradient) -> OATableRowData { + let paletteColorRow = OATableRowData() + paletteColorRow.cellType = OATwoIconsButtonTableViewCell.reuseIdentifier + paletteColorRow.key = "paletteColor" + paletteColorRow.title = paletteItem.displayName + paletteColorRow.setObj(paletteItem, forKey: "palette") + return paletteColorRow + } + private func setupColorCollectionCell(_ cell: OACollectionSingleLineTableViewCell) { let data = (hostColorHandler?.getData() as? [[PaletteItemSolid]]) ?? [colorItems] if let items = data.first { @@ -448,7 +518,6 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { iconHandler.setIconBackgroundSize(size: 36) iconHandler.setIconSize(size: 24) iconHandler.setSpacing(spacing: 10) - iconHandler.roundedSquareCells = false iconHandler.innerViewCornerRadius = -1 if let poiCategoryKey { @@ -512,60 +581,7 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { handler.titles = chipsTitles handler.setSelectedIndexPath(IndexPath(row: selectedIndex, section: 0)) } - - override func onRowSelected(_ indexPath: IndexPath) { - super.onRowSelected(indexPath) - let item = data.item(for: indexPath) - if item.key == "paletteColor" { - if let palette = item.obj(forKey: "palette") as? PaletteItemGradient { - selectedPaletteItem = palette - delegate?.selectPaletteItem?(palette) - } - dismissWith(animated: true) - } else if (collectionType == .poiIconCategories || collectionType == .profileIconCategories || collectionType == .baseAppearanceCategories) && inSearchMode { - if let searchIconName = item.iconName, - let poiIconsDelegate = iconsDelegate as? BaseAppearanceIconCollectionHandler { - selectedIconItem = searchIconName - poiIconsDelegate.setIconName(searchIconName) - poiIconsDelegate.selectIconName(searchIconName) - poiIconsDelegate.allIconsVCDelegate = nil - } - searchController?.dismiss(animated: true) - dismiss(animated: true) - } - } - - override func sectionsCount() -> Int { - Int(data.sectionCount()) - } - - override func rowsCount(_ section: Int) -> Int { - Int(data.rowCount(UInt(section))) - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let item = data.item(for: indexPath) - if item.cellType == OADividerCell.reuseIdentifier { - return 1.0 / UIScreen.main.scale - } else if item.obj(forKey: chipsTitlesKey) is [[String: String]] { - return 52 - } - return UITableView.automaticDimension - } - - override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - let item = data.item(for: indexPath) - if item.obj(forKey: chipsTitlesKey) is [[String: String]] { - return ChipsCollectionHandler.folderCellHeight - } - if let iconsHandler = iconsDelegate as? IconCollectionHandler { - return iconsHandler.getItemSize().height - } else if let colorCollectionHandler { - return colorCollectionHandler.getItemSize().height - } - return UITableView.automaticDimension - } - + // MARK: - Additions private func openColorPicker(with colorItem: PaletteItemSolid) { @@ -623,23 +639,7 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { return UIMenu(children: menuElements) } - - // MARK: - Selectors - - override func onRightNavbarButtonPressed() { - switch collectionType { - case .colorItems: - isStartedNewColorAdding = true - if let selectedColorItem { - openColorPicker(with: selectedColorItem) - } - case .colorizationPaletteItems, .terrainPaletteItems: - GradientPaletteHelper.shared.showAddPaletteEditor(from: self, paletteCategory: paletteCategory, sourceView: navigationItem.rightBarButtonItem?.customView) - default: - break - } - } - + // MARK: - Search private func setupSearch() { @@ -726,7 +726,7 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { alert.addTextField { textField in textField.text = paletteItem.displayName } - alert.addAction(UIAlertAction(title: localizedString("shared_string_apply"), style: .default) { [weak self, weak alert] _ in + let applyAction = UIAlertAction(title: localizedString("shared_string_apply"), style: .default) { [weak self, weak alert] _ in guard let self, let newName = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines) else { return } guard !newName.isEmpty else { OAUtilities.showToast(localizedString("empty_name"), details: nil, duration: 4, in: self.view) @@ -734,8 +734,10 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { } guard let indexPath = self.indexPath(for: paletteItem), let renamedPaletteItem = GradientPaletteHelper.shared.renamePaletteItem(paletteItem, newName: newName) else { return } self.renameItem(fromContextMenu: indexPath, oldItem: paletteItem, newItem: renamedPaletteItem) - }) + } + alert.addAction(applyAction) alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + alert.preferredAction = applyAction present(alert, animated: true) } @@ -752,19 +754,6 @@ final class ItemsCollectionViewController: OABaseNavbarViewController { } present(alert, animated: true) } - - func applyPaletteEditorResult(_ paletteItem: PaletteItemGradient, replacing originalId: String?) { - guard let paletteItems else { return } - paletteItems.replaceAll(withObjectsSync: GradientPaletteHelper.shared.paletteItems(category: paletteItem.properties.fileType.category, sortMode: .lastUsedTime)) - if originalId == nil || selectedPaletteItem?.id == originalId { - selectedPaletteItem = paletteItem - delegate?.selectPaletteItem?(paletteItem) - } else { - delegate?.reloadData?() - } - - reloadData() - } } extension ItemsCollectionViewController: UISearchBarDelegate { @@ -848,10 +837,9 @@ extension ItemsCollectionViewController: OAColorsCollectionCellDelegate { if selectedPaletteItem?.id == oldItem.id { selectedPaletteItem = newItem delegate?.selectPaletteItem?(newItem) - } else { - delegate?.reloadData?() } - + + delegate?.reloadData?() tableView.reloadRows(at: [indexPath], with: .automatic) } diff --git a/Sources/Controllers/MyPlaces/TracksChangeAppearanceViewController.swift b/Sources/Controllers/MyPlaces/TracksChangeAppearanceViewController.swift index de09878584..25e1f79bc2 100644 --- a/Sources/Controllers/MyPlaces/TracksChangeAppearanceViewController.swift +++ b/Sources/Controllers/MyPlaces/TracksChangeAppearanceViewController.swift @@ -436,13 +436,13 @@ final class TracksChangeAppearanceViewController: OABaseNavbarViewController { if let colorsCollectionIndexPath, let colorCell = tableView.cellForRow(at: colorsCollectionIndexPath) as? OACollectionSingleLineTableViewCell, let colorHandler = colorCell.getCollectionHandler() as? OAColorCollectionHandler { colorCollectionVC.hostColorHandler = colorHandler } - navigationController?.pushViewController(colorCollectionVC, animated: true) + showMediumToLargeSheetViewController(colorCollectionVC) } else if isGradientColorSelected { if let paletteColorItem = selectedPaletteColorItem { let paletteItems = sortedPaletteColorItems.asArray().compactMap { $0 as? PaletteItemGradient } let colorCollectionVC = ItemsCollectionViewController(collectionType: .colorizationPaletteItems, items: paletteItems, selectedItem: paletteColorItem) colorCollectionVC.delegate = self - navigationController?.pushViewController(colorCollectionVC, animated: true) + showMediumToLargeSheetViewController(colorCollectionVC) } } } else if item.key == RowKey.applyExistingTracksRowKey.rawValue { diff --git a/Sources/Controllers/RouteInfoMenu/OARouteLineAppearanceHudViewController.mm b/Sources/Controllers/RouteInfoMenu/OARouteLineAppearanceHudViewController.mm index dfc9842a87..087ad16f64 100644 --- a/Sources/Controllers/RouteInfoMenu/OARouteLineAppearanceHudViewController.mm +++ b/Sources/Controllers/RouteInfoMenu/OARouteLineAppearanceHudViewController.mm @@ -1362,7 +1362,7 @@ - (void)onButtonPressed:(OAGPXBaseTableData *)tableData if (vc) { vc.delegate = self; - [self.navigationController pushViewController:vc animated:YES]; + [self showMediumToLargeSheetViewController:vc]; } } } diff --git a/Sources/Controllers/TargetMenu/GPX/TrackMenu/OATrackMenuAppearanceHudViewController.mm b/Sources/Controllers/TargetMenu/GPX/TrackMenu/OATrackMenuAppearanceHudViewController.mm index ef48d34014..56f7ebf97e 100644 --- a/Sources/Controllers/TargetMenu/GPX/TrackMenu/OATrackMenuAppearanceHudViewController.mm +++ b/Sources/Controllers/TargetMenu/GPX/TrackMenu/OATrackMenuAppearanceHudViewController.mm @@ -2537,7 +2537,7 @@ - (void)onButtonPressed:(OAGPXBaseTableData *)tableData if (colorCollectionViewController) { colorCollectionViewController.delegate = self; - [self.navigationController pushViewController:colorCollectionViewController animated:YES]; + [self showMediumToLargeSheetViewController:colorCollectionViewController]; } } else if ([tableData.key isEqualToString:@"vertical_exaggeration"]) diff --git a/Sources/Helpers/Appearance/GradientPaletteHelper.swift b/Sources/Helpers/Appearance/GradientPaletteHelper.swift index dddb3fc488..e04a2ed673 100644 --- a/Sources/Helpers/Appearance/GradientPaletteHelper.swift +++ b/Sources/Helpers/Appearance/GradientPaletteHelper.swift @@ -155,7 +155,8 @@ final class GradientPaletteHelper: NSObject { self.openGradientEditor(from: viewController, fileType: fileType) }) } - + + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) if let popoverPresentationController = alert.popoverPresentationController { popoverPresentationController.sourceView = sourceView ?? viewController.view popoverPresentationController.sourceRect = sourceView?.bounds ?? viewController.view.bounds @@ -167,14 +168,14 @@ final class GradientPaletteHelper: NSObject { } } - func showEditPaletteEditor(from viewController: UIViewController, paletteItem: PaletteItemGradient) { + func showEditPaletteEditor(from viewController: UIViewController, paletteItem: PaletteItemGradient, onSave: ((PaletteItemGradient) -> Void)? = nil) { if !OAIAPHelper.isOsmAndProAvailable() { guard let navigationController = OARootViewController.instance().navigationController else { return } OAChoosePlanHelper.showChoosePlanScreen(with: OAFeature.advanced_WIDGETS(), navController: navigationController) return } - openGradientEditor(from: viewController, originalId: paletteItem.id, fileType: paletteItem.properties.fileType) + openGradientEditor(from: viewController, originalId: paletteItem.id, fileType: paletteItem.properties.fileType, onSave: onSave) } private func updateExternalDependenciesIfNeeded(category: GradientPaletteCategory) { @@ -232,10 +233,15 @@ final class GradientPaletteHelper: NSObject { return (fileType.category, paletteName) } - private func openGradientEditor(from viewController: UIViewController, originalId: String? = nil, fileType: GradientFileType) { + private func openGradientEditor(from viewController: UIViewController, originalId: String? = nil, fileType: GradientFileType, onSave: ((PaletteItemGradient) -> Void)? = nil) { let editor = GradientEditorViewController(originalId: originalId, fileType: fileType) { [weak self, weak viewController] draft, newName in guard let self, let paletteItem = self.applyGradientEdits(draft, newName: newName) else { return false } - self.applyPaletteEditorResult(paletteItem, replacing: draft.originalId, from: viewController) + if let onSave { + onSave(paletteItem) + } else { + self.applyPaletteEditorResult(paletteItem, replacing: draft.originalId, from: viewController) + } + return true } diff --git a/Sources/SwiftExtensions/UIViewController+Extension.swift b/Sources/SwiftExtensions/UIViewController+Extension.swift index 9dcd8e2547..f87646d2b8 100644 --- a/Sources/SwiftExtensions/UIViewController+Extension.swift +++ b/Sources/SwiftExtensions/UIViewController+Extension.swift @@ -42,6 +42,20 @@ extension UIViewController { present(navigationController, animated: true, completion: nil) } + + @objc func showMediumToLargeSheetViewController(_ viewController: UIViewController) { + let sheetNavigationController = UINavigationController(rootViewController: viewController) + sheetNavigationController.modalPresentationStyle = .pageSheet + if let sheet = sheetNavigationController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.selectedDetentIdentifier = .medium + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 20 + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + } + + (navigationController ?? self).present(sheetNavigationController, animated: true) + } } extension UIViewController { From 6db5ac4578b51d10a86d0fc8a8561fcaa234b8ef Mon Sep 17 00:00:00 2001 From: alex-dev Date: Tue, 23 Jun 2026 21:16:55 +0300 Subject: [PATCH 36/47] Drop redeclaration --- .../Astronomy/contextmenu/AstroScheduleCardViewHolder.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardViewHolder.swift b/Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardViewHolder.swift index 970bb4a20e..4aa39883de 100644 --- a/Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardViewHolder.swift +++ b/Sources/Plugins/Astronomy/contextmenu/AstroScheduleCardViewHolder.swift @@ -329,9 +329,3 @@ enum AstroScheduleCardViewHolder { dayOffset > 0 ? "+\(dayOffset)" : nil } } - -private extension Array { - subscript(safe index: Int) -> Element? { - indices.contains(index) ? self[index] : nil - } -} From 7e439a3e8f9fd975423170912d9c2e1f5575989c Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Wed, 24 Jun 2026 09:10:51 +0200 Subject: [PATCH 37/47] [WIP] Route between points fixes --- .../Icons/PlanRoute/Contents.json | 6 + .../Contents.json | 15 + .../ic_custom_straight_line.svg | 3 + .../Contents.json | 15 + .../map_ruler_center_day.svg | 3 + .../en.lproj/Localizable.strings | 11 + .../Map/Layers/OAMeasurementToolLayer.h | 1 + .../Map/Layers/OAMeasurementToolLayer.mm | 29 +- .../PlanRoute/OAPlanRouteEditingBridge.h | 19 + .../PlanRoute/OAPlanRouteEditingBridge.mm | 174 ++++- .../PlanRoute/PlanRouteCrosshairView.swift | 1 + .../PlanRouteEditingContextDataProvider.swift | 44 +- .../PlanRoute/PlanRouteModels.swift | 95 ++- .../PlanRouteScrollableViewController.swift | 162 +++- .../PlanRoute/PlanRouteStubDataProvider.swift | 104 --- .../RouteBetweenPointsViewController.swift | 329 ++++++++ .../SegmentRouteSettingsViewController.swift | 736 ++++++++++++++++++ .../Tabs/PlanRouteRouteViewController.swift | 47 +- Sources/OsmAnd Maps-Bridging-Header.h | 1 + 19 files changed, 1660 insertions(+), 135 deletions(-) create mode 100644 Resources/Images.xcassets/Icons/PlanRoute/Contents.json create mode 100644 Resources/Images.xcassets/Icons/PlanRoute/ic_custom_straight_line.imageset/Contents.json create mode 100644 Resources/Images.xcassets/Icons/PlanRoute/ic_custom_straight_line.imageset/ic_custom_straight_line.svg create mode 100644 Resources/Images.xcassets/Icons/PlanRoute/map_ruler_center_day.imageset/Contents.json create mode 100644 Resources/Images.xcassets/Icons/PlanRoute/map_ruler_center_day.imageset/map_ruler_center_day.svg create mode 100644 Sources/Controllers/PlanRoute/PlanRouteCrosshairView.swift delete mode 100644 Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift create mode 100644 Sources/Controllers/PlanRoute/RouteBetweenPointsViewController.swift create mode 100644 Sources/Controllers/PlanRoute/SegmentRouteSettingsViewController.swift diff --git a/Resources/Images.xcassets/Icons/PlanRoute/Contents.json b/Resources/Images.xcassets/Icons/PlanRoute/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Resources/Images.xcassets/Icons/PlanRoute/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Icons/PlanRoute/ic_custom_straight_line.imageset/Contents.json b/Resources/Images.xcassets/Icons/PlanRoute/ic_custom_straight_line.imageset/Contents.json new file mode 100644 index 0000000000..cd96438ac4 --- /dev/null +++ b/Resources/Images.xcassets/Icons/PlanRoute/ic_custom_straight_line.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_custom_straight_line.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Resources/Images.xcassets/Icons/PlanRoute/ic_custom_straight_line.imageset/ic_custom_straight_line.svg b/Resources/Images.xcassets/Icons/PlanRoute/ic_custom_straight_line.imageset/ic_custom_straight_line.svg new file mode 100644 index 0000000000..d088edbedb --- /dev/null +++ b/Resources/Images.xcassets/Icons/PlanRoute/ic_custom_straight_line.imageset/ic_custom_straight_line.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/Images.xcassets/Icons/PlanRoute/map_ruler_center_day.imageset/Contents.json b/Resources/Images.xcassets/Icons/PlanRoute/map_ruler_center_day.imageset/Contents.json new file mode 100644 index 0000000000..cbe5d00335 --- /dev/null +++ b/Resources/Images.xcassets/Icons/PlanRoute/map_ruler_center_day.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "map_ruler_center_day.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Resources/Images.xcassets/Icons/PlanRoute/map_ruler_center_day.imageset/map_ruler_center_day.svg b/Resources/Images.xcassets/Icons/PlanRoute/map_ruler_center_day.imageset/map_ruler_center_day.svg new file mode 100644 index 0000000000..cb7acb2e6f --- /dev/null +++ b/Resources/Images.xcassets/Icons/PlanRoute/map_ruler_center_day.imageset/map_ruler_center_day.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 4a97295102..83464fb689 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -1692,6 +1692,17 @@ "change_folder" = "Change folder"; "select_folder" = "Select folder"; "select_folder_descr" = "Select folder or add new one"; +"plan_route_select_segment_hint" = "Select segment or section to change Route type and Settings."; +"plan_route_change_for_whole_segment" = "Change for whole segment"; +"plan_route_change_for_whole_track" = "Change for whole track"; +"plan_route_section_recalculate_format" = "%1$@ section of %2$@ will be recalculated using the selected profile."; +"plan_route_segment_recalculate_format" = "%@ will be recalculated using the selected profile."; +"plan_route_start_new_segment_hint" = "New segment continues with the same route type."; +"plan_route_straight_line" = "Straight line"; +"plan_route_use_elevation_data" = "Use elevation data"; +"plan_route_avoid_roads" = "Avoid roads"; +"plan_route_consider_temp_limitations" = "Consider temporary limitations"; +"plan_route_navigation_settings" = "Navigation settings"; "add_folder" = "Add folder"; "add_smart_folder" = "Add smart folder"; "save_as_smart_folder" = "Save as smart folder"; diff --git a/Sources/Controllers/Map/Layers/OAMeasurementToolLayer.h b/Sources/Controllers/Map/Layers/OAMeasurementToolLayer.h index e914e631b6..8974729b83 100644 --- a/Sources/Controllers/Map/Layers/OAMeasurementToolLayer.h +++ b/Sources/Controllers/Map/Layers/OAMeasurementToolLayer.h @@ -24,6 +24,7 @@ @property (nonatomic, weak) id delegate; @property (nonatomic) CLLocation *pressPointLocation; +@property (nonatomic) CGPoint cursorScreenPoint; - (OASWptPt *) addCenterPoint:(BOOL)addPointBefore; - (OASWptPt *) addPoint:(BOOL)addPointBefore; diff --git a/Sources/Controllers/Map/Layers/OAMeasurementToolLayer.mm b/Sources/Controllers/Map/Layers/OAMeasurementToolLayer.mm index b43fe55946..300cec2c60 100644 --- a/Sources/Controllers/Map/Layers/OAMeasurementToolLayer.mm +++ b/Sources/Controllers/Map/Layers/OAMeasurementToolLayer.mm @@ -273,8 +273,20 @@ - (OASWptPt *) addPoint:(BOOL)addPointBefore - (OASWptPt *) addCenterPoint:(BOOL)addPointBefore { - const auto center = [self.mapViewController.mapView getCenterPixel]; - const auto elevated31 = [OANativeUtilities get31FromElevatedPixel:center]; + OsmAnd::PointI elevated31; + if (!CGPointEqualToPoint(_cursorScreenPoint, CGPointZero)) + { + CGFloat scale = self.mapViewController.mapView.contentScaleFactor; + CGPoint scaledPoint = CGPointMake(_cursorScreenPoint.x * scale, _cursorScreenPoint.y * scale); + OsmAnd::PointI location31; + [self.mapViewController.mapView convert:scaledPoint toLocation:&location31]; + elevated31 = location31; + } + else + { + const auto center = [self.mapViewController.mapView getCenterPixel]; + elevated31 = [OANativeUtilities get31FromElevatedPixel:center]; + } const auto latLon = OsmAnd::Utilities::convert31ToLatLon(elevated31); OASWptPt *pt = [[OASWptPt alloc] init]; @@ -400,8 +412,17 @@ - (void) drawBeforeAfterPath NSArray *after = _editingCtx.getAfterSegments; OsmAnd::PointI center; - auto centerPixel = self.mapViewController.mapView.getCenterPixel; - [self.mapViewController.mapView convert:CGPointMake(centerPixel.x, centerPixel.y) toLocation:¢er]; + if (!CGPointEqualToPoint(_cursorScreenPoint, CGPointZero)) + { + CGFloat scale = self.mapViewController.mapView.contentScaleFactor; + CGPoint scaledPoint = CGPointMake(_cursorScreenPoint.x * scale, _cursorScreenPoint.y * scale); + [self.mapViewController.mapView convert:scaledPoint toLocation:¢er]; + } + else + { + auto centerPixel = self.mapViewController.mapView.getCenterPixel; + [self.mapViewController.mapView convert:CGPointMake(centerPixel.x, centerPixel.y) toLocation:¢er]; + } if (center == _cachedCenter) return; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h index e028288745..3fedb3fd58 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -7,6 +7,7 @@ // #import +#import NS_ASSUME_NONNULL_BEGIN @@ -55,12 +56,18 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) BOOL canRedo; @property (nonatomic, readonly) BOOL hasRoute; @property (nonatomic, readonly) double routeDistance; +@property (nonatomic, readonly) double distanceToMapCenter; +@property (nonatomic, readonly) double bearingToMapCenter; +- (void)dismiss; - (void)prepareNewRoute; - (void)openTrackWithFilePath:(NSString *)filePath; - (void)addCenterPoint; +- (void)setCrosshairScreenPoint:(CGPoint)point; - (void)undo; - (void)redo; +- (void)reverseRoute; +- (void)clearAllPoints; - (NSArray *)buildSegments; - (NSArray *)availableModes; @@ -73,6 +80,18 @@ NS_ASSUME_NONNULL_BEGIN - (void)sortSegmentDoorToDoorWithPointIndexes:(NSArray *)indexes; - (void)selectPointAtIndex:(NSInteger)index; +- (void)saveAs:(NSString *)fileName + folder:(nullable NSString *)folder + showOnMap:(BOOL)showOnMap + onComplete:(void (^)(BOOL success, NSString * _Nullable outPath))onComplete; + +- (void)saveAsCopy:(NSString *)fileName + folder:(nullable NSString *)folder + showOnMap:(BOOL)showOnMap + onComplete:(void (^)(BOOL success, NSString * _Nullable outPath))onComplete; + +- (void)enterNavigationWithTrackName:(NSString *)trackName; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm index 60a18ee5d2..bb412ec43d 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -25,6 +25,14 @@ #import "OARemovePointCommand.h" #import "OAReorderPointCommand.h" #import "OAChangeRouteModeCommand.h" +#import "OAReversePointsCommand.h" +#import "OAClearPointsCommand.h" +#import "OAGPXDatabase.h" +#import "OAUtilities.h" +#import "OAAppVersion.h" +#import "OsmAndApp.h" +#import "OAMapActions.h" +#import "OAAppSettings.h" @class OAMeasurementToolLayer, OAMeasurementEditingContext; @@ -60,6 +68,10 @@ - (instancetype)initWithIndex:(NSInteger)index @end @interface OAPlanRouteEditingBridge () +{ + double _distanceToMapCenter; + double _bearingToMapCenter; +} - (OAMeasurementToolLayer *)layer; - (OAMeasurementEditingContext *)editingContext; @@ -71,6 +83,11 @@ - (OAPlanRouteSegmentData *)buildSegmentWithIndex:(NSInteger)segmentIndex - (OAPlanRouteGroupData *)buildGroupWithKey:(NSString *)key indexes:(NSArray *)indexes allPoints:(NSArray *)allPoints; +- (void)performSaveWithFileName:(NSString *)fileName + folder:(nullable NSString *)folder + showOnMap:(BOOL)showOnMap + asCopy:(BOOL)asCopy + onComplete:(void (^)(BOOL success, NSString * _Nullable outPath))onComplete; @end @@ -202,7 +219,9 @@ - (double)routeDistance - (NSArray *)availableModes { - return [OAApplicationMode values]; + return [[OAApplicationMode values] filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(OAApplicationMode *mode, NSDictionary *bindings) { + return mode != [OAApplicationMode DEFAULT]; + }]]; } - (void)addCenterPoint @@ -215,6 +234,25 @@ - (void)addCenterPoint [layer updateLayer]; } +- (void)setCrosshairScreenPoint:(CGPoint)point +{ + OAMeasurementToolLayer *layer = [self layer]; + if (layer == nil) + return; + layer.cursorScreenPoint = point; + [layer updateLayer]; +} + +- (void)dismiss +{ + OAMeasurementToolLayer *layer = [self layer]; + if (layer == nil) + return; + layer.cursorScreenPoint = CGPointZero; + layer.editingCtx = nil; + [layer resetLayer]; +} + - (void)prepareNewRoute { OAMeasurementToolLayer *layer = [self layer]; @@ -480,6 +518,136 @@ - (void)selectPointAtIndex:(NSInteger)index ctx.selectedPointPosition = index; } +- (double)distanceToMapCenter +{ + return _distanceToMapCenter; +} + +- (double)bearingToMapCenter +{ + return _bearingToMapCenter; +} + +- (void)reverseRoute +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil || ctx.getPointsCount < 2) + return; + [ctx.commandManager execute:[[OAReversePointsCommand alloc] initWithLayer:layer]]; + [layer updateLayer]; + if (self.onChange) + self.onChange(); +} + +- (void)clearAllPoints +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + [ctx.commandManager execute:[[OAClearPointsCommand alloc] initWithMeasurementLayer:layer mode:EOAClearPointsModeAll]]; + [ctx cancelSnapToRoad]; + [layer updateLayer]; + if (self.onChange) + self.onChange(); +} + +- (void)saveAs:(NSString *)fileName + folder:(nullable NSString *)folder + showOnMap:(BOOL)showOnMap + onComplete:(void (^)(BOOL success, NSString * _Nullable outPath))onComplete +{ + [self performSaveWithFileName:fileName folder:folder showOnMap:showOnMap asCopy:NO onComplete:onComplete]; +} + +- (void)saveAsCopy:(NSString *)fileName + folder:(nullable NSString *)folder + showOnMap:(BOOL)showOnMap + onComplete:(void (^)(BOOL success, NSString * _Nullable outPath))onComplete +{ + [self performSaveWithFileName:fileName folder:folder showOnMap:showOnMap asCopy:YES onComplete:onComplete]; +} + +- (void)performSaveWithFileName:(NSString *)fileName + folder:(nullable NSString *)folder + showOnMap:(BOOL)showOnMap + asCopy:(BOOL)asCopy + onComplete:(void (^)(BOOL success, NSString * _Nullable outPath))onComplete +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + { + if (onComplete) onComplete(NO, nil); + return; + } + NSString *trackName = fileName.length > 0 ? fileName : OALocalizedString(@"quick_action_new_route"); + OASGpxFile *gpx = [ctx exportGpx:trackName]; + if (gpx == nil) + { + if (onComplete) onComplete(NO, nil); + return; + } + NSString *gpxRootPath = OsmAndApp.instance.gpxPath; + NSString *folderPath = (folder.length > 0) ? [gpxRootPath stringByAppendingPathComponent:folder] : gpxRootPath; + NSString *outFile = [[folderPath stringByAppendingPathComponent:trackName] stringByAppendingPathExtension:@"gpx"]; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + OASKFile *file = [[OASKFile alloc] initWithFilePath:outFile]; + OASKException *exception = [OASGpxUtilities.shared writeGpxFileFile:file gpxFile:gpx]; + BOOL success = (exception == nil); + if (success) + { + gpx.path = outFile; + OAGPXDatabase *gpxDb = OAGPXDatabase.sharedDb; + NSString *gpxFilePath = [OAUtilities getGpxShortPath:outFile]; + OASGpxDataItem *item = [gpxDb getGPXItem:gpxFilePath]; + if (!item) + item = [gpxDb addGPXFileToDBIfNeeded:gpxFilePath]; + [gpxDb updateDataItem:item]; + if (!asCopy) + { + OAGpxData *gpxData = [[OAGpxData alloc] initWithFile:gpx]; + ctx.gpxData = gpxData; + [ctx setChangesSaved]; + } + if (showOnMap) + { + dispatch_async(dispatch_get_main_queue(), ^{ + [[OAAppSettings sharedManager] showGpx:@[gpxFilePath]]; + }); + } + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (onComplete) onComplete(success, success ? outFile : nil); + }); + }); +} + +- (void)enterNavigationWithTrackName:(NSString *)trackName +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + NSString *name = trackName.length > 0 ? trackName : OALocalizedString(@"quick_action_new_route"); + OASGpxFile *gpx = [ctx exportGpx:name]; + if (gpx == nil) + return; + NSString *outFile = [[OsmAndApp.instance.gpxPath stringByAppendingPathComponent:name] stringByAppendingPathExtension:@"gpx"]; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + OASKFile *file = [[OASKFile alloc] initWithFilePath:outFile]; + [OASGpxUtilities.shared writeGpxFileFile:file gpxFile:gpx]; + gpx.path = outFile; + dispatch_async(dispatch_get_main_queue(), ^{ + [OARootViewController.instance.mapPanel.mapActions enterRoutePlanningModeGivenGpx:gpx + path:outFile + from:nil + fromName:nil + useIntermediatePointsByDefault:YES + showDialog:YES]; + }); + }); +} + - (void)sortSegmentDoorToDoorWithPointIndexes:(NSArray *)indexes { OAMeasurementToolLayer *layer = [self layer]; @@ -525,6 +693,10 @@ - (void)sortSegmentDoorToDoorWithPointIndexes:(NSArray *)indexes - (void)onMeasure:(double)distance bearing:(double)bearing { + _distanceToMapCenter = distance; + _bearingToMapCenter = bearing; + if (self.onChange) + self.onChange(); } - (void)onTouch:(CLLocationCoordinate2D)coordinate longPress:(BOOL)longPress diff --git a/Sources/Controllers/PlanRoute/PlanRouteCrosshairView.swift b/Sources/Controllers/PlanRoute/PlanRouteCrosshairView.swift new file mode 100644 index 0000000000..a1bef6bf1e --- /dev/null +++ b/Sources/Controllers/PlanRoute/PlanRouteCrosshairView.swift @@ -0,0 +1 @@ +// File intentionally left empty. Crosshair uses map_ruler_center_day image asset via UIImageView. diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index 48b28f33ed..dc62dfcc43 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -46,8 +46,8 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { arrivalTime: nil, uphill: 0, downhill: 0, - mapCenterDistance: 0, - bearing: 0) + mapCenterDistance: bridge.distanceToMapCenter, + bearing: bridge.bearingToMapCenter) } var elevationData: PlanRouteElevationData? { @@ -82,6 +82,34 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { bridge.redo() } + func reverseRoute() { + bridge.reverseRoute() + } + + func clearAllPoints() { + bridge.clearAllPoints() + } + + func saveAs(fileName: String, folder: String?, showOnMap: Bool, onComplete: @escaping (Bool, String?) -> Void) { + bridge.save(as: fileName, folder: folder, showOnMap: showOnMap, onComplete: onComplete) + } + + func saveAsCopy(fileName: String, folder: String?, showOnMap: Bool, onComplete: @escaping (Bool, String?) -> Void) { + bridge.save(asCopy: fileName, folder: folder, showOnMap: showOnMap, onComplete: onComplete) + } + + func enterNavigation() { + bridge.enterNavigation(withTrackName: mode.title) + } + + func setCrosshairPosition(screenPoint: CGPoint) { + bridge.setCrosshairScreenPoint(screenPoint) + } + + func dismissLayer() { + bridge.dismiss() + } + func moveRoutePoint(from: Int, to: Int) { bridge.movePoint(from: from, to: to) } @@ -102,6 +130,11 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { bridge.apply(mode, pointIndex: pointIndex, wholeRoute: wholeRoute) } + func applyModeToContext(_ mode: OAApplicationMode?, context: SegmentRouteContext) { + guard let mode else { return } + bridge.apply(mode, pointIndex: context.applyPointIndex, wholeRoute: context.applyWholeRoute) + } + func sortDoorToDoor(pointIndexes: [Int]) { bridge.sortSegmentDoorToDoor(withPointIndexes: pointIndexes.map { NSNumber(value: $0) }) } @@ -113,6 +146,13 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { bridge.selectPoint(at: index) } + func routingParams(for context: SegmentRouteContext) -> PlanRouteSegmentRoutingParams { + PlanRouteSegmentRoutingParams(useElevationData: false, considerTemporaryLimitations: true) + } + + func applyRoutingParams(_ params: PlanRouteSegmentRoutingParams, for context: SegmentRouteContext) { + } + private func mapSegment(_ segment: OAPlanRouteSegmentData) -> PlanRouteSegment { PlanRouteSegment(index: segment.index, groups: segment.groups.map { mapGroup($0) }, diff --git a/Sources/Controllers/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/PlanRoute/PlanRouteModels.swift index b0d340a870..2a50e67edf 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteModels.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteModels.swift @@ -164,6 +164,84 @@ struct PlanRouteElevationData { let elevations: [Double] } +struct PlanRouteSegmentRoutingParams { + var useElevationData: Bool + var considerTemporaryLimitations: Bool +} + +enum SegmentRouteContext { + case profileGroup(PlanRouteProfileGroup, segment: PlanRouteSegment) + case wholeSegment(PlanRouteSegment) + case wholeTrack + + var screenTitle: String { + switch self { + case let .profileGroup(_, segment), let .wholeSegment(segment): + return String(format: localizedString("segments_count"), segment.index + 1) + case .wholeTrack: + return localizedString("route_between_points") + } + } + + var screenSubtitle: String? { + switch self { + case let .profileGroup(group, _): + let modeName = group.appMode?.toHumanString() ?? localizedString("plan_route_straight_line") + let distance = OAOsmAndFormatter.getFormattedDistance(Float(group.distance)) ?? "" + return "\(modeName) • \(distance)" + case let .wholeSegment(segment): + guard !segment.multiMode, let mode = segment.singleMode else { return nil } + let distance = OAOsmAndFormatter.getFormattedDistance(Float(segment.distance)) ?? "" + return "\(mode.toHumanString() ?? "") • \(distance)" + case .wholeTrack: + return nil + } + } + + var recalculateSubtitle: String { + switch self { + case let .profileGroup(group, segment): + let modeName = group.appMode?.toHumanString() ?? localizedString("plan_route_straight_line") + let segmentTitle = String(format: localizedString("segments_count"), segment.index + 1) + return String(format: localizedString("plan_route_section_recalculate_format"), modeName, segmentTitle) + case let .wholeSegment(segment): + let segmentTitle = String(format: localizedString("segments_count"), segment.index + 1) + return String(format: localizedString("plan_route_segment_recalculate_format"), segmentTitle) + case .wholeTrack: + return localizedString("whole_track_descr") + } + } + + var currentMode: OAApplicationMode? { + switch self { + case let .profileGroup(group, _): return group.appMode + case let .wholeSegment(segment): return segment.multiMode ? nil : segment.singleMode + case .wholeTrack: return nil + } + } + + var applyPointIndex: Int { + switch self { + case let .profileGroup(group, _): return group.lastPointIndex + case let .wholeSegment(segment): return segment.pointIndexes.last ?? 0 + case .wholeTrack: return 0 + } + } + + var applyWholeRoute: Bool { + switch self { + case .profileGroup: return false + case .wholeSegment: return true + case .wholeTrack: return true + } + } + + var usesCloseButton: Bool { + if case .wholeTrack = self { return true } + return false + } +} + protocol PlanRoutePoiDataSource: AnyObject { var poiPoints: [PlanRoutePoint] { get } } @@ -182,22 +260,37 @@ protocol PlanRoutePointsDataSource: AnyObject { func addRoutePoint() func undo() func redo() + func reverseRoute() + func clearAllPoints() func moveRoutePoint(from: Int, to: Int) func deleteRoutePoint(at index: Int) func deleteSegment(pointIndexes: [Int]) func startNewSegment() func applyMode(_ mode: OAApplicationMode, pointIndex: Int, wholeRoute: Bool) + func applyModeToContext(_ mode: OAApplicationMode?, context: SegmentRouteContext) func sortDoorToDoor(pointIndexes: [Int]) func saveSegment(pointIndexes: [Int]) func selectRoutePoint(at index: Int) + func routingParams(for context: SegmentRouteContext) -> PlanRouteSegmentRoutingParams + func applyRoutingParams(_ params: PlanRouteSegmentRoutingParams, for context: SegmentRouteContext) } -protocol PlanRouteDataProvider: PlanRoutePoiDataSource, PlanRouteAnalyzeDataSource, PlanRoutePointsDataSource { +protocol PlanRouteSaveDataSource: AnyObject { + func saveAs(fileName: String, folder: String?, showOnMap: Bool, onComplete: @escaping (Bool, String?) -> Void) + func saveAsCopy(fileName: String, folder: String?, showOnMap: Bool, onComplete: @escaping (Bool, String?) -> Void) + func enterNavigation() +} + +protocol PlanRouteDataProvider: PlanRoutePoiDataSource, PlanRouteAnalyzeDataSource, PlanRoutePointsDataSource, PlanRouteSaveDataSource { + var mode: PlanRouteMode { get } var hasChanges: Bool { get } var canUndo: Bool { get } var canRedo: Bool { get } var onDataChanged: (() -> Void)? { get set } + + func setCrosshairPosition(screenPoint: CGPoint) + func dismissLayer() } protocol PlanRouteTabContent: AnyObject { diff --git a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift index ebe6797451..6a42c5009d 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift @@ -27,12 +27,18 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController private let topPartView = PlanRouteTopPartView() private let segmentControl = UISegmentedControl() private let tabContainerView = UIView() + private let crosshairView: UIImageView = { + let imageView = UIImageView(image: UIImage(named: "map_ruler_center_day")) + imageView.contentMode = .center + return imageView + }() private let tabs = PlanRouteTab.allCases private var sheetState: EOADraggableMenuState = .expanded private var selectedTab: PlanRouteTab = .default private var tabViewControllers: [PlanRouteTab: UIViewController] = [:] private var sheetHeightConstraint: NSLayoutConstraint? + private var crosshairCenterYConstraint: NSLayoutConstraint? private var panStartHeight: CGFloat = 0 private weak var currentTabViewController: UIViewController? @@ -73,6 +79,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController setupBottomToolbar() setupContent() setupTopToolbar() + setupCrosshair() dataProvider.onDataChanged = { [weak self] in self?.reloadData() } selectTab(.default) reloadData() @@ -83,6 +90,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController applyHeight(for: sheetState) tabContainerView.alpha = isContentVisible(in: sheetState) ? 1 : 0 view.layoutIfNeeded() + updateCrosshairPosition(sheetHeight: height(for: sheetState)) if animated { sheetView.transform = CGAffineTransform(translationX: 0, y: height(for: sheetState)) UIView.animate(withDuration: Self.animationDuration) { [weak self] in @@ -96,6 +104,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController coordinator.animate { [weak self] _ in guard let self else { return } applyHeight(for: sheetState) + updateCrosshairPosition(sheetHeight: height(for: sheetState)) refreshMapControls() } } @@ -134,6 +143,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController override func hide(_ animated: Bool, duration: TimeInterval, onComplete: (() -> Void)!) { let dismiss: () -> Void = { [weak self] in + self?.dataProvider.dismissLayer() OARootViewController.instance().mapPanel?.hideScrollableHudViewController() self?.removeFromParent() self?.view.removeFromSuperview() @@ -239,6 +249,8 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController segmentControl.setTitleTextAttributes(segmentAttributes, for: .normal) segmentControl.setTitleTextAttributes(segmentAttributes, for: .selected) segmentControl.addTarget(self, action: #selector(onSegmentChanged), for: .valueChanged) + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(onSegmentTapped)) + segmentControl.addGestureRecognizer(tapGesture) } private func setupBottomToolbar() { @@ -275,6 +287,40 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController ]) } + private func setupCrosshair() { + crosshairView.translatesAutoresizingMaskIntoConstraints = false + crosshairView.isUserInteractionEnabled = false + view.insertSubview(crosshairView, belowSubview: sheetView) + let centerY = crosshairView.centerYAnchor.constraint(equalTo: view.topAnchor, constant: crosshairCenterY(sheetHeight: height(for: sheetState))) + crosshairCenterYConstraint = centerY + NSLayoutConstraint.activate([ + crosshairView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + centerY + ]) + } + + private func crosshairCenterY(sheetHeight: CGFloat) -> CGFloat { + let h = OAUtilities.calculateScreenHeight() + if sheetHeight <= height(for: .initial) { + return h / 2.0 + } + let visibleTop = getNavbarHeight() + let visibleBottom = h - min(sheetHeight, height(for: .expanded)) + return visibleTop + (visibleBottom - visibleTop) / 2 + } + + private func updateCrosshairPosition(sheetHeight: CGFloat) { + let centerY = crosshairCenterY(sheetHeight: sheetHeight) + crosshairCenterYConstraint?.constant = centerY + let x = view.bounds.midX + guard x > 0 else { return } + if sheetHeight > height(for: .initial) { + dataProvider.setCrosshairPosition(screenPoint: CGPoint(x: x, y: centerY)) + } else { + dataProvider.setCrosshairPosition(screenPoint: .zero) + } + } + private func height(for state: EOADraggableMenuState) -> CGFloat { let screenHeight = OAUtilities.calculateScreenHeight() let collapsed = Self.grabberAreaHeight + Self.topPartHeight + 8 + Self.segmentedControlHeight + 12 @@ -301,7 +347,9 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController private func setState(_ state: EOADraggableMenuState, animated: Bool) { sheetState = state - sheetHeightConstraint?.constant = height(for: state) + let h = height(for: state) + sheetHeightConstraint?.constant = h + updateCrosshairPosition(sheetHeight: h) let updates: () -> Void = { [weak self] in guard let self else { return } view.layoutIfNeeded() @@ -397,11 +445,24 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController } private func handleSave() { - print("[PlanRoute] Save tapped") + switch dataProvider.mode { + case .newRoute: + presentSaveDialog(duplicate: false) + case .editTrack(let fileName): + dataProvider.saveAs(fileName: fileName, folder: nil, showOnMap: true) { [weak self] success, _ in + guard let self else { return } + if success { + reloadData() + } else { + showSaveError() + } + } + } } private func handleAddPoi() { - print("[PlanRoute] Add POI tapped") + dataProvider.addRoutePoint() + reloadData() } private func handleUndo() { @@ -420,15 +481,72 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController } private func handleMenuAction(_ action: PlanRouteMenuAction) { - print("[PlanRoute] Options menu action: \(action)") + switch action { + case .saveAs: + presentSaveDialog(duplicate: false) + case .saveAsCopy: + presentSaveDialog(duplicate: true) + case .appendToExistingTrack: + presentAppendToTrack() + case .changeSegmentOrder: + break + case .viewDirections: + break + case .reverseRoute: + dataProvider.reverseRoute() + reloadData() + case .navigation: + hide() + dataProvider.enterNavigation() + case .clearAllPoints: + confirmClearAllPoints() + } + } + + private func presentSaveDialog(duplicate: Bool) { + let fileName: String + switch dataProvider.mode { + case .newRoute: fileName = localizedString("quick_action_new_route") + case .editTrack(let name): fileName = name + } + guard let vc = OASaveTrackViewController(fileName: fileName, filePath: nil, showOnMap: true, simplifiedTrack: false, duplicate: duplicate) else { return } + vc.delegate = self + present(UINavigationController(rootViewController: vc), animated: true) + } + + private func presentAppendToTrack() { + guard let vc = OAOpenAddTrackViewController(screenType: .addToATrack) else { return } + vc.delegate = self + present(UINavigationController(rootViewController: vc), animated: true) + } + + private func confirmClearAllPoints() { + let alert = UIAlertController(title: localizedString("distance_measurement_clear_route"), + message: nil, + preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: localizedString("shared_string_clear"), style: .destructive) { [weak self] _ in + self?.dataProvider.clearAllPoints() + self?.reloadData() + }) + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func showSaveError() { + let alert = UIAlertController(title: localizedString("gpx_export_failed"), + message: nil, + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: localizedString("shared_string_ok"), style: .default)) + present(alert, animated: true) } @objc private func onSegmentChanged() { let index = segmentControl.selectedSegmentIndex guard tabs.indices.contains(index) else { return } - let tab = tabs[index] - print("[PlanRoute] Segment switched to: \(tab)") - selectTab(tab) + selectTab(tabs[index]) + } + + @objc private func onSegmentTapped() { if sheetState == .initial { setState(.expanded, animated: true) } @@ -443,7 +561,9 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController case .changed: let lower = height(for: .initial) let upper = height(for: .fullScreen) - sheetHeightConstraint.constant = min(max(panStartHeight - translation, lower), upper) + let newHeight = min(max(panStartHeight - translation, lower), upper) + sheetHeightConstraint.constant = newHeight + updateCrosshairPosition(sheetHeight: newHeight) case .ended, .cancelled: let velocity = gesture.velocity(in: view).y setState(nearestState(for: sheetHeightConstraint.constant, velocity: velocity), animated: true) @@ -460,3 +580,29 @@ extension PlanRouteScrollableViewController: UIGestureRecognizerDelegate { return !tabContainerView.bounds.contains(location) } } + +// MARK: - OASaveTrackViewControllerDelegate +extension PlanRouteScrollableViewController: OASaveTrackViewControllerDelegate { + func onSave(asNewTrack fileName: String, showOnMap: Bool, simplifiedTrack: Bool, openTrack: Bool) { + dataProvider.saveAs(fileName: fileName, folder: nil, showOnMap: showOnMap) { [weak self] success, _ in + guard let self else { return } + if success { + topToolbar.titleText = fileName + reloadData() + } else { + showSaveError() + } + } + } +} + +// MARK: - OAOpenAddTrackDelegate +extension PlanRouteScrollableViewController: OAOpenAddTrackDelegate { + func onFileSelected(_ gpxFilePath: String) { + let fileName = ((gpxFilePath as NSString).lastPathComponent as NSString).deletingPathExtension + dataProvider.saveAs(fileName: fileName, folder: nil, showOnMap: false) { [weak self] success, _ in + guard let self else { return } + if !success { showSaveError() } + } + } +} diff --git a/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift deleted file mode 100644 index 45794d17d6..0000000000 --- a/Sources/Controllers/PlanRoute/PlanRouteStubDataProvider.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// PlanRouteStubDataProvider.swift -// OsmAnd Maps -// -// Created by OsmAnd on 15.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import UIKit - -final class PlanRouteStubDataProvider: PlanRouteDataProvider { - let mode: PlanRouteMode - - var onDataChanged: (() -> Void)? - - init(mode: PlanRouteMode = .newRoute) { - self.mode = mode - } - - var hasChanges: Bool { - false - } - - var canUndo: Bool { - false - } - - var canRedo: Bool { - false - } - - var routeInfo: PlanRouteInfo { - PlanRouteInfo(isNewRoute: mode.isNewRoute, - isStraightLine: false, - hasRoute: !routeSegments.isEmpty, - totalDistance: 0, - duration: 0, - arrivalTime: nil, - uphill: 0, - downhill: 0, - mapCenterDistance: 0, - bearing: 100) - } - - var elevationData: PlanRouteElevationData? { - nil - } - - var poiPoints: [PlanRoutePoint] { - [] - } - - var routeSegments: [PlanRouteSegment] { - guard mode.isEditTrack else { return [] } - return [sampleSegment] - } - - var canStartNewSegment: Bool { - mode.isEditTrack - } - - private var sampleSegment: PlanRouteSegment { - let cyclingPoints = [ - PlanRoutePoint(index: 0, name: "Point - 1", distanceFromPrevious: 0, bearing: 100, isStart: true, isDestination: false), - PlanRoutePoint(index: 1, name: "Point - 2", distanceFromPrevious: 100, bearing: 100, isStart: false, isDestination: false) - ] - let walkingPoints = [ - PlanRoutePoint(index: 2, name: "Point - 3", distanceFromPrevious: 200, bearing: 100, isStart: false, isDestination: false), - PlanRoutePoint(index: 3, name: "Point - 4", distanceFromPrevious: 5000, bearing: 100, isStart: false, isDestination: false), - PlanRoutePoint(index: 4, name: "Point - 5", distanceFromPrevious: 3580, bearing: 100, isStart: false, isDestination: true) - ] - let groups = [ - PlanRouteProfileGroup(appMode: OAApplicationMode.bicycle(), distance: 53000, lastPointIndex: 1, points: cyclingPoints), - PlanRouteProfileGroup(appMode: OAApplicationMode.pedestrian(), distance: 120000, lastPointIndex: 4, points: walkingPoints) - ] - return PlanRouteSegment(index: 0, groups: groups, routed: true, multiMode: true, singleMode: nil, distance: 173000) - } - - var availableModes: [OAApplicationMode] { - OAApplicationMode.values() - } - - func addRoutePoint() {} - - func undo() {} - - func redo() {} - - func moveRoutePoint(from: Int, to: Int) {} - - func deleteRoutePoint(at index: Int) {} - - func deleteSegment(pointIndexes: [Int]) {} - - func startNewSegment() {} - - func applyMode(_ mode: OAApplicationMode, pointIndex: Int, wholeRoute: Bool) {} - - func sortDoorToDoor(pointIndexes: [Int]) {} - - func saveSegment(pointIndexes: [Int]) {} - - func selectRoutePoint(at index: Int) {} -} diff --git a/Sources/Controllers/PlanRoute/RouteBetweenPointsViewController.swift b/Sources/Controllers/PlanRoute/RouteBetweenPointsViewController.swift new file mode 100644 index 0000000000..aa59459c66 --- /dev/null +++ b/Sources/Controllers/PlanRoute/RouteBetweenPointsViewController.swift @@ -0,0 +1,329 @@ +// +// RouteBetweenPointsViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 24.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class RouteBetweenPointsViewController: UIViewController { + + private enum Row { + case profileGroup(PlanRouteProfileGroup, segment: PlanRouteSegment) + case changeWholeSegment(PlanRouteSegment) + case startNewSegment + case changeWholeTrack + } + + private struct SectionModel { + let headerTitle: String? + let rows: [Row] + } + + private weak var dataSource: PlanRoutePointsDataSource? + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private var sections: [SectionModel] = [] + + init(dataSource: PlanRoutePointsDataSource?) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setupTableView() + reloadData() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + reloadData() + } + + private func setupNavigationBar() { + title = localizedString("route_between_points") + navigationItem.backButtonDisplayMode = .minimal + let closeButton = UIBarButtonItem(image: UIImage(systemName: "xmark"), + style: .plain, + target: self, + action: #selector(onCloseTapped)) + closeButton.tintColor = .textColorPrimary + navigationItem.leftBarButtonItem = closeButton + } + + private func setupTableView() { + view.backgroundColor = .viewBg + tableView.backgroundColor = .viewBg + tableView.dataSource = self + tableView.delegate = self + tableView.sectionHeaderTopPadding = 0 + tableView.register(RouteGroupCell.self, forCellReuseIdentifier: RouteGroupCell.reuseId) + tableView.register(RouteActionCell.self, forCellReuseIdentifier: RouteActionCell.reuseId) + tableView.tableHeaderView = makeHintHeaderView() + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func makeHintHeaderView() -> UIView { + let label = UILabel() + label.text = localizedString("plan_route_select_segment_hint") + label.font = .scaledSystemFont(ofSize: 13) + label.textColor = .textColorSecondary + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + + let container = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 52)) + container.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 32), + label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -32), + label.topAnchor.constraint(equalTo: container.topAnchor, constant: 12), + label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8) + ]) + return container + } + + private func reloadData() { + sections = buildSections() + tableView.reloadData() + } + + private func buildSections() -> [SectionModel] { + let segments = dataSource?.routeSegments ?? [] + var result: [SectionModel] = segments.map { makeSegmentSection($0) } + + if dataSource?.canStartNewSegment ?? false { + result.append(SectionModel(headerTitle: nil, rows: [.startNewSegment])) + } + result.append(SectionModel(headerTitle: nil, rows: [.changeWholeTrack])) + return result + } + + private func makeSegmentSection(_ segment: PlanRouteSegment) -> SectionModel { + var rows: [Row] = segment.groups.map { .profileGroup($0, segment: segment) } + if segment.groups.count > 1 { + rows.append(.changeWholeSegment(segment)) + } + let title = String(format: localizedString("segments_count"), segment.index + 1) + return SectionModel(headerTitle: title, rows: rows) + } + + private func openSettings(context: SegmentRouteContext) { + let detailVC = SegmentRouteSettingsViewController(context: context, dataSource: dataSource) + navigationController?.pushViewController(detailVC, animated: true) + } + + @objc private func onCloseTapped() { + dismiss(animated: true) + } +} + +// MARK: - UITableViewDataSource +extension RouteBetweenPointsViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + sections[section].rows.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = sections[indexPath.section].rows[indexPath.row] + switch row { + case let .profileGroup(group, _): + guard let cell = tableView.dequeueReusableCell(withIdentifier: RouteGroupCell.reuseId, for: indexPath) as? RouteGroupCell else { + return UITableViewCell() + } + cell.configure(group: group) + return cell + case let .changeWholeSegment(segment): + guard let cell = tableView.dequeueReusableCell(withIdentifier: RouteGroupCell.reuseId, for: indexPath) as? RouteGroupCell else { + return UITableViewCell() + } + cell.configureWholeSegment(segment: segment) + return cell + case .startNewSegment: + guard let cell = tableView.dequeueReusableCell(withIdentifier: RouteActionCell.reuseId, for: indexPath) as? RouteActionCell else { + return UITableViewCell() + } + cell.configure(title: localizedString("gpx_start_new_segment"), isDestructive: false) + return cell + case .changeWholeTrack: + guard let cell = tableView.dequeueReusableCell(withIdentifier: RouteActionCell.reuseId, for: indexPath) as? RouteActionCell else { + return UITableViewCell() + } + cell.configure(title: localizedString("plan_route_change_for_whole_track"), isDestructive: false) + return cell + } + } +} + +// MARK: - UITableViewDelegate +extension RouteBetweenPointsViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + sections[section].headerTitle != nil ? 44 : 0 + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let title = sections[section].headerTitle else { return nil } + let header = UITableViewHeaderFooterView() + var config = header.defaultContentConfiguration() + config.text = title + config.textProperties.font = .scaledSystemFont(ofSize: 13) + config.textProperties.color = .textColorSecondary + header.contentConfiguration = config + return header + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + nil + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let row = sections[indexPath.section].rows[indexPath.row] + switch row { + case let .profileGroup(group, segment): + openSettings(context: .profileGroup(group, segment: segment)) + case let .changeWholeSegment(segment): + openSettings(context: .wholeSegment(segment)) + case .startNewSegment: + dataSource?.startNewSegment() + dismiss(animated: true) + case .changeWholeTrack: + openSettings(context: .wholeTrack) + } + } +} + +// MARK: - RouteGroupCell + +private final class RouteGroupCell: UITableViewCell { + static let reuseId = "RouteGroupCell" + + private static let iconSize: CGFloat = 24 + + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let distanceLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(group: PlanRouteProfileGroup) { + let mode = group.appMode + if let mode { + iconView.image = mode.getIcon()?.withRenderingMode(.alwaysTemplate) + iconView.tintColor = mode.getProfileColor() ?? .iconColorActive + titleLabel.text = mode.toHumanString() + } else { + iconView.image = .templateImageNamed("ic_custom_straight_line") + iconView.tintColor = .iconColorActive + titleLabel.text = localizedString("plan_route_straight_line") + } + distanceLabel.text = formattedDistance(group.distance) + } + + func configureWholeSegment(segment: PlanRouteSegment) { + iconView.image = nil + titleLabel.text = localizedString("plan_route_change_for_whole_segment") + distanceLabel.text = formattedDistance(segment.distance) + } + + private func setupCell() { + backgroundColor = .groupBg + accessoryType = .disclosureIndicator + selectionStyle = .default + + iconView.contentMode = .scaleAspectFit + + titleLabel.font = .scaledSystemFont(ofSize: 17) + titleLabel.textColor = .textColorPrimary + + distanceLabel.font = .scaledSystemFont(ofSize: 17) + distanceLabel.textColor = .textColorSecondary + distanceLabel.setContentHuggingPriority(.required, for: .horizontal) + + [iconView, titleLabel, distanceLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: Self.iconSize), + iconView.heightAnchor.constraint(equalToConstant: Self.iconSize), + + titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 16), + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + titleLabel.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 12), + + distanceLabel.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 8), + distanceLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -4), + distanceLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + } + + private func formattedDistance(_ meters: Double) -> String { + OAOsmAndFormatter.getFormattedDistance(Float(meters)) ?? "" + } +} + +// MARK: - RouteActionCell + +private final class RouteActionCell: UITableViewCell { + static let reuseId = "RouteActionCell" + + private let titleLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String, isDestructive: Bool) { + titleLabel.text = title + titleLabel.textColor = isDestructive ? .systemRed : .iconColorActive + } + + private func setupCell() { + backgroundColor = .groupBg + selectionStyle = .default + + titleLabel.font = .scaledSystemFont(ofSize: 17) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(titleLabel) + + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 14), + titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -14) + ]) + } +} diff --git a/Sources/Controllers/PlanRoute/SegmentRouteSettingsViewController.swift b/Sources/Controllers/PlanRoute/SegmentRouteSettingsViewController.swift new file mode 100644 index 0000000000..a36fa41910 --- /dev/null +++ b/Sources/Controllers/PlanRoute/SegmentRouteSettingsViewController.swift @@ -0,0 +1,736 @@ +// +// SegmentRouteSettingsViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 24.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class SegmentRouteSettingsViewController: UIViewController { + + private enum ActiveTab { + case routeType + case settings + } + + private weak var dataSource: PlanRoutePointsDataSource? + private let context: SegmentRouteContext + private var activeTab: ActiveTab = .routeType + private var selectedMode: OAApplicationMode? + private var routingParams: PlanRouteSegmentRoutingParams + + private let segmentControl = UISegmentedControl() + private let tabContainerView = UIView() + private var routeTypeVC: RouteTypeViewController? + private var settingsVC: RouteSettingsViewController? + private var activeTabViewController: UIViewController? + + init(context: SegmentRouteContext, dataSource: PlanRoutePointsDataSource?) { + self.context = context + self.dataSource = dataSource + self.selectedMode = context.currentMode + self.routingParams = dataSource?.routingParams(for: context) ?? PlanRouteSegmentRoutingParams(useElevationData: false, + considerTemporaryLimitations: true) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .viewBg + setupNavigationBar() + setupSegmentControl() + setupTabContainer() + switchTab(to: .routeType, animated: false) + } + + private func setupNavigationBar() { + navigationItem.title = context.screenTitle + if let subtitle = context.screenSubtitle { + let titleView = TwoLineTitleView(title: context.screenTitle, subtitle: subtitle) + navigationItem.titleView = titleView + } + + if context.usesCloseButton { + let closeButton = UIBarButtonItem(image: UIImage(systemName: "xmark"), + style: .plain, + target: self, + action: #selector(onCloseTapped)) + closeButton.tintColor = .textColorPrimary + navigationItem.leftBarButtonItem = closeButton + } + + let checkmarkImage = UIImage.templateImageNamed("ic_checkmark_default")?.withTintColor(.white, renderingMode: .alwaysOriginal) + navigationItem.rightBarButtonItem = UIBarButtonItem(image: checkmarkImage, + style: .done, + target: self, + action: #selector(onConfirmTapped)) + } + + private func setupSegmentControl() { + segmentControl.removeAllSegments() + segmentControl.insertSegment(withTitle: localizedString("layer_route"), at: 0, animated: false) + segmentControl.insertSegment(withTitle: localizedString("shared_string_settings"), at: 1, animated: false) + segmentControl.selectedSegmentIndex = 0 + segmentControl.backgroundColor = .groupBgColorSecondary + segmentControl.selectedSegmentTintColor = UIColor.white + let attrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.textColorPrimary, + .font: UIFont.scaledSystemFont(ofSize: 13, weight: .medium) + ] + segmentControl.setTitleTextAttributes(attrs, for: .normal) + segmentControl.setTitleTextAttributes(attrs, for: .selected) + segmentControl.addTarget(self, action: #selector(onSegmentChanged), for: .valueChanged) + + segmentControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(segmentControl) + NSLayoutConstraint.activate([ + segmentControl.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), + segmentControl.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + segmentControl.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + segmentControl.heightAnchor.constraint(equalToConstant: 36) + ]) + } + + private func setupTabContainer() { + tabContainerView.clipsToBounds = true + tabContainerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tabContainerView) + NSLayoutConstraint.activate([ + tabContainerView.topAnchor.constraint(equalTo: segmentControl.bottomAnchor, constant: 12), + tabContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tabContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tabContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func switchTab(to tab: ActiveTab, animated: Bool) { + activeTab = tab + let newVC: UIViewController + switch tab { + case .routeType: + let vc = makeRouteTypeVC() + routeTypeVC = vc + newVC = vc + case .settings: + let vc = makeSettingsVC() + settingsVC = vc + newVC = vc + } + + activeTabViewController?.willMove(toParent: nil) + activeTabViewController?.view.removeFromSuperview() + activeTabViewController?.removeFromParent() + + addChild(newVC) + newVC.view.translatesAutoresizingMaskIntoConstraints = false + tabContainerView.addSubview(newVC.view) + NSLayoutConstraint.activate([ + newVC.view.topAnchor.constraint(equalTo: tabContainerView.topAnchor), + newVC.view.leadingAnchor.constraint(equalTo: tabContainerView.leadingAnchor), + newVC.view.trailingAnchor.constraint(equalTo: tabContainerView.trailingAnchor), + newVC.view.bottomAnchor.constraint(equalTo: tabContainerView.bottomAnchor) + ]) + newVC.didMove(toParent: self) + activeTabViewController = newVC + } + + private func makeRouteTypeVC() -> RouteTypeViewController { + RouteTypeViewController( + context: context, + availableModes: dataSource?.availableModes ?? [], + selectedMode: selectedMode, + canStartNewSegment: context.usesCloseButton && (dataSource?.canStartNewSegment ?? false), + onModeSelected: { [weak self] mode in + self?.selectedMode = mode + }, + onStartNewSegment: { [weak self] in + self?.dataSource?.startNewSegment() + self?.navigationController?.dismiss(animated: true) + } + ) + } + + private func makeSettingsVC() -> RouteSettingsViewController { + RouteSettingsViewController( + params: routingParams, + onParamsChanged: { [weak self] updated in + self?.routingParams = updated + } + ) + } + + @objc private func onSegmentChanged() { + let tab: ActiveTab = segmentControl.selectedSegmentIndex == 0 ? .routeType : .settings + switchTab(to: tab, animated: false) + } + + @objc private func onConfirmTapped() { + dataSource?.applyModeToContext(selectedMode, context: context) + dataSource?.applyRoutingParams(routingParams, for: context) + if context.usesCloseButton { + navigationController?.dismiss(animated: true) + } else { + navigationController?.popViewController(animated: true) + } + } + + @objc private func onCloseTapped() { + navigationController?.dismiss(animated: true) + } +} + +// MARK: - TwoLineTitleView + +private final class TwoLineTitleView: UIView { + init(title: String, subtitle: String) { + super.init(frame: .zero) + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = .textColorPrimary + titleLabel.textAlignment = .center + + let subtitleLabel = UILabel() + subtitleLabel.text = subtitle + subtitleLabel.font = .scaledSystemFont(ofSize: 12) + subtitleLabel.textColor = .textColorSecondary + subtitleLabel.textAlignment = .center + + let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + stack.axis = .vertical + stack.spacing = 1 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - RouteTypeViewController + +private final class RouteTypeViewController: UIViewController { + + private enum Row { + case straightLine + case mode(OAApplicationMode) + case startNewSegment + } + + private struct SectionModel { + let rows: [Row] + let footerTitle: String? + } + + private let context: SegmentRouteContext + private let availableModes: [OAApplicationMode] + private var selectedMode: OAApplicationMode? + private let canStartNewSegment: Bool + private let onModeSelected: (OAApplicationMode?) -> Void + private let onStartNewSegment: () -> Void + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private var sections: [SectionModel] = [] + + init(context: SegmentRouteContext, + availableModes: [OAApplicationMode], + selectedMode: OAApplicationMode?, + canStartNewSegment: Bool, + onModeSelected: @escaping (OAApplicationMode?) -> Void, + onStartNewSegment: @escaping () -> Void) { + self.context = context + self.availableModes = availableModes + self.selectedMode = selectedMode + self.canStartNewSegment = canStartNewSegment + self.onModeSelected = onModeSelected + self.onStartNewSegment = onStartNewSegment + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupTableView() + rebuildSections() + } + + private func setupTableView() { + view.backgroundColor = .viewBg + tableView.backgroundColor = .viewBg + tableView.dataSource = self + tableView.delegate = self + tableView.sectionHeaderTopPadding = 0 + tableView.register(RouteTypeModeCell.self, forCellReuseIdentifier: RouteTypeModeCell.reuseId) + tableView.register(RouteActionCell.self, forCellReuseIdentifier: RouteActionCell.reuseId) + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func rebuildSections() { + var result: [SectionModel] = [] + + result.append(SectionModel(rows: [.straightLine], footerTitle: nil)) + + let modeRows: [Row] = availableModes.map { .mode($0) } + let modesFooter: String? = canStartNewSegment ? nil : nil + result.append(SectionModel(rows: modeRows, footerTitle: modesFooter)) + + if canStartNewSegment { + result.append(SectionModel(rows: [.startNewSegment], + footerTitle: localizedString("plan_route_start_new_segment_hint"))) + } + + sections = result + tableView.reloadData() + } + + private func isSelected(_ mode: OAApplicationMode?) -> Bool { + selectedMode?.stringKey == mode?.stringKey + } +} + +extension RouteTypeViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { sections.count } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + sections[section].rows.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = sections[indexPath.section].rows[indexPath.row] + switch row { + case .straightLine: + guard let cell = tableView.dequeueReusableCell(withIdentifier: RouteTypeModeCell.reuseId, for: indexPath) as? RouteTypeModeCell else { + return UITableViewCell() + } + cell.configure(title: localizedString("plan_route_straight_line"), + icon: .templateImageNamed("ic_custom_straight_line"), + tintColor: .iconColorActive, + isSelected: selectedMode == nil) + return cell + case let .mode(mode): + guard let cell = tableView.dequeueReusableCell(withIdentifier: RouteTypeModeCell.reuseId, for: indexPath) as? RouteTypeModeCell else { + return UITableViewCell() + } + cell.configure(title: mode.toHumanString() ?? "", + icon: mode.getIcon(), + tintColor: .iconColorActive, + isSelected: isSelected(mode)) + return cell + case .startNewSegment: + guard let cell = tableView.dequeueReusableCell(withIdentifier: RouteActionCell.reuseId, for: indexPath) as? RouteActionCell else { + return UITableViewCell() + } + cell.configure(title: localizedString("gpx_start_new_segment"), isDestructive: false) + return cell + } + } +} + +extension RouteTypeViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard section == 0 else { return nil } + let header = UITableViewHeaderFooterView() + var config = header.defaultContentConfiguration() + config.text = context.recalculateSubtitle + config.textProperties.font = .scaledSystemFont(ofSize: 13) + config.textProperties.color = .textColorSecondary + config.textProperties.numberOfLines = 0 + header.contentConfiguration = config + return header + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + section == 0 ? UITableView.automaticDimension : 0 + } + + func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + sections[section].footerTitle + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let row = sections[indexPath.section].rows[indexPath.row] + switch row { + case .straightLine: + selectedMode = nil + onModeSelected(nil) + tableView.reloadData() + case let .mode(mode): + selectedMode = mode + onModeSelected(mode) + tableView.reloadData() + case .startNewSegment: + onStartNewSegment() + } + } +} + +// MARK: - RouteTypeModeCell + +private final class RouteTypeModeCell: UITableViewCell { + static let reuseId = "RouteTypeModeCell" + + private static let checkmarkSize: CGFloat = 20 + private static let iconSize: CGFloat = 24 + private static let gap: CGFloat = 8 + private static let leadingInset: CGFloat = 16 + private static let verticalPadding: CGFloat = 12 + + private let checkmarkView = UIImageView() + private let iconView = UIImageView() + private let titleLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String, icon: UIImage?, tintColor: UIColor, isSelected: Bool) { + iconView.image = icon?.withRenderingMode(.alwaysTemplate) + iconView.tintColor = tintColor + titleLabel.text = title + checkmarkView.image = isSelected ? .templateImageNamed("ic_checkmark_default") : nil + accessoryType = .none + let inset = Self.leadingInset + Self.checkmarkSize + Self.gap + Self.iconSize + Self.gap + separatorInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: Self.leadingInset) + } + + private func setupCell() { + backgroundColor = .groupBg + selectionStyle = .default + + checkmarkView.contentMode = .scaleAspectFit + checkmarkView.tintColor = .iconColorActive + checkmarkView.isAccessibilityElement = false + + iconView.contentMode = .scaleAspectFit + + titleLabel.font = .scaledSystemFont(ofSize: 17) + titleLabel.textColor = .textColorPrimary + titleLabel.numberOfLines = 0 + + [checkmarkView, iconView, titleLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + NSLayoutConstraint.activate([ + checkmarkView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Self.leadingInset), + checkmarkView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + checkmarkView.widthAnchor.constraint(equalToConstant: Self.checkmarkSize), + checkmarkView.heightAnchor.constraint(equalToConstant: Self.checkmarkSize), + + iconView.leadingAnchor.constraint(equalTo: checkmarkView.trailingAnchor, constant: Self.gap), + iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: Self.iconSize), + iconView.heightAnchor.constraint(equalToConstant: Self.iconSize), + + titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: Self.gap), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -Self.leadingInset), + titleLabel.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: Self.verticalPadding), + titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -Self.verticalPadding), + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 52) + ]) + } +} + +// MARK: - RouteActionCell (reused from RouteBetweenPointsViewController) + +private final class RouteActionCell: UITableViewCell { + static let reuseId = "RouteActionCell_Settings" + + private let titleLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String, isDestructive: Bool) { + titleLabel.text = title + titleLabel.textColor = isDestructive ? .systemRed : .iconColorActive + } + + private func setupCell() { + backgroundColor = .groupBg + selectionStyle = .default + + titleLabel.font = .scaledSystemFont(ofSize: 17) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(titleLabel) + + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 14), + titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -14) + ]) + } +} + +// MARK: - RouteSettingsViewController + +private final class RouteSettingsViewController: UIViewController { + + private enum Row { + case useElevationData + case avoidRoads + case considerTempLimitations + case navigationSettings + } + + private struct SectionModel { + let rows: [Row] + } + + private var params: PlanRouteSegmentRoutingParams + private let onParamsChanged: (PlanRouteSegmentRoutingParams) -> Void + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private let sections: [SectionModel] = [ + SectionModel(rows: [.useElevationData]), + SectionModel(rows: [.avoidRoads, .considerTempLimitations]), + SectionModel(rows: [.navigationSettings]) + ] + + init(params: PlanRouteSegmentRoutingParams, onParamsChanged: @escaping (PlanRouteSegmentRoutingParams) -> Void) { + self.params = params + self.onParamsChanged = onParamsChanged + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupTableView() + } + + private func setupTableView() { + view.backgroundColor = .viewBg + tableView.backgroundColor = .viewBg + tableView.dataSource = self + tableView.delegate = self + tableView.sectionHeaderTopPadding = 0 + tableView.register(RouteSettingToggleCell.self, forCellReuseIdentifier: RouteSettingToggleCell.reuseId) + tableView.register(RouteSettingNavigationCell.self, forCellReuseIdentifier: RouteSettingNavigationCell.reuseId) + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } +} + +extension RouteSettingsViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { sections.count } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + sections[section].rows.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = sections[indexPath.section].rows[indexPath.row] + switch row { + case .useElevationData: + guard let cell = tableView.dequeueReusableCell(withIdentifier: RouteSettingToggleCell.reuseId, for: indexPath) as? RouteSettingToggleCell else { + return UITableViewCell() + } + cell.configure(title: localizedString("plan_route_use_elevation_data"), + isOn: params.useElevationData) { [weak self] isOn in + guard let self else { return } + self.params.useElevationData = isOn + self.onParamsChanged(self.params) + } + return cell + case .avoidRoads: + guard let cell = tableView.dequeueReusableCell(withIdentifier: RouteSettingNavigationCell.reuseId, for: indexPath) as? RouteSettingNavigationCell else { + return UITableViewCell() + } + cell.configure(title: localizedString("plan_route_avoid_roads")) + return cell + case .considerTempLimitations: + guard let cell = tableView.dequeueReusableCell(withIdentifier: RouteSettingToggleCell.reuseId, for: indexPath) as? RouteSettingToggleCell else { + return UITableViewCell() + } + cell.configure(title: localizedString("plan_route_consider_temp_limitations"), + isOn: params.considerTemporaryLimitations) { [weak self] isOn in + guard let self else { return } + self.params.considerTemporaryLimitations = isOn + self.onParamsChanged(self.params) + } + return cell + case .navigationSettings: + guard let cell = tableView.dequeueReusableCell(withIdentifier: RouteSettingNavigationCell.reuseId, for: indexPath) as? RouteSettingNavigationCell else { + return UITableViewCell() + } + cell.configure(title: localizedString("plan_route_navigation_settings")) + return cell + } + } +} + +extension RouteSettingsViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + } +} + +// MARK: - RouteSettingToggleCell + +private final class RouteSettingToggleCell: UITableViewCell { + static let reuseId = "RouteSettingToggleCell" + + private static let iconSize: CGFloat = 24 + + private let iconContainer = UIView() + private let titleLabel = UILabel() + private let toggle = UISwitch() + private var onToggle: ((Bool) -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String, isOn: Bool, onToggle: @escaping (Bool) -> Void) { + titleLabel.text = title + toggle.isOn = isOn + self.onToggle = onToggle + } + + private func setupCell() { + backgroundColor = .groupBg + selectionStyle = .none + + iconContainer.backgroundColor = .iconColorDefault + iconContainer.layer.cornerRadius = Self.iconSize / 2 + + titleLabel.font = .scaledSystemFont(ofSize: 17) + titleLabel.textColor = .textColorPrimary + titleLabel.numberOfLines = 0 + + toggle.onTintColor = UIColor(rgbValue: 0x65C366) + toggle.addTarget(self, action: #selector(onToggleSwitched), for: .valueChanged) + + [iconContainer, titleLabel, toggle].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + NSLayoutConstraint.activate([ + iconContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + iconContainer.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + iconContainer.widthAnchor.constraint(equalToConstant: Self.iconSize), + iconContainer.heightAnchor.constraint(equalToConstant: Self.iconSize), + + titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 16), + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + titleLabel.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 12), + + toggle.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 8), + toggle.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + toggle.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + } + + @objc private func onToggleSwitched() { + onToggle?(toggle.isOn) + } +} + +// MARK: - RouteSettingNavigationCell + +private final class RouteSettingNavigationCell: UITableViewCell { + static let reuseId = "RouteSettingNavigationCell" + + private static let iconSize: CGFloat = 24 + + private let iconContainer = UIView() + private let titleLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String) { + titleLabel.text = title + } + + private func setupCell() { + backgroundColor = .groupBg + accessoryType = .disclosureIndicator + selectionStyle = .default + + iconContainer.backgroundColor = .iconColorDefault + iconContainer.layer.cornerRadius = Self.iconSize / 2 + + titleLabel.font = .scaledSystemFont(ofSize: 17) + titleLabel.textColor = .textColorPrimary + + [iconContainer, titleLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + NSLayoutConstraint.activate([ + iconContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + iconContainer.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + iconContainer.widthAnchor.constraint(equalToConstant: Self.iconSize), + iconContainer.heightAnchor.constraint(equalToConstant: Self.iconSize), + + titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + titleLabel.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 12) + ]) + } +} diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift index 0c46d6f49f..f8bf6d71c2 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift @@ -102,10 +102,22 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent return result } + private static func segmentColor(at index: Int) -> UIColor { + let palette: [UIColor] = [ + .iconColorActive, + .iconColorGreen, + UIColor(red: 0.96, green: 0.60, blue: 0.13, alpha: 1), + UIColor(red: 0.88, green: 0.24, blue: 0.24, alpha: 1), + UIColor(red: 0.42, green: 0.27, blue: 0.80, alpha: 1), + UIColor(red: 0.09, green: 0.62, blue: 0.80, alpha: 1) + ] + return palette[index % palette.count] + } + private func makeSection(for segment: PlanRouteSegment, multipleSegments: Bool) -> SectionModel { var rows: [Row] = [] + let color = Self.segmentColor(at: segment.index) for group in segment.groups { - let color = group.appMode?.getProfileColor() ?? .iconColorActive if segment.multiMode, group.appMode != nil { rows.append(.profileGroup(group, segment: segment)) } @@ -143,7 +155,7 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent children.append(UIAction(title: localizedString("change_mode"), subtitle: segment.singleMode?.toHumanString(), image: segment.singleMode?.getIcon()) { [weak self] _ in - self?.presentModePicker(pointIndex: segment.pointIndexes.last ?? 0, wholeRoute: true) + self?.presentRouteBetweenPoints() }) } children.append(makeSortMenu(pointIndexes: segment.pointIndexes)) @@ -164,7 +176,7 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent let changeRouteType = UIAction(title: localizedString("change_mode"), subtitle: group.appMode?.toHumanString(), image: group.appMode?.getIcon()) { [weak self] _ in - self?.presentModePicker(pointIndex: group.lastPointIndex, wholeRoute: false) + self?.presentRouteBetweenPoints() } let deleteSection = UIAction(title: localizedString("delete_section"), image: .templateImageNamed("ic_custom_trash_outlined"), @@ -177,7 +189,7 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent private func makeRouteTypeMenu(pointIndex: Int) -> UIMenu { let changeRouteType = UIAction(title: localizedString("change_mode"), image: .templateImageNamed("ic_custom_point_to_point")) { [weak self] _ in - self?.presentModePicker(pointIndex: pointIndex, wholeRoute: true) + self?.presentRouteBetweenPoints() } return UIMenu(children: [changeRouteType]) } @@ -195,17 +207,16 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent return UIMenu(title: localizedString("shared_string_sort"), image: sortImage, children: [manual, doorToDoor]) } - private func presentModePicker(pointIndex: Int, wholeRoute: Bool) { + private func presentRouteBetweenPoints() { guard let dataSource else { return } - let alert = UIAlertController(title: localizedString("change_mode"), message: nil, preferredStyle: .actionSheet) - for mode in dataSource.availableModes { - alert.addAction(UIAlertAction(title: mode.toHumanString(), style: .default) { [weak self] _ in - dataSource.applyMode(mode, pointIndex: pointIndex, wholeRoute: wholeRoute) - self?.reloadData() - }) + let listVC = RouteBetweenPointsViewController(dataSource: dataSource) + let navController = UINavigationController(rootViewController: listVC) + navController.modalPresentationStyle = .pageSheet + if let sheet = navController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true } - alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) - present(alert, animated: true) + present(navController, animated: true) } private func setSingleMode(for segment: PlanRouteSegment) { @@ -286,7 +297,8 @@ extension PlanRouteRouteViewController: UITableViewDataSource { // MARK: - UITableViewDelegate extension PlanRouteRouteViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - sections[section].headerTitle != nil ? 44 : 0 + guard sections[section].headerTitle != nil else { return 0 } + return sections[section].headerSubtitle != nil ? 60 : 44 } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { @@ -305,8 +317,13 @@ extension PlanRouteRouteViewController: UITableViewDelegate { startNewSegment() return } - if case let .point(point, _) = section.rows[indexPath.row] { + switch section.rows[indexPath.row] { + case let .point(point, _): dataSource?.selectRoutePoint(at: point.index) + case .profileGroup: + presentRouteBetweenPoints() + case .empty: + break } } diff --git a/Sources/OsmAnd Maps-Bridging-Header.h b/Sources/OsmAnd Maps-Bridging-Header.h index d219150c0b..2d63a2e847 100644 --- a/Sources/OsmAnd Maps-Bridging-Header.h +++ b/Sources/OsmAnd Maps-Bridging-Header.h @@ -185,6 +185,7 @@ #import "OARoutePlanningHudViewController.h" #import "OAPlanRouteEditingBridge.h" #import "OASaveTrackViewController.h" +#import "OAOpenAddTrackViewController.h" #import "OASelectTrackFolderViewController.h" #import "OARecordSettingsBottomSheetViewController.h" #import "OAAlertBottomSheetViewController.h" From e26b1e3ec8ba44e1ed4729f563800c59bbf21d88 Mon Sep 17 00:00:00 2001 From: Dmitry Svetlichny Date: Wed, 24 Jun 2026 16:36:06 +0300 Subject: [PATCH 38/47] Add openAddPoi --- .../PlanRoute/OAPlanRouteEditingBridge.h | 3 +- .../PlanRoute/OAPlanRouteEditingBridge.mm | 44 +++++++++++++++++++ .../PlanRouteEditingContextDataProvider.swift | 7 +++ .../PlanRoute/PlanRouteModels.swift | 2 + .../PlanRouteScrollableViewController.swift | 3 +- .../TargetMenu/OAEditPointViewController.mm | 2 +- 6 files changed, 57 insertions(+), 4 deletions(-) diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h index 3fedb3fd58..b08d760f00 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -11,7 +11,7 @@ NS_ASSUME_NONNULL_BEGIN -@class OAApplicationMode; +@class OAApplicationMode, UIViewController; @interface OAPlanRoutePointData : NSObject @@ -68,6 +68,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)redo; - (void)reverseRoute; - (void)clearAllPoints; +- (void)openAddPoiWithFilePath:(NSString *)filePath presentingViewController:(UIViewController *)presentingViewController; - (NSArray *)buildSegments; - (NSArray *)availableModes; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm index bb412ec43d..2028446a92 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -12,6 +12,7 @@ #import "OARootViewController.h" #import "OAMapPanelViewController.h" #import "OAMapViewController.h" +#import "OAMapRendererView.h" #import "OAMapLayers.h" #import "OAMeasurementToolLayer.h" #import "OAMeasurementEditingContext.h" @@ -33,6 +34,8 @@ #import "OsmAndApp.h" #import "OAMapActions.h" #import "OAAppSettings.h" +#import "OAEditPointViewController.h" +#import "OANativeUtilities.h" @class OAMeasurementToolLayer, OAMeasurementEditingContext; @@ -77,6 +80,7 @@ - (OAMeasurementToolLayer *)layer; - (OAMeasurementEditingContext *)editingContext; - (double)distanceFrom:(OASWptPt *)from to:(OASWptPt *)to; - (double)bearingFrom:(OASWptPt *)from to:(OASWptPt *)to; +- (CLLocationCoordinate2D)crosshairLocation; - (OAPlanRouteSegmentData *)buildSegmentWithIndex:(NSInteger)segmentIndex pointIndexes:(NSArray *)pointIndexes allPoints:(NSArray *)allPoints; @@ -553,6 +557,46 @@ - (void)clearAllPoints self.onChange(); } +- (void)openAddPoiWithFilePath:(NSString *)filePath presentingViewController:(UIViewController *)presentingViewController +{ + if (filePath.length == 0 || presentingViewController == nil) + return; + + CLLocationCoordinate2D location = [self crosshairLocation]; + if (!CLLocationCoordinate2DIsValid(location)) + return; + + NSString *gpxFilePath = filePath.isAbsolutePath ? filePath : [OsmAndApp.instance.gpxPath stringByAppendingPathComponent:filePath]; + OAEditPointViewController *controller = [[OAEditPointViewController alloc] initWithLocation:location title:OALocalizedString(@"shared_string_waypoint") address:nil customParam:gpxFilePath pointType:EOAEditPointTypeWaypoint targetMenuState:nil poi:nil]; + controller.gpxWptDelegate = (id)[OARootViewController instance].mapPanel; + UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:controller]; + [presentingViewController presentViewController:navigationController animated:YES completion:nil]; +} + +- (CLLocationCoordinate2D)crosshairLocation +{ + OAMapRendererView *mapView = [OARootViewController instance].mapPanel.mapViewController.mapView; + if (mapView == nil) + return kCLLocationCoordinate2DInvalid; + + OAMeasurementToolLayer *layer = [self layer]; + OsmAnd::PointI location31; + if (layer != nil && !CGPointEqualToPoint(layer.cursorScreenPoint, CGPointZero)) + { + CGFloat scale = mapView.contentScaleFactor; + CGPoint scaledPoint = CGPointMake(layer.cursorScreenPoint.x * scale, layer.cursorScreenPoint.y * scale); + [mapView convert:scaledPoint toLocation:&location31]; + } + else + { + const auto center = [mapView getCenterPixel]; + location31 = [OANativeUtilities get31FromElevatedPixel:center]; + } + + const auto latLon = OsmAnd::Utilities::convert31ToLatLon(location31); + return CLLocationCoordinate2DMake(latLon.latitude, latLon.longitude); +} + - (void)saveAs:(NSString *)fileName folder:(nullable NSString *)folder showOnMap:(BOOL)showOnMap diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index dc62dfcc43..83cbfeab53 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -14,9 +14,11 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { var onDataChanged: (() -> Void)? private let bridge = OAPlanRouteEditingBridge() + private let filePath: String? init(mode: PlanRouteMode = .newRoute, filePath: String? = nil) { self.mode = mode + self.filePath = filePath bridge.onChange = { [weak self] in self?.onDataChanged?() } if mode.isEditTrack, let filePath { bridge.openTrack(withFilePath: filePath) @@ -74,6 +76,11 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { bridge.addCenterPoint() } + func openAddPoi(from presentingViewController: UIViewController) { + guard mode.isEditTrack, let filePath, !filePath.isEmpty else { return } + bridge.openAddPoi(withFilePath: filePath, presenting: presentingViewController) + } + func undo() { bridge.undo() } diff --git a/Sources/Controllers/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/PlanRoute/PlanRouteModels.swift index 2a50e67edf..e9a56c0743 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteModels.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteModels.swift @@ -244,6 +244,8 @@ enum SegmentRouteContext { protocol PlanRoutePoiDataSource: AnyObject { var poiPoints: [PlanRoutePoint] { get } + + func openAddPoi(from presentingViewController: UIViewController) } protocol PlanRouteAnalyzeDataSource: AnyObject { diff --git a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift index 6a42c5009d..23c7f4679b 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift @@ -461,8 +461,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController } private func handleAddPoi() { - dataProvider.addRoutePoint() - reloadData() + dataProvider.openAddPoi(from: self) } private func handleUndo() { diff --git a/Sources/Controllers/TargetMenu/OAEditPointViewController.mm b/Sources/Controllers/TargetMenu/OAEditPointViewController.mm index 93335f2eb2..0ee81ac4ac 100644 --- a/Sources/Controllers/TargetMenu/OAEditPointViewController.mm +++ b/Sources/Controllers/TargetMenu/OAEditPointViewController.mm @@ -399,7 +399,7 @@ - (void) setupGroups { [names addObject:group[@"title"]]; [colors addObject:group[@"color"] ? [UIColor colorFromString:group[@"color"]] : [UIColor colorNamed:ACColorNameIconColorActive]]; - [sizes addObject:group[@"count"]]; + [sizes addObject:@([group[@"count"] integerValue])]; } } From 15bdcc0389545b6e729d1e6ed4c1f8f5f4557889 Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Wed, 24 Jun 2026 17:40:49 +0200 Subject: [PATCH 39/47] [WIP] UI Fixes --- .../PlanRoute/OAPlanRouteEditingBridge.h | 9 + .../PlanRoute/OAPlanRouteEditingBridge.mm | 187 +++++++++- .../PlanRouteEditingContextDataProvider.swift | 30 ++ .../PlanRoute/PlanRouteModels.swift | 8 + .../PlanRoutePointMenuViewController.swift | 345 ++++++++++++++++++ .../PlanRouteScrollableViewController.swift | 92 ++++- .../Tabs/PlanRouteRouteViewController.swift | 57 ++- 7 files changed, 708 insertions(+), 20 deletions(-) create mode 100644 Sources/Controllers/PlanRoute/PlanRoutePointMenuViewController.swift diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h index 3fedb3fd58..ed4b9f9744 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -47,6 +47,10 @@ NS_ASSUME_NONNULL_BEGIN @interface OAPlanRouteEditingBridge : NSObject @property (nonatomic, copy, nullable) void (^onChange)(void); +@property (nonatomic, copy, nullable) void (^onPointSelected)(NSInteger index); +@property (nonatomic, copy, nullable) void (^onChangeRouteTypeBefore)(NSInteger pointIndex); +@property (nonatomic, copy, nullable) void (^onChangeRouteTypeAfter)(NSInteger pointIndex); +@property (nonatomic, weak, nullable) UIViewController *presenterViewController; @property (nonatomic, readonly) BOOL hasContext; @property (nonatomic, readonly) BOOL hasPoints; @@ -79,6 +83,11 @@ NS_ASSUME_NONNULL_BEGIN - (void)applyMode:(OAApplicationMode *)mode pointIndex:(NSInteger)pointIndex wholeRoute:(BOOL)wholeRoute; - (void)sortSegmentDoorToDoorWithPointIndexes:(NSArray *)indexes; - (void)selectPointAtIndex:(NSInteger)index; +- (void)showPointOptionsAtIndex:(NSInteger)index NS_SWIFT_NAME(showPointOptions(at:)); +- (void)addPointBeforeIndex:(NSInteger)index NS_SWIFT_NAME(addPointBefore(index:)); +- (void)addPointAfterIndex:(NSInteger)index NS_SWIFT_NAME(addPointAfter(index:)); +- (void)trimBeforeIndex:(NSInteger)index NS_SWIFT_NAME(trimBefore(index:)); +- (void)trimAfterIndex:(NSInteger)index NS_SWIFT_NAME(trimAfter(index:)); - (void)saveAs:(NSString *)fileName folder:(nullable NSString *)folder diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm index bb412ec43d..f62b344e31 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -7,9 +7,11 @@ // #import "OAPlanRouteEditingBridge.h" +#import "OAPointOptionsBottomSheetViewController.h" #import #import "Localization.h" #import "OARootViewController.h" +#import "OAMapRendererView.h" #import "OAMapPanelViewController.h" #import "OAMapViewController.h" #import "OAMapLayers.h" @@ -67,7 +69,7 @@ - (instancetype)initWithIndex:(NSInteger)index @end -@interface OAPlanRouteEditingBridge () +@interface OAPlanRouteEditingBridge () { double _distanceToMapCenter; double _bearingToMapCenter; @@ -398,7 +400,11 @@ - (OAPlanRouteGroupData *)buildGroupWithKey:(NSString *)key { OAApplicationMode *appMode = nil; if (key.length > 0) - appMode = [OAApplicationMode valueOfStringKey:key def:OAApplicationMode.DEFAULT]; + { + OAApplicationMode *mode = [OAApplicationMode valueOfStringKey:key def:OAApplicationMode.DEFAULT]; + if (mode != [OAApplicationMode DEFAULT]) + appMode = mode; + } NSMutableArray *points = [NSMutableArray array]; double groupDistance = 0; @@ -516,6 +522,109 @@ - (void)selectPointAtIndex:(NSInteger)index if (ctx == nil) return; ctx.selectedPointPosition = index; + if (self.onPointSelected) + self.onPointSelected(index); +} + +- (void)showPointOptionsAtIndex:(NSInteger)index +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil || index < 0 || index >= ctx.getPointsCount) + return; + ctx.selectedPointPosition = index; + OASWptPt *pt = ctx.getPoints[index]; + OAPointOptionsBottomSheetViewController *sheet = [[OAPointOptionsBottomSheetViewController alloc] + initWithPoint:pt + index:index + editingContext:ctx]; + sheet.delegate = self; + UIViewController *presenter = self.presenterViewController; + if (presenter) + [sheet presentInViewController:presenter]; +} + +- (NSInteger)findNearestPointToCoordinate:(CLLocationCoordinate2D)coordinate +{ + OAMapRendererView *mapView = [OARootViewController instance].mapPanel.mapViewController.mapView; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil || ctx.getPointsCount == 0 || mapView == nil) + return -1; + + CGPoint p0 = CGPointZero; + CGPoint p1 = CGPointMake(0., 44.); + OsmAnd::PointI ip0, ip1; + [mapView convert:p0 toLocation:&ip0]; + [mapView convert:p1 toLocation:&ip1]; + OsmAnd::LatLon ll0 = OsmAnd::Utilities::convert31ToLatLon(ip0); + OsmAnd::LatLon ll1 = OsmAnd::Utilities::convert31ToLatLon(ip1); + double hitRadius = [OAMapUtils getDistance:ll0.latitude lon1:ll0.longitude lat2:ll1.latitude lon2:ll1.longitude]; + + NSInteger nearest = -1; + double lowestDist = hitRadius; + for (NSInteger i = 0; i < ctx.getPointsCount; i++) + { + OASWptPt *pt = ctx.getPoints[i]; + double dist = [OAMapUtils getDistance:coordinate.latitude + lon1:coordinate.longitude + lat2:pt.getLatitude + lon2:pt.getLongitude]; + if (dist < lowestDist) + { + lowestDist = dist; + nearest = i; + } + } + return nearest; +} + +- (void)addPointBeforeIndex:(NSInteger)index +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + ctx.selectedPointPosition = index; + [layer addCenterPoint:YES]; + if (self.onChange) + self.onChange(); +} + +- (void)addPointAfterIndex:(NSInteger)index +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + ctx.selectedPointPosition = index; + [layer addCenterPoint:NO]; + if (self.onChange) + self.onChange(); +} + +- (void)trimBeforeIndex:(NSInteger)index +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + ctx.selectedPointPosition = index; + [ctx.commandManager execute:[[OAClearPointsCommand alloc] initWithMeasurementLayer:layer mode:EOAClearPointsModeBefore]]; + [layer updateLayer]; + if (self.onChange) + self.onChange(); +} + +- (void)trimAfterIndex:(NSInteger)index +{ + OAMeasurementToolLayer *layer = [self layer]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + ctx.selectedPointPosition = index; + [ctx.commandManager execute:[[OAClearPointsCommand alloc] initWithMeasurementLayer:layer mode:EOAClearPointsModeAfter]]; + [layer updateLayer]; + if (self.onChange) + self.onChange(); } - (double)distanceToMapCenter @@ -707,6 +816,14 @@ - (void)onTouch:(CLLocationCoordinate2D)coordinate longPress:(BOOL)longPress OAMeasurementEditingContext *ctx = [self editingContext]; if (ctx == nil) return; + + NSInteger hitIndex = [self findNearestPointToCoordinate:coordinate]; + if (hitIndex != -1) + { + [self showPointOptionsAtIndex:hitIndex]; + return; + } + layer.pressPointLocation = [[CLLocation alloc] initWithLatitude:coordinate.latitude longitude:coordinate.longitude]; [ctx.commandManager execute:[[OAAddPointCommand alloc] initWithLayer:layer center:NO]]; [layer updateLayer]; @@ -714,4 +831,70 @@ - (void)onTouch:(CLLocationCoordinate2D)coordinate longPress:(BOOL)longPress self.onChange(); } +#pragma mark - OAPointOptionsBottmSheetDelegate + +- (void)onMovePoint:(NSInteger)point +{ + if (self.onPointSelected) + self.onPointSelected(point); +} + +- (void)onClearPoints:(EOAClearPointsMode)mode +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + NSInteger idx = ctx.selectedPointPosition; + if (mode == EOAClearPointsModeBefore) + [self trimBeforeIndex:idx]; + else + [self trimAfterIndex:idx]; +} + +- (void)onAddPoints:(EOAAddPointMode)type +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + NSInteger idx = ctx.selectedPointPosition; + if (type == EOAAddPointModeBefore) + [self addPointBeforeIndex:idx]; + else + [self addPointAfterIndex:idx]; +} + +- (void)onDeletePoint +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil) + return; + [self deletePointAtIndex:ctx.selectedPointPosition]; +} + +- (void)onChangeRouteTypeBefore +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (self.onChangeRouteTypeBefore && ctx != nil) + self.onChangeRouteTypeBefore(ctx.selectedPointPosition); +} + +- (void)onChangeRouteTypeAfter +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (self.onChangeRouteTypeAfter && ctx != nil) + self.onChangeRouteTypeAfter(ctx.selectedPointPosition); +} + +- (void)onSplitPointsBefore {} +- (void)onSplitPointsAfter {} +- (void)onJoinPoints {} +- (void)onCloseMenu {} + +- (void)onClearSelection +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx) + ctx.selectedPointPosition = -1; +} + @end diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index dc62dfcc43..c17fe109c7 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -12,6 +12,15 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { let mode: PlanRouteMode var onDataChanged: (() -> Void)? + var onPointSelected: ((Int) -> Void)? { + didSet { bridge.onPointSelected = onPointSelected } + } + var onChangeRouteTypeBefore: ((Int) -> Void)? { + didSet { bridge.onChangeRouteTypeBefore = onChangeRouteTypeBefore } + } + var onChangeRouteTypeAfter: ((Int) -> Void)? { + didSet { bridge.onChangeRouteTypeAfter = onChangeRouteTypeAfter } + } private let bridge = OAPlanRouteEditingBridge() @@ -110,6 +119,11 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { bridge.dismiss() } + func showPointOptions(index: Int, in viewController: UIViewController) { + bridge.presenterViewController = viewController + bridge.showPointOptions(at: index) + } + func moveRoutePoint(from: Int, to: Int) { bridge.movePoint(from: from, to: to) } @@ -146,6 +160,22 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { bridge.selectPoint(at: index) } + func addPointBefore(index: Int) { + bridge.addPointBefore(index: index) + } + + func addPointAfter(index: Int) { + bridge.addPointAfter(index: index) + } + + func trimBefore(index: Int) { + bridge.trimBefore(index: index) + } + + func trimAfter(index: Int) { + bridge.trimAfter(index: index) + } + func routingParams(for context: SegmentRouteContext) -> PlanRouteSegmentRoutingParams { PlanRouteSegmentRoutingParams(useElevationData: false, considerTemporaryLimitations: true) } diff --git a/Sources/Controllers/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/PlanRoute/PlanRouteModels.swift index 2a50e67edf..0552a653a7 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteModels.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteModels.swift @@ -271,6 +271,10 @@ protocol PlanRoutePointsDataSource: AnyObject { func sortDoorToDoor(pointIndexes: [Int]) func saveSegment(pointIndexes: [Int]) func selectRoutePoint(at index: Int) + func addPointBefore(index: Int) + func addPointAfter(index: Int) + func trimBefore(index: Int) + func trimAfter(index: Int) func routingParams(for context: SegmentRouteContext) -> PlanRouteSegmentRoutingParams func applyRoutingParams(_ params: PlanRouteSegmentRoutingParams, for context: SegmentRouteContext) } @@ -288,9 +292,13 @@ protocol PlanRouteDataProvider: PlanRoutePoiDataSource, PlanRouteAnalyzeDataSour var canUndo: Bool { get } var canRedo: Bool { get } var onDataChanged: (() -> Void)? { get set } + var onPointSelected: ((Int) -> Void)? { get set } + var onChangeRouteTypeBefore: ((Int) -> Void)? { get set } + var onChangeRouteTypeAfter: ((Int) -> Void)? { get set } func setCrosshairPosition(screenPoint: CGPoint) func dismissLayer() + func showPointOptions(index: Int, in viewController: UIViewController) } protocol PlanRouteTabContent: AnyObject { diff --git a/Sources/Controllers/PlanRoute/PlanRoutePointMenuViewController.swift b/Sources/Controllers/PlanRoute/PlanRoutePointMenuViewController.swift new file mode 100644 index 0000000000..24b311ee4d --- /dev/null +++ b/Sources/Controllers/PlanRoute/PlanRoutePointMenuViewController.swift @@ -0,0 +1,345 @@ +// +// PlanRoutePointMenuViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 24.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class PlanRoutePointMenuViewController: UIViewController { + + private enum Section: Int, CaseIterable { + case movePoint + case addPoint + case trim + case changeRouteType + case delete + } + + fileprivate enum Row { + case movePoint + case addBefore + case addAfter + case trimBefore + case trimAfter + case changeTypeBefore + case changeTypeAfter + case delete + } + + fileprivate struct RowModel { + let row: Row + let title: String + let subtitle: String? + let icon: UIImage? + let isEnabled: Bool + let isDestructive: Bool + } + + private let point: PlanRoutePoint + private let segment: PlanRouteSegment + private let group: PlanRouteProfileGroup + private weak var dataSource: PlanRoutePointsDataSource? + + var onChangeRouteType: ((SegmentRouteContext) -> Void)? + var onDismissed: (() -> Void)? + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private var sections: [[RowModel]] = [] + + init(point: PlanRoutePoint, + segment: PlanRouteSegment, + group: PlanRouteProfileGroup, + dataSource: PlanRoutePointsDataSource?) { + self.point = point + self.segment = segment + self.group = group + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setupTableView() + setupCancelButton() + buildSections() + } + + private func setupNavigationBar() { + title = point.name + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "xmark"), + style: .plain, + target: self, + action: #selector(onCloseTapped) + ) + navigationItem.leftBarButtonItem?.tintColor = .textColorPrimary + } + + private func setupTableView() { + view.backgroundColor = .viewBg + tableView.backgroundColor = .viewBg + tableView.dataSource = self + tableView.delegate = self + tableView.sectionHeaderTopPadding = 0 + tableView.register(PlanRouteMenuActionCell.self, forCellReuseIdentifier: PlanRouteMenuActionCell.reuseId) + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -80) + ]) + } + + private func setupCancelButton() { + let cancelButton = UIButton(type: .system) + cancelButton.setTitle(localizedString("shared_string_cancel"), for: .normal) + cancelButton.setTitleColor(.iconColorActive, for: .normal) + cancelButton.titleLabel?.font = .scaledSystemFont(ofSize: 17, weight: .medium) + cancelButton.backgroundColor = .groupBg + cancelButton.layer.cornerRadius = 16 + cancelButton.translatesAutoresizingMaskIntoConstraints = false + cancelButton.addTarget(self, action: #selector(onCloseTapped), for: .touchUpInside) + view.addSubview(cancelButton) + NSLayoutConstraint.activate([ + cancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + cancelButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + cancelButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8), + cancelButton.heightAnchor.constraint(equalToConstant: 50) + ]) + } + + private func buildSections() { + let groupIndex = segment.groups.firstIndex(where: { $0.lastPointIndex == group.lastPointIndex }) ?? 0 + let prevGroup: PlanRouteProfileGroup? = groupIndex > 0 ? segment.groups[groupIndex - 1] : nil + + let trimBeforeSubtitle: String? = point.isStart ? localizedString("start_point") : nil + let trimAfterSubtitle: String? = point.isDestination ? localizedString("route_descr_destination") : formattedDistance(point.distanceFromPrevious) + + let changeTypeBeforeIcon = prevGroup?.appMode?.getIcon() ?? .templateImageNamed("ic_custom_straight_line") + let changeTypeAfterIcon = group.appMode?.getIcon() ?? .templateImageNamed("ic_custom_straight_line") + + sections = [ + [RowModel(row: .movePoint, + title: localizedString("plan_route_move_point"), + subtitle: nil, + icon: .templateImageNamed("ic_custom_move"), + isEnabled: true, + isDestructive: false)], + [RowModel(row: .addBefore, + title: localizedString("plan_route_add_point_before"), + subtitle: nil, + icon: .templateImageNamed("ic_custom_add_point_before"), + isEnabled: true, + isDestructive: false), + RowModel(row: .addAfter, + title: localizedString("plan_route_add_point_after"), + subtitle: nil, + icon: .templateImageNamed("ic_custom_add_point_after"), + isEnabled: true, + isDestructive: false)], + [RowModel(row: .trimBefore, + title: localizedString("plan_route_trim_before"), + subtitle: trimBeforeSubtitle, + icon: .templateImageNamed("ic_custom_trim_before"), + isEnabled: !point.isStart, + isDestructive: false), + RowModel(row: .trimAfter, + title: localizedString("plan_route_trim_after"), + subtitle: trimAfterSubtitle, + icon: .templateImageNamed("ic_custom_trim_after"), + isEnabled: !point.isDestination, + isDestructive: false)], + [RowModel(row: .changeTypeBefore, + title: localizedString("plan_route_change_route_type_before"), + subtitle: nil, + icon: changeTypeBeforeIcon, + isEnabled: !point.isStart, + isDestructive: false), + RowModel(row: .changeTypeAfter, + title: localizedString("plan_route_change_route_type_after"), + subtitle: nil, + icon: changeTypeAfterIcon, + isEnabled: true, + isDestructive: false)], + [RowModel(row: .delete, + title: localizedString("plan_route_delete_point"), + subtitle: nil, + icon: .templateImageNamed("ic_custom_trash_outlined"), + isEnabled: true, + isDestructive: true)] + ] + tableView.reloadData() + } + + private func handle(row: Row) { + switch row { + case .movePoint: + dataSource?.selectRoutePoint(at: point.index) + dismiss(animated: true) + case .addBefore: + dataSource?.addPointBefore(index: point.index) + dismiss(animated: true) + case .addAfter: + dataSource?.addPointAfter(index: point.index) + dismiss(animated: true) + case .trimBefore: + dataSource?.trimBefore(index: point.index) + dismiss(animated: true) + case .trimAfter: + dataSource?.trimAfter(index: point.index) + dismiss(animated: true) + case .changeTypeBefore: + let groupIndex = segment.groups.firstIndex(where: { $0.lastPointIndex == group.lastPointIndex }) ?? 0 + if groupIndex > 0 { + let prevGroup = segment.groups[groupIndex - 1] + let context = SegmentRouteContext.profileGroup(prevGroup, segment: segment) + dismiss(animated: true) { [weak self] in + self?.onChangeRouteType?(context) + } + } + case .changeTypeAfter: + let context = SegmentRouteContext.profileGroup(group, segment: segment) + dismiss(animated: true) { [weak self] in + self?.onChangeRouteType?(context) + } + case .delete: + dataSource?.deleteRoutePoint(at: point.index) + dismiss(animated: true) + } + } + + private func formattedDistance(_ meters: Double) -> String { + OAOsmAndFormatter.getFormattedDistance(Float(meters)) ?? "" + } + + @objc private func onCloseTapped() { + dismiss(animated: true) { [weak self] in + self?.onDismissed?() + } + } +} + +// MARK: - UITableViewDataSource +extension PlanRoutePointMenuViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + sections[section].count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let model = sections[indexPath.section][indexPath.row] + guard let cell = tableView.dequeueReusableCell(withIdentifier: PlanRouteMenuActionCell.reuseId, for: indexPath) as? PlanRouteMenuActionCell else { + return UITableViewCell() + } + cell.configure(model: model) + return cell + } +} + +// MARK: - UITableViewDelegate +extension PlanRoutePointMenuViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + section == 0 ? 0 : 8 + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + nil + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let model = sections[indexPath.section][indexPath.row] + guard model.isEnabled else { return } + handle(row: model.row) + } +} + +// MARK: - PlanRouteMenuActionCell + +private final class PlanRouteMenuActionCell: UITableViewCell { + static let reuseId = "PlanRouteMenuActionCell" + + private static let iconSize: CGFloat = 24 + + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let iconView = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(model: PlanRoutePointMenuViewController.RowModel) { + let titleColor: UIColor + if model.isDestructive { + titleColor = .systemOrange + } else if model.isEnabled { + titleColor = .textColorPrimary + } else { + titleColor = .textColorSecondary + } + titleLabel.text = model.title + titleLabel.textColor = titleColor + + subtitleLabel.text = model.subtitle + subtitleLabel.isHidden = model.subtitle == nil + + iconView.image = model.icon?.withRenderingMode(.alwaysTemplate) + iconView.tintColor = model.isDestructive ? .systemOrange : + (model.isEnabled ? .iconColorActive : .iconColorTertiary) + isUserInteractionEnabled = model.isEnabled || model.isDestructive + } + + private func setupCell() { + backgroundColor = .groupBg + selectionStyle = .default + + titleLabel.font = .scaledSystemFont(ofSize: 17) + titleLabel.numberOfLines = 1 + + subtitleLabel.font = .scaledSystemFont(ofSize: 13) + subtitleLabel.textColor = .textColorSecondary + subtitleLabel.numberOfLines = 1 + + iconView.contentMode = .scaleAspectFit + + let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + textStack.axis = .vertical + textStack.spacing = 2 + + [textStack, iconView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + NSLayoutConstraint.activate([ + textStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + textStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + textStack.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 12), + textStack.trailingAnchor.constraint(lessThanOrEqualTo: iconView.leadingAnchor, constant: -8), + + iconView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: Self.iconSize), + iconView.heightAnchor.constraint(equalToConstant: Self.iconSize) + ]) + } +} diff --git a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift index 6a42c5009d..82fbdd61c2 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift @@ -41,6 +41,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController private var crosshairCenterYConstraint: NSLayoutConstraint? private var panStartHeight: CGFloat = 0 private weak var currentTabViewController: UIViewController? + private let routeTypeButton = PlanRouteButtonFactory.iconButton(image: .templateImageNamed("ic_custom_straight_line")) init(dataProvider: PlanRouteDataProvider) { self.dataProvider = dataProvider @@ -79,8 +80,12 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController setupBottomToolbar() setupContent() setupTopToolbar() + setupRouteTypeButton() setupCrosshair() dataProvider.onDataChanged = { [weak self] in self?.reloadData() } + dataProvider.onPointSelected = { [weak self] index in self?.presentPointMenu(for: index) } + dataProvider.onChangeRouteTypeBefore = { [weak self] pointIndex in self?.presentChangeRouteType(before: pointIndex) } + dataProvider.onChangeRouteTypeAfter = { [weak self] pointIndex in self?.presentChangeRouteType(after: pointIndex) } selectTab(.default) reloadData() } @@ -163,6 +168,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController topPartView.configure(with: dataProvider.routeInfo) bottomToolbar.isUndoEnabled = dataProvider.canUndo bottomToolbar.isRedoEnabled = dataProvider.canRedo + updateRouteTypeButton() currentTabViewController.flatMap { $0 as? PlanRouteTabContent }?.reloadData() } @@ -299,6 +305,85 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController ]) } + private func setupRouteTypeButton() { + routeTypeButton.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(routeTypeButton, belowSubview: sheetView) + NSLayoutConstraint.activate([ + routeTypeButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 8), + routeTypeButton.bottomAnchor.constraint(equalTo: sheetView.topAnchor, constant: -12) + ]) + routeTypeButton.addTarget(self, action: #selector(onRouteTypeButtonTapped), for: .touchUpInside) + updateRouteTypeButton() + } + + private func updateRouteTypeButton() { + let segments = dataProvider.routeSegments + let mode = segments.last?.singleMode + let icon: UIImage? + if let mode { + icon = mode.getIcon()?.withRenderingMode(.alwaysTemplate) + } else { + icon = .templateImageNamed("ic_custom_straight_line") + } + routeTypeButton.setImage(icon, for: .normal) + } + + private func presentRouteBetweenPoints() { + let listVC = RouteBetweenPointsViewController(dataSource: dataProvider) + let navController = UINavigationController(rootViewController: listVC) + navController.modalPresentationStyle = .pageSheet + if let sheet = navController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + present(navController, animated: true) + } + + func presentPointMenu(for pointIndex: Int) { + dataProvider.showPointOptions(index: pointIndex, in: self) + } + + private func presentChangeRouteType(before pointIndex: Int) { + let segments = dataProvider.routeSegments + guard let (segment, group, _) = findPointContext(index: pointIndex, in: segments) else { return } + let groupIndex = segment.groups.firstIndex(where: { $0.lastPointIndex == group.lastPointIndex }) ?? 0 + guard groupIndex > 0 else { return } + let prevGroup = segment.groups[groupIndex - 1] + presentSettingsForContext(.profileGroup(prevGroup, segment: segment)) + } + + private func presentChangeRouteType(after pointIndex: Int) { + let segments = dataProvider.routeSegments + guard let (segment, group, _) = findPointContext(index: pointIndex, in: segments) else { return } + presentSettingsForContext(.profileGroup(group, segment: segment)) + } + + private func presentSettingsForContext(_ context: SegmentRouteContext) { + let settingsVC = SegmentRouteSettingsViewController(context: context, dataSource: dataProvider) + let nav = UINavigationController(rootViewController: settingsVC) + nav.modalPresentationStyle = .pageSheet + if let sheet = nav.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + present(nav, animated: true) + } + + private func findPointContext(index: Int, in segments: [PlanRouteSegment]) -> (PlanRouteSegment, PlanRouteProfileGroup, PlanRoutePoint)? { + for segment in segments { + for group in segment.groups { + if let point = group.points.first(where: { $0.index == index }) { + return (segment, group, point) + } + } + } + return nil + } + + @objc private func onRouteTypeButtonTapped() { + presentRouteBetweenPoints() + } + private func crosshairCenterY(sheetHeight: CGFloat) -> CGFloat { let h = OAUtilities.calculateScreenHeight() if sheetHeight <= height(for: .initial) { @@ -391,7 +476,12 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController switch tab { case .poi: controller = PlanRoutePoiViewController(dataSource: dataProvider) case .analyze: controller = PlanRouteAnalyzeViewController(dataSource: dataProvider) - case .route: controller = PlanRouteRouteViewController(dataSource: dataProvider) + case .route: + let routeVC = PlanRouteRouteViewController(dataSource: dataProvider) + routeVC.onPointSelected = { [weak self] point, group, segment in + self?.presentPointMenu(for: point.index) + } + controller = routeVC } tabViewControllers[tab] = controller return controller diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift index f8bf6d71c2..74d397588e 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRouteRouteViewController.swift @@ -32,6 +32,9 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent private let tableView = UITableView(frame: .zero, style: .insetGrouped) private var sections: [SectionModel] = [] + private var pendingEmptySegmentIndex: Int? + + var onPointSelected: ((PlanRoutePoint, PlanRouteProfileGroup, PlanRouteSegment) -> Void)? init(dataSource: PlanRoutePointsDataSource?) { self.dataSource = dataSource @@ -50,6 +53,11 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent func reloadData() { guard isViewLoaded else { return } + if let pendingIndex = pendingEmptySegmentIndex, + let segments = dataSource?.routeSegments, + segments.count >= pendingIndex { + pendingEmptySegmentIndex = nil + } sections = buildSections() tableView.reloadData() } @@ -83,6 +91,7 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent private func buildSections() -> [SectionModel] { let segments = dataSource?.routeSegments ?? [] guard !segments.isEmpty else { + pendingEmptySegmentIndex = nil return [SectionModel(headerTitle: localizedString("route_points"), headerSubtitle: nil, headerMenu: makeRouteTypeMenu(pointIndex: 0), @@ -90,9 +99,17 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent isStartNewSegment: false)] } - let multipleSegments = segments.count > 1 + let multipleSegments = segments.count > 1 || pendingEmptySegmentIndex != nil var result: [SectionModel] = segments.map { makeSection(for: $0, multipleSegments: multipleSegments) } - if dataSource?.canStartNewSegment ?? false { + + if let pendingIndex = pendingEmptySegmentIndex { + let title = String(format: localizedString("segments_count"), pendingIndex) + result.append(SectionModel(headerTitle: title, + headerSubtitle: nil, + headerMenu: nil, + rows: [.empty], + isStartNewSegment: false)) + } else if dataSource?.canStartNewSegment ?? false { result.append(SectionModel(headerTitle: nil, headerSubtitle: nil, headerMenu: nil, @@ -102,26 +119,17 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent return result } - private static func segmentColor(at index: Int) -> UIColor { - let palette: [UIColor] = [ - .iconColorActive, - .iconColorGreen, - UIColor(red: 0.96, green: 0.60, blue: 0.13, alpha: 1), - UIColor(red: 0.88, green: 0.24, blue: 0.24, alpha: 1), - UIColor(red: 0.42, green: 0.27, blue: 0.80, alpha: 1), - UIColor(red: 0.09, green: 0.62, blue: 0.80, alpha: 1) - ] - return palette[index % palette.count] - } - private func makeSection(for segment: PlanRouteSegment, multipleSegments: Bool) -> SectionModel { var rows: [Row] = [] - let color = Self.segmentColor(at: segment.index) + let segmentColor = segment.singleMode?.getProfileColor() ?? .iconColorActive for group in segment.groups { if segment.multiMode, group.appMode != nil { rows.append(.profileGroup(group, segment: segment)) } - rows.append(contentsOf: group.points.map { Row.point($0, color: color) }) + let groupColor = segment.multiMode + ? (group.appMode?.getProfileColor() ?? .iconColorActive) + : segmentColor + rows.append(contentsOf: group.points.map { Row.point($0, color: groupColor) }) } let title: String @@ -236,10 +244,23 @@ final class PlanRouteRouteViewController: UIViewController, PlanRouteTabContent } private func startNewSegment() { + let nextIndex = (dataSource?.routeSegments.count ?? 0) + 1 + pendingEmptySegmentIndex = nextIndex dataSource?.startNewSegment() reloadData() } + private func findSegmentAndGroup(for pointIndex: Int) -> (PlanRouteSegment, PlanRouteProfileGroup)? { + for segment in dataSource?.routeSegments ?? [] { + for group in segment.groups { + if group.points.contains(where: { $0.index == pointIndex }) { + return (segment, group) + } + } + } + return nil + } + private func formattedDistance(_ meters: Double) -> String { OAOsmAndFormatter.getFormattedDistance(Float(meters)) ?? "" } @@ -319,7 +340,9 @@ extension PlanRouteRouteViewController: UITableViewDelegate { } switch section.rows[indexPath.row] { case let .point(point, _): - dataSource?.selectRoutePoint(at: point.index) + if let (seg, grp) = findSegmentAndGroup(for: point.index) { + onPointSelected?(point, grp, seg) + } case .profileGroup: presentRouteBetweenPoints() case .empty: From 300d03a7d6e9b7f24b586fd86c2bb61dfa170bf9 Mon Sep 17 00:00:00 2001 From: Dmitry Svetlichny Date: Thu, 25 Jun 2026 13:08:25 +0300 Subject: [PATCH 40/47] Add POI to plan GPX --- .../PlanRoute/OAPlanRouteEditingBridge.h | 2 +- .../PlanRoute/OAPlanRouteEditingBridge.mm | 104 +++++++++++++++++- .../PlanRouteEditingContextDataProvider.swift | 2 +- 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h index ad254837b8..f5e2fc14c6 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -72,7 +72,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)redo; - (void)reverseRoute; - (void)clearAllPoints; -- (void)openAddPoiWithFilePath:(NSString *)filePath presentingViewController:(UIViewController *)presentingViewController; +- (void)openAddPoiWithFilePath:(nullable NSString *)filePath presentingViewController:(UIViewController *)presentingViewController; - (NSArray *)buildSegments; - (NSArray *)availableModes; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm index 86cd1b9ad1..9f83631dcf 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -38,6 +38,7 @@ #import "OAAppSettings.h" #import "OAEditPointViewController.h" #import "OANativeUtilities.h" +#import "OAGpxWptItem.h" @class OAMeasurementToolLayer, OAMeasurementEditingContext; @@ -72,8 +73,10 @@ - (instancetype)initWithIndex:(NSInteger)index @end -@interface OAPlanRouteEditingBridge () +@interface OAPlanRouteEditingBridge () { + OASGpxFile *_draftGpxFile; + NSString *_draftGpxPath; double _distanceToMapCenter; double _bearingToMapCenter; } @@ -83,6 +86,10 @@ - (OAMeasurementEditingContext *)editingContext; - (double)distanceFrom:(OASWptPt *)from to:(OASWptPt *)to; - (double)bearingFrom:(OASWptPt *)from to:(OASWptPt *)to; - (CLLocationCoordinate2D)crosshairLocation; +- (OASGpxFile *)gpxFileForWaypoints; +- (void)refreshDraftGpx; +- (void)clearDraftGpx; +- (void)addDraftWaypointsToGpx:(OASGpxFile *)gpx; - (OAPlanRouteSegmentData *)buildSegmentWithIndex:(NSInteger)segmentIndex pointIndexes:(NSArray *)pointIndexes allPoints:(NSArray *)allPoints; @@ -251,6 +258,7 @@ - (void)setCrosshairScreenPoint:(CGPoint)point - (void)dismiss { + [self clearDraftGpx]; OAMeasurementToolLayer *layer = [self layer]; if (layer == nil) return; @@ -668,20 +676,103 @@ - (void)clearAllPoints - (void)openAddPoiWithFilePath:(NSString *)filePath presentingViewController:(UIViewController *)presentingViewController { - if (filePath.length == 0 || presentingViewController == nil) + if (presentingViewController == nil) return; CLLocationCoordinate2D location = [self crosshairLocation]; if (!CLLocationCoordinate2DIsValid(location)) return; - NSString *gpxFilePath = filePath.isAbsolutePath ? filePath : [OsmAndApp.instance.gpxPath stringByAppendingPathComponent:filePath]; + BOOL newRoute = filePath.length == 0; + NSString *gpxFilePath = newRoute ? [self gpxFileForWaypoints].path : (filePath.isAbsolutePath ? filePath : [OsmAndApp.instance.gpxPath stringByAppendingPathComponent:filePath]); + if (gpxFilePath.length == 0) + return; + OAEditPointViewController *controller = [[OAEditPointViewController alloc] initWithLocation:location title:OALocalizedString(@"shared_string_waypoint") address:nil customParam:gpxFilePath pointType:EOAEditPointTypeWaypoint targetMenuState:nil poi:nil]; - controller.gpxWptDelegate = (id)[OARootViewController instance].mapPanel; + controller.gpxWptDelegate = newRoute ? self : (id)[OARootViewController instance].mapPanel; UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:controller]; [presentingViewController presentViewController:navigationController animated:YES completion:nil]; } +- (OASGpxFile *)gpxFileForWaypoints +{ + if ([self editingContext] == nil) + return nil; + + if (_draftGpxFile == nil) + { + _draftGpxFile = [[OASGpxFile alloc] initWithAuthor:[OAAppVersion getFullVersionWithAppName]]; + NSString *folderPath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"OsmAndPlanRoute"]; + NSFileManager *fileManager = NSFileManager.defaultManager; + [fileManager removeItemAtPath:folderPath error:nil]; + [fileManager createDirectoryAtPath:folderPath withIntermediateDirectories:YES attributes:nil error:nil]; + _draftGpxPath = [folderPath stringByAppendingPathComponent:[[NSString stringWithFormat:@"plan_route_%@", [NSUUID UUID].UUIDString] stringByAppendingPathExtension:@"gpx"]]; + _draftGpxFile.path = _draftGpxPath; + OASKFile *file = [[OASKFile alloc] initWithFilePath:_draftGpxPath]; + [OASGpxUtilities.shared writeGpxFileFile:file gpxFile:_draftGpxFile]; + } + + return _draftGpxFile; +} + +- (void)refreshDraftGpx +{ + if (_draftGpxPath.length == 0 || _draftGpxFile == nil) + return; + + OAMapViewController *mapViewController = OARootViewController.instance.mapPanel.mapViewController; + [mapViewController hideTempGpxTrack:NO]; + OASKFile *file = [[OASKFile alloc] initWithFilePath:_draftGpxPath]; + [OASGpxUtilities.shared writeGpxFileFile:file gpxFile:_draftGpxFile]; + [mapViewController showTempGpxTrackFromGpxFile:_draftGpxFile]; +} + +- (void)clearDraftGpx +{ + if (_draftGpxPath.length == 0) + return; + + NSString *draftGpxPath = _draftGpxPath; + _draftGpxPath = nil; + _draftGpxFile = nil; + [OARootViewController.instance.mapPanel.mapViewController hideTempGpxTrack]; + [NSFileManager.defaultManager removeItemAtPath:draftGpxPath.stringByDeletingLastPathComponent error:nil]; +} + +- (void)addDraftWaypointsToGpx:(OASGpxFile *)gpx +{ + if (_draftGpxFile.getPointsList.count > 0) + [gpx addPointsCollection:_draftGpxFile.getPointsList]; +} + +#pragma mark - OAGpxWptEditingHandlerDelegate + +- (void)saveGpxWpt:(OAGpxWptItem *)gpxWpt gpxFileName:(NSString *)gpxFileName +{ + OASGpxFile *gpxFile = [self gpxFileForWaypoints]; + if (gpxFile == nil || gpxWpt.point == nil) + return; + + [gpxFile addPointPoint:[[OASWptPt alloc] initWithWptPt:gpxWpt.point]]; + [self refreshDraftGpx]; + if (self.onChange) + self.onChange(); +} + +- (void)updateGpxWpt:(OAGpxWptItem *)gpxWptItem docPath:(NSString *)docPath updateMap:(BOOL)updateMap +{ + [self refreshDraftGpx]; +} + +- (void)deleteGpxWpt:(OAGpxWptItem *)gpxWptItem docPath:(NSString *)docPath +{ +} + +- (void)saveItemToStorage:(OAGpxWptItem *)gpxWptItem +{ + [self refreshDraftGpx]; +} + - (CLLocationCoordinate2D)crosshairLocation { OAMapRendererView *mapView = [OARootViewController instance].mapPanel.mapViewController.mapView; @@ -741,6 +832,7 @@ - (void)performSaveWithFileName:(NSString *)fileName if (onComplete) onComplete(NO, nil); return; } + [self addDraftWaypointsToGpx:gpx]; NSString *gpxRootPath = OsmAndApp.instance.gpxPath; NSString *folderPath = (folder.length > 0) ? [gpxRootPath stringByAppendingPathComponent:folder] : gpxRootPath; NSString *outFile = [[folderPath stringByAppendingPathComponent:trackName] stringByAppendingPathExtension:@"gpx"]; @@ -771,6 +863,8 @@ - (void)performSaveWithFileName:(NSString *)fileName } } dispatch_async(dispatch_get_main_queue(), ^{ + if (success) + [self clearDraftGpx]; if (onComplete) onComplete(success, success ? outFile : nil); }); }); @@ -785,12 +879,14 @@ - (void)enterNavigationWithTrackName:(NSString *)trackName OASGpxFile *gpx = [ctx exportGpx:name]; if (gpx == nil) return; + [self addDraftWaypointsToGpx:gpx]; NSString *outFile = [[OsmAndApp.instance.gpxPath stringByAppendingPathComponent:name] stringByAppendingPathExtension:@"gpx"]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ OASKFile *file = [[OASKFile alloc] initWithFilePath:outFile]; [OASGpxUtilities.shared writeGpxFileFile:file gpxFile:gpx]; gpx.path = outFile; dispatch_async(dispatch_get_main_queue(), ^{ + [self clearDraftGpx]; [OARootViewController.instance.mapPanel.mapActions enterRoutePlanningModeGivenGpx:gpx path:outFile from:nil diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index 46c9da5760..2cddb61575 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -86,7 +86,7 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { } func openAddPoi(from presentingViewController: UIViewController) { - guard mode.isEditTrack, let filePath, !filePath.isEmpty else { return } + guard mode.isNewRoute || (filePath?.isEmpty == false) else { return } bridge.openAddPoi(withFilePath: filePath, presenting: presentingViewController) } From d51dd56828079b87f22c00f6221c70c7e02b56ac Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Thu, 25 Jun 2026 22:37:52 +0200 Subject: [PATCH 41/47] [WIP] Analyze tab --- .../en.lproj/Localizable.strings | 13 + .../GetElevationDataViewController.swift | 172 ++++++ .../PlanRouteEditingContextDataProvider.swift | 14 + .../PlanRoute/PlanRouteModels.swift | 19 + .../PlanRouteScrollableViewController.swift | 1 + .../Tabs/PlanRouteAnalyzeViewController.swift | 577 +++++++++++++++++- Sources/OsmAnd Maps-Bridging-Header.h | 3 + 7 files changed, 788 insertions(+), 11 deletions(-) create mode 100644 Sources/Controllers/PlanRoute/GetElevationDataViewController.swift diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index da9b7d3054..79866f233c 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -4795,3 +4795,16 @@ "selected_waypoints_exit_descr" = "Your waypoint selections will not be saved if you close now"; "auto_select_nearest_points" = "Auto-select nearest points"; "auto_select_nearest_footer" = "Automatically find and select the waypoints closest to this track."; + +"no_elevation_data" = "No elevation data"; +"no_elevation_data_description" = "OsmAnd can retrieve elevation data from nearby roads or terrain maps."; +"get_elevation_data" = "Get elevation data"; +"get_elevation_data_description" = "Select a method to retrieve altitude data from offline maps or terrain maps."; +"use_nearby_roads" = "Use nearby roads"; +"may_adjust_track_geometry" = "May adjust track geometry"; +"use_terrain_maps" = "Use Terrain maps"; +"track_geometry_stays_unchanged" = "Track geometry stays unchanged"; +"calculating_elevation" = "Calculating elevation..."; +"calculating_elevation_description" = "Retrieving altitude data from offline maps."; +"recalculate_elevation" = "Recalculate elevation"; +"average_speed" = "Avg. speed"; diff --git a/Sources/Controllers/PlanRoute/GetElevationDataViewController.swift b/Sources/Controllers/PlanRoute/GetElevationDataViewController.swift new file mode 100644 index 0000000000..47985bf098 --- /dev/null +++ b/Sources/Controllers/PlanRoute/GetElevationDataViewController.swift @@ -0,0 +1,172 @@ +// +// GetElevationDataViewController.swift +// OsmAnd Maps +// +// Created by OsmAnd on 25.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import UIKit + +final class GetElevationDataViewController: UIViewController { + + var onSelectMethod: ((Bool) -> Void)? + + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + private let separatorView = UIView() + private let stackView = UIStackView() + + override func viewDidLoad() { + super.viewDidLoad() + setupView() + } + + private func setupView() { + view.backgroundColor = .groupBg + + let closeButton = UIButton(type: .system) + closeButton.setImage(UIImage(systemName: "xmark"), for: .normal) + closeButton.tintColor = .iconColorDefault + closeButton.addTarget(self, action: #selector(onClose), for: .touchUpInside) + closeButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(closeButton) + + titleLabel.text = localizedString("get_elevation_data") + titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = .textColorPrimary + titleLabel.textAlignment = .center + titleLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(titleLabel) + + descriptionLabel.text = localizedString("get_elevation_data_description") + descriptionLabel.font = .preferredFont(forTextStyle: .footnote) + descriptionLabel.textColor = .textColorSecondary + descriptionLabel.textAlignment = .center + descriptionLabel.numberOfLines = 0 + descriptionLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(descriptionLabel) + + let optionsCard = UIView() + optionsCard.backgroundColor = .viewBg + optionsCard.layer.cornerRadius = 12 + optionsCard.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(optionsCard) + + let nearbyRoadsRow = makeOptionRow( + icon: .templateImageNamed("ic_custom_attach_track"), + title: localizedString("use_nearby_roads"), + subtitle: localizedString("may_adjust_track_geometry"), + useNearbyRoads: true + ) + + separatorView.backgroundColor = .customSeparator + separatorView.translatesAutoresizingMaskIntoConstraints = false + + let terrainRow = makeOptionRow( + icon: .templateImageNamed("ic_custom_terrain"), + title: localizedString("use_terrain_maps"), + subtitle: localizedString("track_geometry_stays_unchanged"), + useNearbyRoads: false + ) + + [nearbyRoadsRow, separatorView, terrainRow].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + optionsCard.addSubview($0) + } + + NSLayoutConstraint.activate([ + closeButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 16), + closeButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + closeButton.widthAnchor.constraint(equalToConstant: 32), + closeButton.heightAnchor.constraint(equalToConstant: 32), + + titleLabel.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), + titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16), + descriptionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + descriptionLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + optionsCard.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 16), + optionsCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + optionsCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + nearbyRoadsRow.topAnchor.constraint(equalTo: optionsCard.topAnchor), + nearbyRoadsRow.leadingAnchor.constraint(equalTo: optionsCard.leadingAnchor), + nearbyRoadsRow.trailingAnchor.constraint(equalTo: optionsCard.trailingAnchor), + + separatorView.topAnchor.constraint(equalTo: nearbyRoadsRow.bottomAnchor), + separatorView.leadingAnchor.constraint(equalTo: optionsCard.leadingAnchor, constant: 56), + separatorView.trailingAnchor.constraint(equalTo: optionsCard.trailingAnchor), + separatorView.heightAnchor.constraint(equalToConstant: 0.5), + + terrainRow.topAnchor.constraint(equalTo: separatorView.bottomAnchor), + terrainRow.leadingAnchor.constraint(equalTo: optionsCard.leadingAnchor), + terrainRow.trailingAnchor.constraint(equalTo: optionsCard.trailingAnchor), + terrainRow.bottomAnchor.constraint(equalTo: optionsCard.bottomAnchor) + ]) + } + + private func makeOptionRow(icon: UIImage?, title: String, subtitle: String, useNearbyRoads: Bool) -> UIView { + let row = UIView() + + let iconView = UIImageView(image: icon) + iconView.tintColor = .iconColorActive + iconView.contentMode = .scaleAspectFit + iconView.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .regular) + titleLabel.textColor = .textColorPrimary + + let subtitleLabel = UILabel() + subtitleLabel.text = subtitle + subtitleLabel.font = .preferredFont(forTextStyle: .footnote) + subtitleLabel.textColor = .textColorSecondary + + let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + textStack.axis = .vertical + textStack.spacing = 2 + + [iconView, textStack].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + row.addSubview($0) + } + + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 16), + iconView.centerYAnchor.constraint(equalTo: row.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 24), + iconView.heightAnchor.constraint(equalToConstant: 24), + + textStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 16), + textStack.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -16), + textStack.topAnchor.constraint(equalTo: row.topAnchor, constant: 12), + textStack.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -12) + ]) + + let tap = UITapGestureRecognizer(target: self, action: useNearbyRoads ? #selector(onNearbyRoads) : #selector(onTerrainMaps)) + row.addGestureRecognizer(tap) + row.isUserInteractionEnabled = true + + return row + } + + @objc private func onClose() { + dismiss(animated: true) + } + + @objc private func onNearbyRoads() { + dismiss(animated: true) { [weak self] in + self?.onSelectMethod?(true) + } + } + + @objc private func onTerrainMaps() { + dismiss(animated: true) { [weak self] in + self?.onSelectMethod?(false) + } + } +} diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index 2cddb61575..4c2d6040f6 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -65,6 +65,20 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { nil } + var isCalculatingElevation: Bool { + false + } + + var analysisData: PlanRouteAnalysisData? { + nil + } + + func startElevationCalculation(useNearbyRoads: Bool) { + } + + func cancelElevationCalculation() { + } + var poiPoints: [PlanRoutePoint] { [] } diff --git a/Sources/Controllers/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/PlanRoute/PlanRouteModels.swift index dcda2fa5f3..f545548264 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteModels.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteModels.swift @@ -7,6 +7,7 @@ // import UIKit +import OsmAndShared enum PlanRouteMode { case newRoute @@ -164,6 +165,19 @@ struct PlanRouteElevationData { let elevations: [Double] } +struct PlanRouteAnalysisData { + let uphill: Double + let downhill: Double + let altMin: Double? + let altMax: Double? + let avgSpeed: Double? + let maxSpeed: Double? + let timeInMotion: TimeInterval? + let gpxAnalysis: GpxTrackAnalysis? + let gpxFile: GpxFile? + let routeStatistics: [OARouteStatistics] +} + struct PlanRouteSegmentRoutingParams { var useElevationData: Bool var considerTemporaryLimitations: Bool @@ -251,6 +265,11 @@ protocol PlanRoutePoiDataSource: AnyObject { protocol PlanRouteAnalyzeDataSource: AnyObject { var routeInfo: PlanRouteInfo { get } var elevationData: PlanRouteElevationData? { get } + var isCalculatingElevation: Bool { get } + var analysisData: PlanRouteAnalysisData? { get } + + func startElevationCalculation(useNearbyRoads: Bool) + func cancelElevationCalculation() } protocol PlanRoutePointsDataSource: AnyObject { diff --git a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift index 84b7c0d843..0073973b85 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift @@ -256,6 +256,7 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController segmentControl.setTitleTextAttributes(segmentAttributes, for: .selected) segmentControl.addTarget(self, action: #selector(onSegmentChanged), for: .valueChanged) let tapGesture = UITapGestureRecognizer(target: self, action: #selector(onSegmentTapped)) + tapGesture.cancelsTouchesInView = false segmentControl.addGestureRecognizer(tapGesture) } diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift index 7d70fd1db9..dea0d25880 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift @@ -7,13 +7,20 @@ // import UIKit +import DGCharts +import OsmAndShared final class PlanRouteAnalyzeViewController: UIViewController, PlanRouteTabContent { let planRouteTab: PlanRouteTab = .analyze private weak var dataSource: PlanRouteAnalyzeDataSource? - private let placeholderLabel = UILabel() + private var selectedYAxisType: GPXDataSetType = .altitude + private var selectedXAxisType: GPXDataSetAxisType = .distance + private var expandedStatIndexes: Set = [] + + private let tableView = UITableView(frame: .zero, style: .grouped) + private weak var chartView: ElevationChart? init(dataSource: PlanRouteAnalyzeDataSource?) { self.dataSource = dataSource @@ -26,25 +33,573 @@ final class PlanRouteAnalyzeViewController: UIViewController, PlanRouteTabConten override func viewDidLoad() { super.viewDidLoad() - setupPlaceholder() + setupTableView() reloadData() } func reloadData() { guard isViewLoaded else { return } - placeholderLabel.text = planRouteTab.title + tableView.reloadData() } - private func setupPlaceholder() { + private func setupTableView() { view.backgroundColor = .clear - placeholderLabel.font = .preferredFont(forTextStyle: .body) - placeholderLabel.textColor = .textColorSecondary - placeholderLabel.textAlignment = .center - placeholderLabel.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(placeholderLabel) + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0) + tableView.delegate = self + tableView.dataSource = self + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private var currentState: AnalyzeState { + guard let dataSource else { return .noData } + if dataSource.isCalculatingElevation { return .calculating } + if dataSource.analysisData != nil { return .hasData } + return .noData + } + + private func showGetElevationSheet() { + let sheet = GetElevationDataViewController() + sheet.onSelectMethod = { [weak self] useNearbyRoads in + self?.dataSource?.startElevationCalculation(useNearbyRoads: useNearbyRoads) + self?.reloadData() + } + let nav = UINavigationController(rootViewController: sheet) + nav.setNavigationBarHidden(true, animated: false) + nav.modalPresentationStyle = .pageSheet + if let presenter = nav.sheetPresentationController { + presenter.detents = [.medium()] + presenter.prefersGrabberVisible = true + } + present(nav, animated: true) + } + + private func showAxisPickerForY() { + let available: [GPXDataSetType] = [.altitude, .speed] + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + for type in available { + alert.addAction(UIAlertAction(title: type.getTitle(), style: .default) { [weak self] _ in + self?.selectedYAxisType = type + self?.refreshChart() + }) + } + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func showAxisPickerForX() { + let available: [GPXDataSetAxisType] = [.distance, .time, .timeOfDay] + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + for type in available { + alert.addAction(UIAlertAction(title: type.getName(), style: .default) { [weak self] _ in + self?.selectedXAxisType = type + self?.refreshChart() + }) + } + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func refreshChart() { + guard let data = dataSource?.analysisData, + let analysis = data.gpxAnalysis, + let gpxFile = data.gpxFile, + let chart = chartView else { return } + let gpxItem = OAGPXDatabase.sharedDb().getGPXItem(OAUtilities.getGpxShortPath(gpxFile.path)) + GpxUIHelper.refreshLineChart(chartView: chart, + analysis: analysis, + firstType: selectedYAxisType, + secondType: .slope, + axisType: selectedXAxisType, + calcWithoutGaps: GpxUtils.calcWithoutGaps(gpxFile, gpxDataItem: gpxItem, overrideIsGeneralTrack: true)) + } + + private func yAxisButtonTitle() -> String { + "\(selectedYAxisType.getTitle())/\(localizedString("shared_string_slope"))" + } +} + +// MARK: - State + +private enum AnalyzeState { + case noData + case calculating + case hasData +} + +// MARK: - Section indices when hasData + +private extension PlanRouteAnalyzeViewController { + static let graphSection = 0 + static let statsSection = 1 + static let roadAttributesBase = 2 + + static let axisPickersRow = 0 + static let chartRow = 1 + static let recalculateRow = 2 +} + +// MARK: - UITableViewDataSource + +extension PlanRouteAnalyzeViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + switch currentState { + case .noData, .calculating: + return 1 + case .hasData: + return Self.roadAttributesBase + (dataSource?.analysisData?.routeStatistics.count ?? 0) + } + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch currentState { + case .noData, .calculating: + return 1 + case .hasData: + switch section { + case Self.graphSection: return 3 + case Self.statsSection: return 1 + default: return 2 + } + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch currentState { + case .noData: + return makeStatusCardCell( + icon: UIImage(systemName: "arrow.down.to.line"), + iconTint: .iconColorTertiary, + title: localizedString("no_elevation_data"), + description: localizedString("no_elevation_data_description"), + actionTitle: localizedString("get_elevation_data"), + isSpinner: false, + action: { [weak self] in self?.showGetElevationSheet() } + ) + case .calculating: + return makeStatusCardCell( + icon: nil, + iconTint: .clear, + title: localizedString("calculating_elevation"), + description: localizedString("calculating_elevation_description"), + actionTitle: localizedString("shared_string_cancel"), + isSpinner: true, + action: { [weak self] in + self?.dataSource?.cancelElevationCalculation() + self?.reloadData() + } + ) + case .hasData: + return makeDataCell(at: indexPath) + } + } + + private func makeDataCell(at indexPath: IndexPath) -> UITableViewCell { + switch indexPath.section { + case Self.graphSection: + switch indexPath.row { + case Self.axisPickersRow: return makeAxisPickersCell() + case Self.chartRow: return makeChartCell() + case Self.recalculateRow: return makeRecalculateCell() + default: return UITableViewCell() + } + case Self.statsSection: + return makeStatsCell() + default: + let statIndex = indexPath.section - Self.roadAttributesBase + return indexPath.row == 0 + ? makeStatHeaderCell(statIndex: statIndex) + : makeStatLegendCell(statIndex: statIndex) + } + } + + // MARK: Axis pickers cell + + private func makeAxisPickersCell() -> UITableViewCell { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.selectionStyle = .none + cell.backgroundColor = .clear + + let yBtn = axisPickerButton(title: yAxisButtonTitle(), action: #selector(onYAxisTapped)) + let xBtn = axisPickerButton(title: selectedXAxisType.getName(), action: #selector(onXAxisTapped)) + let stack = UIStackView(arrangedSubviews: [yBtn, xBtn]) + stack.axis = .horizontal + stack.spacing = 8 + stack.distribution = .fillEqually + stack.translatesAutoresizingMaskIntoConstraints = false + cell.contentView.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 8), + stack.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -20), + stack.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -4), + stack.heightAnchor.constraint(equalToConstant: 36) + ]) + return cell + } + + private func axisPickerButton(title: String, action: Selector) -> UIButton { + let button = UIButton(type: .system) + button.backgroundColor = .cellButtonBg + button.layer.cornerRadius = 8 + button.clipsToBounds = true + var config = UIButton.Configuration.plain() + config.title = title + config.baseForegroundColor = .textColorPrimary + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attrs in + var out = attrs + out.font = UIFont.scaledSystemFont(ofSize: 13, weight: .medium) + return out + } + config.image = UIImage(systemName: "chevron.up.chevron.down", + withConfiguration: UIImage.SymbolConfiguration(scale: .small)) + config.imagePlacement = .trailing + config.imagePadding = 4 + button.configuration = config + button.addTarget(self, action: action, for: .touchUpInside) + return button + } + + // MARK: Chart cell + + private func makeChartCell() -> UITableViewCell { + guard let data = dataSource?.analysisData, + let analysis = data.gpxAnalysis, + let gpxFile = data.gpxFile, + let nibs = Bundle.main.loadNibNamed(ElevationChartCell.reuseIdentifier, owner: self, options: nil), + let cell = nibs.first as? ElevationChartCell else { + return UITableViewCell() + } + cell.selectionStyle = .none + cell.backgroundColor = .clear + cell.separatorInset = UIEdgeInsets(top: 0, left: .greatestFiniteMagnitude, bottom: 0, right: 0) + cell.heightConstraint.constant = 130 + + let useHours = (analysis.timeSpan / 3_600_000) > 0 + GpxUIHelper.setupElevationChart(chartView: cell.chartView, + topOffset: 20, + bottomOffset: 4, + useGesturesAndScale: true, + showXInMarker: false, + startTime: analysis.startTime, + useHours: useHours) + let gpxItem = OAGPXDatabase.sharedDb().getGPXItem(OAUtilities.getGpxShortPath(gpxFile.path)) + GpxUIHelper.refreshLineChart(chartView: cell.chartView, + analysis: analysis, + firstType: selectedYAxisType, + secondType: .slope, + axisType: selectedXAxisType, + calcWithoutGaps: GpxUtils.calcWithoutGaps(gpxFile, gpxDataItem: gpxItem, overrideIsGeneralTrack: true)) + chartView = cell.chartView + return cell + } + + // MARK: Recalculate cell + + private func makeRecalculateCell() -> UITableViewCell { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.selectionStyle = .none + cell.backgroundColor = .clear + let button = UIButton(type: .system) + button.setTitle(localizedString("recalculate_elevation"), for: .normal) + button.setTitleColor(.textColorActive, for: .normal) + button.titleLabel?.font = .scaledSystemFont(ofSize: 15, weight: .medium) + button.contentHorizontalAlignment = .left + button.addTarget(self, action: #selector(onRecalculateTapped), for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + cell.contentView.addSubview(button) + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 4), + button.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 20), + button.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -20), + button.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -8) + ]) + return cell + } + + // MARK: Stats grid cell + + private func makeStatsCell() -> UITableViewCell { + guard let data = dataSource?.analysisData else { return UITableViewCell() } + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.selectionStyle = .none + cell.backgroundColor = .clear + let grid = statsGridView(data: data) + grid.translatesAutoresizingMaskIntoConstraints = false + cell.contentView.addSubview(grid) + NSLayoutConstraint.activate([ + grid.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 4), + grid.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 20), + grid.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -20), + grid.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -4) + ]) + return cell + } + + private func statsGridView(data: PlanRouteAnalysisData) -> UIView { + let altRange: String + if let min = data.altMin, let max = data.altMax { + let minStr = OAOsmAndFormatter.getFormattedAlt(min) ?? "–" + let maxStr = OAOsmAndFormatter.getFormattedAlt(max) ?? "–" + altRange = "\(minStr), \(maxStr)" + } else { + altRange = "–" + } + let items: [(String, String)] = [ + (fmtAlt(data.uphill), localizedString("shared_string_uphill")), + (fmtAlt(data.downhill), localizedString("shared_string_downhill")), + (altRange, localizedString("altitude_range")), + (fmtSpeed(data.avgSpeed), localizedString("average_speed")), + (fmtSpeed(data.maxSpeed), localizedString("shared_string_max_speed")), + (fmtTime(data.timeInMotion), localizedString("moving_time")) + ] + let row0 = makeGridRow(items: Array(items[0...2])) + let row1 = makeGridRow(items: Array(items[3...5])) + let container = UIStackView(arrangedSubviews: [row0, row1]) + container.axis = .vertical + container.spacing = 4 + return container + } + + private func makeGridRow(items: [(String, String)]) -> UIStackView { + let row = UIStackView(arrangedSubviews: items.map { statItemView(value: $0.0, label: $0.1) }) + row.axis = .horizontal + row.distribution = .fillEqually + return row + } + + private func statItemView(value: String, label: String) -> UIView { + let valueLabel = UILabel() + valueLabel.text = value + valueLabel.font = .preferredFont(forTextStyle: .footnote) + valueLabel.textColor = .textColorActive + valueLabel.adjustsFontSizeToFitWidth = true + valueLabel.minimumScaleFactor = 0.7 + + let nameLabel = UILabel() + nameLabel.text = label + nameLabel.font = .preferredFont(forTextStyle: .caption2) + nameLabel.textColor = .textColorSecondary + + let stack = UIStackView(arrangedSubviews: [valueLabel, nameLabel]) + stack.axis = .vertical + stack.spacing = 2 + stack.layoutMargins = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) + stack.isLayoutMarginsRelativeArrangement = true + return stack + } + + // MARK: Road attribute header cell + + private func makeStatHeaderCell(statIndex: Int) -> UITableViewCell { + guard let stats = dataSource?.analysisData?.routeStatistics, statIndex < stats.count, + let analysis = dataSource?.analysisData?.gpxAnalysis, + let nibs = Bundle.main.loadNibNamed(OARouteInfoCell.reuseIdentifier, owner: self, options: nil), + let cell = nibs.first as? OARouteInfoCell, + let titleView = cell.titleView, + let expandImageView = cell.expandImageView, + let detailsButton = cell.detailsButton, + let barChartView = cell.barChartView else { return UITableViewCell() } + + let stat = stats[statIndex] + let isExpanded = expandedStatIndexes.contains(statIndex) + cell.selectionStyle = .none + cell.separatorInset = UIEdgeInsets(top: 0, left: .greatestFiniteMagnitude, bottom: 0, right: 0) + titleView.text = OAUtilities.getLocalizedRouteInfoProperty(stat.name) + expandImageView.image = UIImage(systemName: isExpanded ? "chevron.down" : "chevron.right")? + .withRenderingMode(.alwaysTemplate) + expandImageView.tintColor = .iconColorDefault + detailsButton.isHidden = true + + GpxUIHelper.refreshBarChart(chartView: barChartView, + statistics: stat, + analysis: analysis, + nightMode: OAAppSettings.sharedManager().nightMode) + barChartView.isUserInteractionEnabled = false + return cell + } + + // MARK: Road attribute legend cell + + private func makeStatLegendCell(statIndex: Int) -> UITableViewCell { + guard let stats = dataSource?.analysisData?.routeStatistics, statIndex < stats.count, + let nibs = Bundle.main.loadNibNamed(OARouteInfoLegendCell.reuseIdentifier, owner: self, options: nil), + let cell = nibs.first as? OARouteInfoLegendCell, + let legendStackView = cell.legendStackView else { return UITableViewCell() } + + let stat = stats[statIndex] + let isExpanded = expandedStatIndexes.contains(statIndex) + cell.selectionStyle = .none + cell.separatorInset = UIEdgeInsets(top: 0, left: .greatestFiniteMagnitude, bottom: 0, right: 0) + + for v in legendStackView.arrangedSubviews { + legendStackView.removeArrangedSubview(v) + v.removeFromSuperview() + } + for key in stat.partition.keys { + guard let segment = stat.partition[key] else { continue } + let propName = segment.getUserPropertyName() ?? "" + let isSteepness = stat.name == "routeInfo_steepness" + let isUndefined = propName == "undefined" + let title: String = (isSteepness && !isUndefined) + ? propName + : localizedString("rendering_attr_\(propName)_name") + let distance = isExpanded ? (OAOsmAndFormatter.getFormattedDistance(segment.distance) ?? "") : "" + let color = UIColor(argbValue: UInt32(bitPattern: Int32(segment.color))) + let item = OARouteInfoLegendItemView(title: title, color: color, distance: distance) + legendStackView.addArrangedSubview(item) + } + return cell + } + + // MARK: Status card cell (no data / calculating) + + private func makeStatusCardCell(icon: UIImage?, + iconTint: UIColor, + title: String, + description: String, + actionTitle: String, + isSpinner: Bool, + action: @escaping () -> Void) -> UITableViewCell { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.selectionStyle = .none + cell.backgroundColor = .clear + + let card = UIView() + card.backgroundColor = .viewBg + card.layer.cornerRadius = 12 + card.translatesAutoresizingMaskIntoConstraints = false + cell.contentView.addSubview(card) + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .scaledSystemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = .textColorPrimary + + let descLabel = UILabel() + descLabel.text = description + descLabel.font = .preferredFont(forTextStyle: .footnote) + descLabel.textColor = .textColorSecondary + descLabel.numberOfLines = 0 + + let separator = UIView() + separator.backgroundColor = .customSeparator + + let actionBtn = UIButton(type: .system) + actionBtn.setTitle(actionTitle, for: .normal) + actionBtn.setTitleColor(.textColorActive, for: .normal) + actionBtn.titleLabel?.font = .scaledSystemFont(ofSize: 15, weight: .medium) + actionBtn.contentHorizontalAlignment = .left + actionBtn.addAction(UIAction { _ in action() }, for: .touchUpInside) + + var trailingView: UIView + if isSpinner { + let spinner = UIActivityIndicatorView(style: .medium) + spinner.startAnimating() + trailingView = spinner + } else { + let iconView = UIImageView(image: icon) + iconView.tintColor = iconTint + iconView.contentMode = .scaleAspectFit + trailingView = iconView + } + + [titleLabel, descLabel, separator, actionBtn, trailingView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + card.addSubview($0) + } + NSLayoutConstraint.activate([ - placeholderLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - placeholderLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + card.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 8), + card.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 20), + card.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -20), + card.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -8), + + trailingView.topAnchor.constraint(equalTo: card.topAnchor, constant: 16), + trailingView.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16), + trailingView.widthAnchor.constraint(equalToConstant: 30), + trailingView.heightAnchor.constraint(equalToConstant: 30), + + titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 16), + titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: trailingView.leadingAnchor, constant: -8), + + descLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), + descLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), + descLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16), + + separator.topAnchor.constraint(equalTo: descLabel.bottomAnchor, constant: 12), + separator.leadingAnchor.constraint(equalTo: card.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: card.trailingAnchor), + separator.heightAnchor.constraint(equalToConstant: 0.5), + + actionBtn.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: 4), + actionBtn.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), + actionBtn.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16), + actionBtn.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -4), + actionBtn.heightAnchor.constraint(equalToConstant: 40) ]) + return cell + } + + // MARK: Formatters + + private func fmtAlt(_ value: Double) -> String { + (value > 0 ? OAOsmAndFormatter.getFormattedAlt(value) : nil) ?? "–" } + + private func fmtSpeed(_ value: Double?) -> String { + guard let v = value, v > 0 else { return "–" } + return OAOsmAndFormatter.getFormattedSpeed(Float(v)) ?? "–" + } + + private func fmtTime(_ interval: TimeInterval?) -> String { + guard let t = interval, t > 0 else { return "–" } + return OAOsmAndFormatter.getFormattedDuration(t) ?? "–" + } +} + +// MARK: - UITableViewDelegate + +extension PlanRouteAnalyzeViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard case .hasData = currentState, + indexPath.section >= Self.roadAttributesBase, + indexPath.row == 0 else { return } + let statIndex = indexPath.section - Self.roadAttributesBase + if expandedStatIndexes.contains(statIndex) { + expandedStatIndexes.remove(statIndex) + } else { + expandedStatIndexes.insert(statIndex) + } + tableView.reloadSections(IndexSet(integer: indexPath.section), with: .automatic) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { nil } + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { .leastNormalMagnitude } + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { nil } + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { .leastNormalMagnitude } +} + +// MARK: - Actions + +private extension PlanRouteAnalyzeViewController { + + @objc func onYAxisTapped() { showAxisPickerForY() } + @objc func onXAxisTapped() { showAxisPickerForX() } + @objc func onRecalculateTapped() { showGetElevationSheet() } } diff --git a/Sources/OsmAnd Maps-Bridging-Header.h b/Sources/OsmAnd Maps-Bridging-Header.h index acf322ad9c..f3b490bd1d 100644 --- a/Sources/OsmAnd Maps-Bridging-Header.h +++ b/Sources/OsmAnd Maps-Bridging-Header.h @@ -252,6 +252,9 @@ #import "OARangeSlider.h" #import "OATitleDescrDraggableCell.h" #import "OARouteStatisticsModeCell.h" +#import "OARouteInfoCell.h" +#import "OARouteInfoLegendCell.h" +#import "OARouteInfoLegendItemView.h" #import "OADeviceScreenTableViewCell.h" #import "OATitleSliderRoundCell.h" #import "OAIconsPaletteCell.h" From 49c5f75338ef107ef573b6a662f47e876d5ca6b1 Mon Sep 17 00:00:00 2001 From: Dmitry Svetlichny Date: Fri, 26 Jun 2026 09:44:47 +0300 Subject: [PATCH 42/47] Add tableView to PlanRoutePoiViewController --- .../PlanRoute/OAPlanRouteEditingBridge.h | 3 +- .../PlanRoute/OAPlanRouteEditingBridge.mm | 85 ++++++++- .../PlanRouteEditingContextDataProvider.swift | 30 ++- .../PlanRoute/PlanRouteModels.swift | 13 +- .../Tabs/PlanRoutePoiViewController.swift | 176 ++++++++++++++++-- 5 files changed, 277 insertions(+), 30 deletions(-) diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h index f5e2fc14c6..64d1016249 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -11,7 +11,7 @@ NS_ASSUME_NONNULL_BEGIN -@class OAApplicationMode, UIViewController; +@class OAApplicationMode, OAGpxWptItem, UIViewController; @interface OAPlanRoutePointData : NSObject @@ -75,6 +75,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)openAddPoiWithFilePath:(nullable NSString *)filePath presentingViewController:(UIViewController *)presentingViewController; - (NSArray *)buildSegments; +- (NSArray *)buildPoiItems; - (NSArray *)availableModes; - (void)deletePointAtIndex:(NSInteger)index; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm index 9f83631dcf..af9bc22755 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -86,7 +86,10 @@ - (OAMeasurementEditingContext *)editingContext; - (double)distanceFrom:(OASWptPt *)from to:(OASWptPt *)to; - (double)bearingFrom:(OASWptPt *)from to:(OASWptPt *)to; - (CLLocationCoordinate2D)crosshairLocation; +- (NSString *)absoluteGpxPathFromPath:(NSString *)filePath; +- (BOOL)isDraftGpxPath:(NSString *)filePath; - (OASGpxFile *)gpxFileForWaypoints; +- (void)notifyStoredGpxChangedAtPath:(NSString *)filePath; - (void)refreshDraftGpx; - (void)clearDraftGpx; - (void)addDraftWaypointsToGpx:(OASGpxFile *)gpx; @@ -290,8 +293,10 @@ - (void)openTrackWithFilePath:(NSString *)filePath OASGpxFile *gpxFile = nil; if (filePath.length > 0) { - OASKFile *file = [[OASKFile alloc] initWithFilePath:filePath]; + NSString *path = [self absoluteGpxPathFromPath:filePath]; + OASKFile *file = [[OASKFile alloc] initWithFilePath:path]; gpxFile = [OASGpxUtilities.shared loadGpxFileFile:file]; + gpxFile.path = path; } OAGpxData *gpxData = gpxFile != nil ? [[OAGpxData alloc] initWithFile:gpxFile] : nil; ctx.gpxData = gpxData; @@ -354,6 +359,22 @@ - (double)bearingFrom:(OASWptPt *)from to:(OASWptPt *)to return result; } +- (NSArray *)buildPoiItems +{ + NSMutableArray *items = [NSMutableArray array]; + for (OASWptPt *point in [self editingContext].gpxData.gpxFile.getPointsList) + { + [items addObject:[OAGpxWptItem withGpxWpt:point]]; + } + + for (OASWptPt *point in _draftGpxFile.getPointsList) + { + [items addObject:[OAGpxWptItem withGpxWpt:point]]; + } + + return items; +} + - (OAPlanRouteSegmentData *)buildSegmentWithIndex:(NSInteger)segmentIndex pointIndexes:(NSArray *)pointIndexes allPoints:(NSArray *)allPoints @@ -683,13 +704,12 @@ - (void)openAddPoiWithFilePath:(NSString *)filePath presentingViewController:(UI if (!CLLocationCoordinate2DIsValid(location)) return; - BOOL newRoute = filePath.length == 0; - NSString *gpxFilePath = newRoute ? [self gpxFileForWaypoints].path : (filePath.isAbsolutePath ? filePath : [OsmAndApp.instance.gpxPath stringByAppendingPathComponent:filePath]); + NSString *gpxFilePath = filePath.length == 0 ? [self gpxFileForWaypoints].path : [self absoluteGpxPathFromPath:filePath]; if (gpxFilePath.length == 0) return; OAEditPointViewController *controller = [[OAEditPointViewController alloc] initWithLocation:location title:OALocalizedString(@"shared_string_waypoint") address:nil customParam:gpxFilePath pointType:EOAEditPointTypeWaypoint targetMenuState:nil poi:nil]; - controller.gpxWptDelegate = newRoute ? self : (id)[OARootViewController instance].mapPanel; + controller.gpxWptDelegate = self; UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:controller]; [presentingViewController presentViewController:navigationController animated:YES completion:nil]; } @@ -715,6 +735,37 @@ - (OASGpxFile *)gpxFileForWaypoints return _draftGpxFile; } +- (BOOL)isDraftGpxPath:(NSString *)filePath +{ + return _draftGpxFile != nil && (filePath.length == 0 || [_draftGpxPath isEqualToString:filePath]); +} + +- (NSString *)absoluteGpxPathFromPath:(NSString *)filePath +{ + if (filePath.length == 0 || filePath.isAbsolutePath) + return filePath; + + return [OsmAndApp.instance.gpxPath stringByAppendingPathComponent:filePath]; +} + +- (void)notifyStoredGpxChangedAtPath:(NSString *)filePath +{ + NSString *path = [self absoluteGpxPathFromPath:filePath]; + if (path.length > 0 && [NSFileManager.defaultManager fileExistsAtPath:path]) + { + OASGpxFile *gpxFile = [OASGpxUtilities.shared loadGpxFileFile:[[OASKFile alloc] initWithFilePath:path]]; + OAMeasurementEditingContext *ctx = [self editingContext]; + if (gpxFile != nil && ctx != nil) + { + gpxFile.path = path; + ctx.gpxData = [[OAGpxData alloc] initWithFile:gpxFile]; + } + } + + if (self.onChange) + self.onChange(); +} + - (void)refreshDraftGpx { if (_draftGpxPath.length == 0 || _draftGpxFile == nil) @@ -749,6 +800,13 @@ - (void)addDraftWaypointsToGpx:(OASGpxFile *)gpx - (void)saveGpxWpt:(OAGpxWptItem *)gpxWpt gpxFileName:(NSString *)gpxFileName { + if (![self isDraftGpxPath:gpxFileName]) + { + [(id)[OARootViewController instance].mapPanel saveGpxWpt:gpxWpt gpxFileName:gpxFileName]; + [self notifyStoredGpxChangedAtPath:gpxFileName]; + return; + } + OASGpxFile *gpxFile = [self gpxFileForWaypoints]; if (gpxFile == nil || gpxWpt.point == nil) return; @@ -761,15 +819,34 @@ - (void)saveGpxWpt:(OAGpxWptItem *)gpxWpt gpxFileName:(NSString *)gpxFileName - (void)updateGpxWpt:(OAGpxWptItem *)gpxWptItem docPath:(NSString *)docPath updateMap:(BOOL)updateMap { + if (![self isDraftGpxPath:docPath]) + { + [(id)[OARootViewController instance].mapPanel updateGpxWpt:gpxWptItem docPath:docPath updateMap:updateMap]; + [self notifyStoredGpxChangedAtPath:docPath]; + return; + } + [self refreshDraftGpx]; } - (void)deleteGpxWpt:(OAGpxWptItem *)gpxWptItem docPath:(NSString *)docPath { + if (![self isDraftGpxPath:docPath]) + { + [(id)[OARootViewController instance].mapPanel deleteGpxWpt:gpxWptItem docPath:docPath]; + [self notifyStoredGpxChangedAtPath:docPath]; + } } - (void)saveItemToStorage:(OAGpxWptItem *)gpxWptItem { + if (![self isDraftGpxPath:gpxWptItem.docPath]) + { + [(id)[OARootViewController instance].mapPanel saveItemToStorage:gpxWptItem]; + [self notifyStoredGpxChangedAtPath:gpxWptItem.docPath]; + return; + } + [self refreshDraftGpx]; } diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index 4c2d6040f6..430a0cfa06 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -73,14 +73,10 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { nil } - func startElevationCalculation(useNearbyRoads: Bool) { - } - - func cancelElevationCalculation() { - } - - var poiPoints: [PlanRoutePoint] { - [] + var poiGroups: [PlanRoutePoiGroup] { + Dictionary(grouping: bridge.buildPoiItems(), by: { poiGroupName(for: $0) }) + .map { PlanRoutePoiGroup(name: $0.key, points: $0.value.map { mapPoiPoint($0) }) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } var routeSegments: [PlanRouteSegment] { @@ -94,6 +90,12 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { var availableModes: [OAApplicationMode] { bridge.availableModes() } + + func startElevationCalculation(useNearbyRoads: Bool) { + } + + func cancelElevationCalculation() { + } func addRoutePoint() { bridge.addCenterPoint() @@ -213,6 +215,18 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { distance: segment.distance) } + private func poiGroupName(for item: OAGpxWptItem) -> String { + let category = item.point.category ?? "" + return category.isEmpty ? localizedString("shared_string_gpx_points") : category + } + + private func mapPoiPoint(_ item: OAGpxWptItem) -> PlanRoutePoiPoint { + let name = item.point.name ?? "" + return PlanRoutePoiPoint(name: name.isEmpty ? localizedString("shared_string_waypoint") : name, + subtitle: item.point.getAddress() ?? "", + icon: item.compositeIconWithDefaultColor()) + } + private func mapGroup(_ group: OAPlanRouteGroupData) -> PlanRouteProfileGroup { PlanRouteProfileGroup(appMode: group.appMode, distance: group.distance, diff --git a/Sources/Controllers/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/PlanRoute/PlanRouteModels.swift index f545548264..10cc7216e1 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteModels.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteModels.swift @@ -159,6 +159,17 @@ struct PlanRouteSegment { } } +struct PlanRoutePoiGroup { + let name: String + let points: [PlanRoutePoiPoint] +} + +struct PlanRoutePoiPoint { + let name: String + let subtitle: String + let icon: UIImage +} + struct PlanRouteElevationData { let uphill: Double let downhill: Double @@ -257,7 +268,7 @@ enum SegmentRouteContext { } protocol PlanRoutePoiDataSource: AnyObject { - var poiPoints: [PlanRoutePoint] { get } + var poiGroups: [PlanRoutePoiGroup] { get } func openAddPoi(from presentingViewController: UIViewController) } diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift index 29d95ac07f..209995d412 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift @@ -9,12 +9,17 @@ import UIKit final class PlanRoutePoiViewController: UIViewController, PlanRouteTabContent { + private static let separatorLeftInset: CGFloat = 72 + private static let bottomContentInset: CGFloat = 72 + let planRouteTab: PlanRouteTab = .poi - + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + + private var groups: [PlanRoutePoiGroup] = [] + private weak var dataSource: PlanRoutePoiDataSource? - - private let placeholderLabel = UILabel() - + init(dataSource: PlanRoutePoiDataSource?) { self.dataSource = dataSource super.init(nibName: nil, bundle: nil) @@ -26,25 +31,164 @@ final class PlanRoutePoiViewController: UIViewController, PlanRouteTabContent { override func viewDidLoad() { super.viewDidLoad() - setupPlaceholder() + setupTableView() reloadData() } - + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + reloadData() + } + func reloadData() { guard isViewLoaded else { return } - placeholderLabel.text = planRouteTab.title + groups = dataSource?.poiGroups ?? [] + tableView.reloadData() } - - private func setupPlaceholder() { + + private func setupTableView() { view.backgroundColor = .clear - placeholderLabel.font = .preferredFont(forTextStyle: .body) - placeholderLabel.textColor = .textColorSecondary - placeholderLabel.textAlignment = .center - placeholderLabel.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(placeholderLabel) + tableView.backgroundColor = .viewBg + tableView.dataSource = self + tableView.delegate = self + tableView.alwaysBounceVertical = true + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 66 + tableView.separatorInset = UIEdgeInsets(top: 0, left: Self.separatorLeftInset, bottom: 0, right: 0) + tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: Self.bottomContentInset, right: 0) + tableView.sectionHeaderHeight = UITableView.automaticDimension + tableView.estimatedSectionHeaderHeight = 60 + tableView.sectionHeaderTopPadding = 0 + tableView.register(UINib(nibName: OASimpleTableViewCell.reuseIdentifier, bundle: nil), forCellReuseIdentifier: OASimpleTableViewCell.reuseIdentifier) + tableView.register(PlanRoutePoiGroupHeaderView.self, forHeaderFooterViewReuseIdentifier: PlanRoutePoiGroupHeaderView.reuseId) + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func configurePoiCell(_ cell: OASimpleTableViewCell, with point: PlanRoutePoiPoint) { + cell.backgroundColor = .groupBg + cell.selectionStyle = .none + cell.titleLabel.text = point.name + cell.titleLabel.textColor = .textColorPrimary + cell.descriptionLabel.text = point.subtitle + cell.descriptionLabel.textColor = .textColorSecondary + cell.descriptionVisibility(!point.subtitle.isEmpty) + cell.leftIconView.image = point.icon + cell.leftIconView.contentMode = .scaleAspectFit + cell.leftIconVisibility(true) + cell.leftEditButtonVisibility(false) + cell.setLeftIconSize(36) + } + + private func poiCountText(_ count: Int) -> String { + "\(count) \(localizedString("shared_string_gpx_points").lowercased())" + } + + private func onPoiGroupHeaderButtonTapped() { + } +} + +extension PlanRoutePoiViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + groups.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + groups[section].points.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: OASimpleTableViewCell.reuseIdentifier, for: indexPath) as? OASimpleTableViewCell else { return UITableViewCell() } + configurePoiCell(cell, with: groups[indexPath.section].points[indexPath.row]) + return cell + } +} + +extension PlanRoutePoiViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: PlanRoutePoiGroupHeaderView.reuseId) as? PlanRoutePoiGroupHeaderView else { return nil } + let group = groups[section] + header.configure(title: group.name, subtitle: poiCountText(group.points.count), buttonAction: { [weak self] in self?.onPoiGroupHeaderButtonTapped() }) + return header + } +} + +private final class PlanRoutePoiGroupHeaderView: UITableViewHeaderFooterView { + static let reuseId = "PlanRoutePoiGroupHeaderView" + + private static let buttonSize: CGFloat = 44 + + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let optionsButton = UIButton(type: .system) + + private var buttonAction: (() -> Void)? + + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private static func makeButtonConfiguration() -> UIButton.Configuration { + var configuration = UIButton.Configuration.plain() + configuration.image = UIImage(systemName: "ellipsis") + configuration.baseForegroundColor = .buttonAccentsBlue + configuration.background.image = UIImage(named: "blue_circle_fill") + configuration.contentInsets = .zero + return configuration + } + + func configure(title: String, subtitle: String, buttonAction: @escaping () -> Void) { + titleLabel.text = title + subtitleLabel.text = subtitle + self.buttonAction = buttonAction + } + + private func setupView() { + titleLabel.font = .scaledSystemFont(ofSize: 20, weight: .semibold) + titleLabel.textColor = .textColorPrimary + titleLabel.numberOfLines = 0 + titleLabel.adjustsFontForContentSizeCategory = true + subtitleLabel.font = .preferredFont(forTextStyle: .footnote) + subtitleLabel.textColor = .textColorSecondary + subtitleLabel.numberOfLines = 0 + subtitleLabel.adjustsFontForContentSizeCategory = true + let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + textStack.axis = .vertical + textStack.spacing = 2 + textStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + optionsButton.configuration = Self.makeButtonConfiguration() + optionsButton.accessibilityLabel = localizedString("shared_string_options") + optionsButton.addTarget(self, action: #selector(onOptionsButtonTapped), for: .touchUpInside) + optionsButton.setContentCompressionResistancePriority(.required, for: .horizontal) + [textStack, optionsButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + NSLayoutConstraint.activate([ - placeholderLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - placeholderLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + textStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + textStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + textStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + optionsButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + optionsButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + optionsButton.leadingAnchor.constraint(greaterThanOrEqualTo: textStack.trailingAnchor, constant: 12), + optionsButton.widthAnchor.constraint(equalToConstant: Self.buttonSize), + optionsButton.heightAnchor.constraint(equalToConstant: Self.buttonSize) ]) } + + @objc private func onOptionsButtonTapped() { + buttonAction?() + } } From 1da0580a9902e54858978f78f15398201cc355a8 Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Fri, 26 Jun 2026 08:58:52 +0200 Subject: [PATCH 43/47] [WIP] Small fixes --- .../PlanRoute/OAPlanRouteEditingBridge.h | 3 ++ .../PlanRoute/OAPlanRouteEditingBridge.mm | 36 +++++++++++++++++-- .../PlanRouteEditingContextDataProvider.swift | 24 ++++++++++++- .../PlanRouteScrollableViewController.swift | 16 ++++++--- .../TrackMenu/OATrackMenuHudViewController.mm | 8 ++--- Sources/OsmAnd Maps-Bridging-Header.h | 1 + 6 files changed, 74 insertions(+), 14 deletions(-) diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h index 64d1016249..75c5c56b76 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -11,6 +11,7 @@ NS_ASSUME_NONNULL_BEGIN +@class OAApplicationMode, UIViewController, OASGpxFile; @class OAApplicationMode, OAGpxWptItem, UIViewController; @interface OAPlanRoutePointData : NSObject @@ -54,6 +55,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) BOOL hasContext; @property (nonatomic, readonly) BOOL hasPoints; +@property (nonatomic, readonly, nullable) OASGpxFile *currentGpxFile; +@property (nonatomic, readonly, nullable) OASGpxFile *exportedGpxFile; @property (nonatomic, readonly) BOOL isAddNewSegmentAllowed; @property (nonatomic, readonly) BOOL hasChanges; @property (nonatomic, readonly) BOOL canUndo; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm index af9bc22755..12443769dc 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -39,6 +39,7 @@ #import "OAEditPointViewController.h" #import "OANativeUtilities.h" #import "OAGpxWptItem.h" +#import "OASelectedGPXHelper.h" @class OAMeasurementToolLayer, OAMeasurementEditingContext; @@ -197,6 +198,19 @@ - (BOOL)hasPoints return [self editingContext].getPoints.count > 0; } +- (OASGpxFile *)currentGpxFile +{ + return [self editingContext].gpxData.gpxFile; +} + +- (OASGpxFile *)exportedGpxFile +{ + OAMeasurementEditingContext *ctx = [self editingContext]; + if (ctx == nil || ctx.getPointsCount == 0) + return nil; + return [ctx exportGpx:@"tmp_analyze"]; +} + - (BOOL)isAddNewSegmentAllowed { OAMeasurementEditingContext *ctx = [self editingContext]; @@ -293,10 +307,26 @@ - (void)openTrackWithFilePath:(NSString *)filePath OASGpxFile *gpxFile = nil; if (filePath.length > 0) { - NSString *path = [self absoluteGpxPathFromPath:filePath]; - OASKFile *file = [[OASKFile alloc] initWithFilePath:path]; + NSString *absolutePath = filePath.isAbsolutePath ? filePath : [OsmAndApp.instance.gpxPath stringByAppendingPathComponent:filePath]; + OASGpxFile *selectedFile = [OASelectedGPXHelper.instance activeGpx][absolutePath.lastPathComponent]; + if (selectedFile) + { + gpxFile = selectedFile; + } + else + { + OASKFile *file = [[OASKFile alloc] initWithFilePath:absolutePath]; gpxFile = [OASGpxUtilities.shared loadGpxFileFile:file]; - gpxFile.path = path; + } + if (gpxFile) + { + if (!gpxFile.routes) + gpxFile.routes = [NSMutableArray new]; + if (!gpxFile.tracks) + gpxFile.tracks = [NSMutableArray new]; + if (!gpxFile.getPointsList) + [gpxFile clearPoints]; + } } OAGpxData *gpxData = gpxFile != nil ? [[OAGpxData alloc] initWithFile:gpxFile] : nil; ctx.gpxData = gpxData; diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index 430a0cfa06..d286f67d4d 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -70,7 +70,29 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { } var analysisData: PlanRouteAnalysisData? { - nil + let gpxFile: GpxFile? + switch mode { + case .editTrack: + gpxFile = bridge.currentGpxFile + case .newRoute: + gpxFile = bridge.exportedGpxFile + } + guard let gpxFile else { return nil } + let analysis = gpxFile.getAnalysis(fileTimestamp: 0) + let hasElevation = analysis.diffElevationUp > 0 || analysis.diffElevationDown > 0 + guard hasElevation else { return nil } + return PlanRouteAnalysisData( + uphill: analysis.diffElevationUp, + downhill: analysis.diffElevationDown, + altMin: analysis.minElevation > 0 ? analysis.minElevation : nil, + altMax: analysis.maxElevation > 0 ? analysis.maxElevation : nil, + avgSpeed: analysis.avgSpeed > 0 ? Double(analysis.avgSpeed) : nil, + maxSpeed: analysis.maxSpeed > 0 ? Double(analysis.maxSpeed) : nil, + timeInMotion: analysis.timeMoving > 0 ? TimeInterval(analysis.timeMoving) / 1000 : nil, + gpxAnalysis: analysis, + gpxFile: gpxFile, + routeStatistics: [] + ) } var poiGroups: [PlanRoutePoiGroup] { diff --git a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift index 0073973b85..37e58ad193 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteScrollableViewController.swift @@ -543,7 +543,12 @@ final class PlanRouteScrollableViewController: OABaseScrollableHudViewController dataProvider.saveAs(fileName: fileName, folder: nil, showOnMap: true) { [weak self] success, _ in guard let self else { return } if success { - reloadData() + let message = String(format: localizedString("gpx_saved_successfully"), fileName) + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: localizedString("shared_string_ok"), style: .default)) + hide(true, duration: Self.animationDuration) { + OARootViewController.instance().present(alert, animated: true) + } } else { showSaveError() } @@ -674,11 +679,14 @@ extension PlanRouteScrollableViewController: UIGestureRecognizerDelegate { // MARK: - OASaveTrackViewControllerDelegate extension PlanRouteScrollableViewController: OASaveTrackViewControllerDelegate { func onSave(asNewTrack fileName: String, showOnMap: Bool, simplifiedTrack: Bool, openTrack: Bool) { - dataProvider.saveAs(fileName: fileName, folder: nil, showOnMap: showOnMap) { [weak self] success, _ in + dataProvider.saveAs(fileName: fileName, folder: nil, showOnMap: showOnMap) { [weak self] success, filePath in guard let self else { return } if success { - topToolbar.titleText = fileName - reloadData() + let path = filePath ?? fileName + hide(true, duration: Self.animationDuration) { + let bottomSheet = OASaveTrackBottomSheetViewController(fileName: path) + bottomSheet?.present(in: OARootViewController.instance()) + } } else { showSaveError() } diff --git a/Sources/Controllers/TargetMenu/GPX/TrackMenu/OATrackMenuHudViewController.mm b/Sources/Controllers/TargetMenu/GPX/TrackMenu/OATrackMenuHudViewController.mm index d9e0264fe6..ec066ccfc4 100644 --- a/Sources/Controllers/TargetMenu/GPX/TrackMenu/OATrackMenuHudViewController.mm +++ b/Sources/Controllers/TargetMenu/GPX/TrackMenu/OATrackMenuHudViewController.mm @@ -981,12 +981,8 @@ - (void)editSegment _pushedNewScreen = YES; __weak __typeof(self) weakSelf = self; [self hide:YES duration:.2 onComplete:^{ - OATrackMenuViewControllerState *state = [weakSelf getCurrentState]; - state.openedFromTrackMenu = YES; - [weakSelf.mapPanelViewController showScrollableHudViewController:[ - [OARoutePlanningHudViewController alloc] initWithFileName:weakSelf.gpx.gpxFilePath - targetMenuState:state - adjustMapPosition:NO]]; + NSString *absolutePath = [OsmAndApp.instance.gpxPath stringByAppendingPathComponent:weakSelf.gpx.gpxFilePath]; + [PlanRouteScrollableViewController openExistingTrackWithFilePath:absolutePath]; }]; } diff --git a/Sources/OsmAnd Maps-Bridging-Header.h b/Sources/OsmAnd Maps-Bridging-Header.h index f3b490bd1d..b55d099ad6 100644 --- a/Sources/OsmAnd Maps-Bridging-Header.h +++ b/Sources/OsmAnd Maps-Bridging-Header.h @@ -187,6 +187,7 @@ #import "OARoutePlanningHudViewController.h" #import "OAPlanRouteEditingBridge.h" #import "OASaveTrackViewController.h" +#import "OASaveTrackBottomSheetViewController.h" #import "OAOpenAddTrackViewController.h" #import "OASelectTrackFolderViewController.h" #import "OARecordSettingsBottomSheetViewController.h" From 7e359d79c52940303810674597a7f07844cc1209 Mon Sep 17 00:00:00 2001 From: Dmitry Svetlichny Date: Fri, 26 Jun 2026 12:02:55 +0300 Subject: [PATCH 44/47] Add Rename Action --- .../en.lproj/Localizable.strings | 1 + .../PlanRoute/OAPlanRouteEditingBridge.h | 1 + .../PlanRoute/OAPlanRouteEditingBridge.mm | 76 +++++++++++++ .../PlanRouteEditingContextDataProvider.swift | 4 + .../Tabs/PlanRoutePoiViewController.swift | 103 +++++++++++++++--- 5 files changed, 172 insertions(+), 13 deletions(-) diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 79866f233c..cb48edf9c3 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -1073,6 +1073,7 @@ "poi_search_near_center" = "Search is performed near the center of the map"; "shared_string_hide" = "Hide"; "shared_string_show" = "Show"; +"shared_string_copy_from" = "Copy from"; // My Places "favorites_item" = "Favorites"; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h index 75c5c56b76..5ceaecb75a 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -80,6 +80,7 @@ NS_ASSUME_NONNULL_BEGIN - (NSArray *)buildSegments; - (NSArray *)buildPoiItems; - (NSArray *)availableModes; +- (void)renamePoiGroupFromName:(NSString *)oldName toName:(NSString *)newName NS_SWIFT_NAME(renamePoiGroup(from:to:)); - (void)deletePointAtIndex:(NSInteger)index; - (void)movePointFrom:(NSInteger)from to:(NSInteger)to; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm index 12443769dc..a8dd423ef3 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -90,6 +90,11 @@ - (CLLocationCoordinate2D)crosshairLocation; - (NSString *)absoluteGpxPathFromPath:(NSString *)filePath; - (BOOL)isDraftGpxPath:(NSString *)filePath; - (OASGpxFile *)gpxFileForWaypoints; +- (NSString *)poiGroupKeyForName:(NSString *)groupName; +- (BOOL)renamePoiGroupInGpx:(nullable OASGpxFile *)gpxFile + fromKey:(NSString *)oldKey + toKey:(NSString *)newKey + displayName:(NSString *)displayName; - (void)notifyStoredGpxChangedAtPath:(NSString *)filePath; - (void)refreshDraftGpx; - (void)clearDraftGpx; @@ -405,6 +410,77 @@ - (double)bearingFrom:(OASWptPt *)from to:(OASWptPt *)to return items; } +- (void)renamePoiGroupFromName:(NSString *)oldName toName:(NSString *)newName +{ + NSString *trimmedName = [newName stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (oldName.length == 0 || trimmedName.length == 0) + return; + + NSString *oldKey = [self poiGroupKeyForName:oldName]; + NSString *newKey = [self poiGroupKeyForName:trimmedName]; + if ([oldKey isEqualToString:newKey]) + return; + + OAMeasurementEditingContext *ctx = [self editingContext]; + OASGpxFile *gpxFile = ctx.gpxData.gpxFile; + BOOL gpxChanged = [self renamePoiGroupInGpx:gpxFile fromKey:oldKey toKey:newKey displayName:trimmedName]; + BOOL draftChanged = [self renamePoiGroupInGpx:_draftGpxFile fromKey:oldKey toKey:newKey displayName:trimmedName]; + if (gpxChanged && gpxFile.path.length > 0) + { + OASKFile *file = [[OASKFile alloc] initWithFilePath:gpxFile.path]; + [OASGpxUtilities.shared writeGpxFileFile:file gpxFile:gpxFile]; + [self notifyStoredGpxChangedAtPath:gpxFile.path]; + } + + if (draftChanged) + [self refreshDraftGpx]; + + if ((gpxChanged && gpxFile.path.length == 0) || draftChanged) + { + if (self.onChange) + self.onChange(); + } +} + +- (NSString *)poiGroupKeyForName:(NSString *)groupName +{ + return [groupName isEqualToString:OALocalizedString(@"shared_string_gpx_points")] ? @"" : (groupName ?: @""); +} + +- (BOOL)renamePoiGroupInGpx:(OASGpxFile *)gpxFile + fromKey:(NSString *)oldKey + toKey:(NSString *)newKey + displayName:(NSString *)displayName +{ + if (gpxFile == nil) + return NO; + + BOOL changed = NO; + for (OASWptPt *point in gpxFile.getPointsList) + { + NSString *pointKey = [self poiGroupKeyForName:point.category]; + if ([pointKey isEqualToString:oldKey]) + { + point.category = newKey; + changed = YES; + } + } + + if (changed && gpxFile.pointsGroups.count > 0) + { + OASGpxUtilitiesPointsGroup *metaGroup = gpxFile.pointsGroups[oldKey]; + if (metaGroup) + { + metaGroup.name = displayName; + [gpxFile.pointsGroups removeObjectForKey:oldKey]; + if (gpxFile.pointsGroups[newKey] == nil) + gpxFile.pointsGroups[newKey] = metaGroup; + } + } + + return changed; +} + - (OAPlanRouteSegmentData *)buildSegmentWithIndex:(NSInteger)segmentIndex pointIndexes:(NSArray *)pointIndexes allPoints:(NSArray *)allPoints diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index d286f67d4d..fb97cfcc7f 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -128,6 +128,10 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { bridge.openAddPoi(withFilePath: filePath, presenting: presentingViewController) } + func renamePoiGroup(from oldName: String, to newName: String) { + bridge.renamePoiGroup(from: oldName, to: newName) + } + func undo() { bridge.undo() } diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift index 209995d412..ca924de3b8 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift @@ -89,9 +89,6 @@ final class PlanRoutePoiViewController: UIViewController, PlanRouteTabContent { private func poiCountText(_ count: Int) -> String { "\(count) \(localizedString("shared_string_gpx_points").lowercased())" } - - private func onPoiGroupHeaderButtonTapped() { - } } extension PlanRoutePoiViewController: UITableViewDataSource { @@ -114,11 +111,97 @@ extension PlanRoutePoiViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: PlanRoutePoiGroupHeaderView.reuseId) as? PlanRoutePoiGroupHeaderView else { return nil } let group = groups[section] - header.configure(title: group.name, subtitle: poiCountText(group.points.count), buttonAction: { [weak self] in self?.onPoiGroupHeaderButtonTapped() }) + header.configure(title: group.name, subtitle: poiCountText(group.points.count), menu: makePoiGroupMenu(for: group)) return header } } +extension PlanRoutePoiViewController { + func makePoiGroupMenu(for group: PlanRoutePoiGroup) -> UIMenu { + let rename = UIAction(title: localizedString("shared_string_rename"), image: .icCustomEdit) { [weak self] _ in + self?.onRenamePoiGroup(group) + } + let changeAppearance = UIAction(title: localizedString("change_appearance"), image: .icCustomAppearanceOutlined) { [weak self] _ in + self?.onChangePoiGroupAppearance(group) + } + let delete = UIAction(title: localizedString("shared_string_delete"), image: .icCustomTrashOutlined, attributes: .destructive) { [weak self] _ in + self?.onDeletePoiGroup(group) + } + + return UIMenu.composedMenu(from: [ + [rename, changeAppearance], + [makePoiGroupSortMenu(group)], + [makePoiGroupCopyFromMenu(group)], + [delete] + ]) + } + + func makePoiGroupSortMenu(_ group: PlanRoutePoiGroup) -> UIMenu { + let sortingOptions = UIMenu(options: .displayInline, children: [makePoiGroupSortAction(.lastModified, group: group)]) + let alphabeticalOptions = UIMenu(options: .displayInline, children: [makePoiGroupSortAction(.nameAZ, group: group), makePoiGroupSortAction(.nameZA, group: group)]) + let dateOptions = UIMenu(options: .displayInline, children: [makePoiGroupSortAction(.newestDateFirst, group: group), makePoiGroupSortAction(.oldestDateFirst, group: group)]) + return UIMenu(title: localizedString("shared_string_sort"), image: .templateImageNamed("ic_custom_swap"), children: [sortingOptions, alphabeticalOptions, dateOptions]) + } + + func makePoiGroupSortAction(_ sortMode: TracksSortMode, group: PlanRoutePoiGroup) -> UIAction { + UIAction(title: sortMode.title, image: sortMode.image) { [weak self] _ in + self?.onSortPoiGroup(group, sortMode: sortMode) + } + } + + func makePoiGroupCopyFromMenu(_ group: PlanRoutePoiGroup) -> UIMenu { + let favorites = UIAction(title: localizedString("shared_string_favorites"), image: .icCustomFavorites) { [weak self] _ in + self?.onCopyPoiGroupFromFavorites(group) + } + let track = UIAction(title: localizedString("shared_string_gpx_track"), image: .icCustomTrip) { [weak self] _ in + self?.onCopyPoiGroupFromTrack(group) + } + let mapMarkers = UIAction(title: localizedString("map_markers"), image: .icCustomMarker) { [weak self] _ in + self?.onCopyPoiGroupFromMapMarkers(group) + } + + return UIMenu(title: localizedString("shared_string_copy_from"), image: .icCustomCopy, children: [favorites, track, mapMarkers]) + } + + func onRenamePoiGroup(_ group: PlanRoutePoiGroup) { + let alert = UIAlertController(title: localizedString("shared_string_rename"), message: localizedString("enter_new_name"), preferredStyle: .alert) + alert.addTextField { textField in + textField.text = group.name + } + + let applyAction = UIAlertAction(title: localizedString("shared_string_apply"), style: .default) { [weak self, weak alert] _ in + guard let self else { return } + let newName = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !newName.isEmpty, newName != group.name else { return } + (dataSource as? PlanRouteEditingContextDataProvider)?.renamePoiGroup(from: group.name, to: newName) + reloadData() + } + + alert.addAction(applyAction) + alert.preferredAction = applyAction + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + present(alert, animated: true) + } + + func onChangePoiGroupAppearance(_ group: PlanRoutePoiGroup) { + } + + func onSortPoiGroup(_ group: PlanRoutePoiGroup, sortMode: TracksSortMode) { + } + + func onCopyPoiGroupFromFavorites(_ group: PlanRoutePoiGroup) { + } + + func onCopyPoiGroupFromTrack(_ group: PlanRoutePoiGroup) { + } + + func onCopyPoiGroupFromMapMarkers(_ group: PlanRoutePoiGroup) { + } + + func onDeletePoiGroup(_ group: PlanRoutePoiGroup) { + } +} + private final class PlanRoutePoiGroupHeaderView: UITableViewHeaderFooterView { static let reuseId = "PlanRoutePoiGroupHeaderView" @@ -128,8 +211,6 @@ private final class PlanRoutePoiGroupHeaderView: UITableViewHeaderFooterView { private let subtitleLabel = UILabel() private let optionsButton = UIButton(type: .system) - private var buttonAction: (() -> Void)? - override init(reuseIdentifier: String?) { super.init(reuseIdentifier: reuseIdentifier) setupView() @@ -148,10 +229,10 @@ private final class PlanRoutePoiGroupHeaderView: UITableViewHeaderFooterView { return configuration } - func configure(title: String, subtitle: String, buttonAction: @escaping () -> Void) { + func configure(title: String, subtitle: String, menu: UIMenu) { titleLabel.text = title subtitleLabel.text = subtitle - self.buttonAction = buttonAction + optionsButton.menu = menu } private func setupView() { @@ -169,7 +250,7 @@ private final class PlanRoutePoiGroupHeaderView: UITableViewHeaderFooterView { textStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) optionsButton.configuration = Self.makeButtonConfiguration() optionsButton.accessibilityLabel = localizedString("shared_string_options") - optionsButton.addTarget(self, action: #selector(onOptionsButtonTapped), for: .touchUpInside) + optionsButton.showsMenuAsPrimaryAction = true optionsButton.setContentCompressionResistancePriority(.required, for: .horizontal) [textStack, optionsButton].forEach { $0.translatesAutoresizingMaskIntoConstraints = false @@ -187,8 +268,4 @@ private final class PlanRoutePoiGroupHeaderView: UITableViewHeaderFooterView { optionsButton.heightAnchor.constraint(equalToConstant: Self.buttonSize) ]) } - - @objc private func onOptionsButtonTapped() { - buttonAction?() - } } From 2a4cb2614167049ed57de0b4ce61e007702b9b0d Mon Sep 17 00:00:00 2001 From: Dmitry Svetlichny Date: Fri, 26 Jun 2026 13:06:02 +0300 Subject: [PATCH 45/47] Add Change appearance --- .../PlanRoute/OAPlanRouteEditingBridge.h | 1 + .../PlanRoute/OAPlanRouteEditingBridge.mm | 138 +++++++++++++++++- .../PlanRouteEditingContextDataProvider.swift | 4 + .../Tabs/PlanRoutePoiViewController.swift | 1 + 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h index 5ceaecb75a..d9e885e600 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -81,6 +81,7 @@ NS_ASSUME_NONNULL_BEGIN - (NSArray *)buildPoiItems; - (NSArray *)availableModes; - (void)renamePoiGroupFromName:(NSString *)oldName toName:(NSString *)newName NS_SWIFT_NAME(renamePoiGroup(from:to:)); +- (void)openPoiGroupAppearanceForName:(NSString *)groupName presentingViewController:(UIViewController *)presentingViewController NS_SWIFT_NAME(openPoiGroupAppearance(_:presenting:)); - (void)deletePointAtIndex:(NSInteger)index; - (void)movePointFrom:(NSInteger)from to:(NSInteger)to; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm index a8dd423ef3..439b451113 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -37,9 +37,12 @@ #import "OAMapActions.h" #import "OAAppSettings.h" #import "OAEditPointViewController.h" +#import "OAEditWaypointsGroupOptionsViewController.h" #import "OANativeUtilities.h" #import "OAGpxWptItem.h" #import "OASelectedGPXHelper.h" +#import "OADefaultFavorite.h" +#import "OAGPXAppearanceCollection.h" @class OAMeasurementToolLayer, OAMeasurementEditingContext; @@ -74,10 +77,11 @@ - (instancetype)initWithIndex:(NSInteger)index @end -@interface OAPlanRouteEditingBridge () +@interface OAPlanRouteEditingBridge () { OASGpxFile *_draftGpxFile; NSString *_draftGpxPath; + NSString *_editingPoiGroupName; double _distanceToMapCenter; double _bearingToMapCenter; } @@ -95,6 +99,14 @@ - (BOOL)renamePoiGroupInGpx:(nullable OASGpxFile *)gpxFile fromKey:(NSString *)oldKey toKey:(NSString *)newKey displayName:(NSString *)displayName; +- (NSInteger)getPoiGroupColor:(NSString *)groupName; +- (NSArray *)changePoiGroupAppearanceInGpx:(nullable OASGpxFile *)gpxFile + groupKey:(NSString *)groupKey + color:(UIColor *)color; +- (void)refreshStoredPoiGroupAppearanceWithItems:(NSArray *)items + gpxFile:(OASGpxFile *)gpxFile + groupKey:(NSString *)groupKey + color:(UIColor *)color; - (void)notifyStoredGpxChangedAtPath:(NSString *)filePath; - (void)refreshDraftGpx; - (void)clearDraftGpx; @@ -442,6 +454,19 @@ - (void)renamePoiGroupFromName:(NSString *)oldName toName:(NSString *)newName } } +- (void)openPoiGroupAppearanceForName:(NSString *)groupName presentingViewController:(UIViewController *)presentingViewController +{ + if (presentingViewController == nil || groupName.length == 0) + return; + + _editingPoiGroupName = groupName; + UIColor *groupColor = UIColorFromARGB([self getPoiGroupColor:groupName]); + OAEditWaypointsGroupOptionsViewController *controller = [[OAEditWaypointsGroupOptionsViewController alloc] initWithScreenType:EOAEditWaypointsGroupColorScreen groupName:groupName groupColor:groupColor]; + controller.delegate = self; + UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:controller]; + [presentingViewController presentViewController:navigationController animated:YES completion:nil]; +} + - (NSString *)poiGroupKeyForName:(NSString *)groupName { return [groupName isEqualToString:OALocalizedString(@"shared_string_gpx_points")] ? @"" : (groupName ?: @""); @@ -481,6 +506,77 @@ - (BOOL)renamePoiGroupInGpx:(OASGpxFile *)gpxFile return changed; } +- (NSInteger)getPoiGroupColor:(NSString *)groupName +{ + NSString *groupKey = [self poiGroupKeyForName:groupName]; + for (OASWptPt *point in [self editingContext].gpxData.gpxFile.getPointsList) + { + if ([[self poiGroupKeyForName:point.category] isEqualToString:groupKey]) + return [point getColor]; + } + + for (OASWptPt *point in _draftGpxFile.getPointsList) + { + if ([[self poiGroupKeyForName:point.category] isEqualToString:groupKey]) + return [point getColor]; + } + + return [[OADefaultFavorite getDefaultColor] toARGBNumber]; +} + +- (NSArray *)changePoiGroupAppearanceInGpx:(OASGpxFile *)gpxFile groupKey:(NSString *)groupKey color:(UIColor *)color +{ + if (gpxFile == nil || color == nil) + return @[]; + + NSMutableArray *changedItems = [NSMutableArray array]; + int colorValue = [color toARGBNumber]; + OASInt *colorToSave = [[OASInt alloc] initWithInt:colorValue]; + for (OASWptPt *point in gpxFile.getPointsList) + { + if ([[self poiGroupKeyForName:point.category] isEqualToString:groupKey] && [point getColor] != colorValue) + { + [point setColorColor:colorToSave]; + [changedItems addObject:[OAGpxWptItem withGpxWpt:point]]; + } + } + + return changedItems; +} + +- (void)refreshStoredPoiGroupAppearanceWithItems:(NSArray *)items + gpxFile:(OASGpxFile *)gpxFile + groupKey:(NSString *)groupKey + color:(UIColor *)color +{ + if (items.count == 0 || gpxFile.path.length == 0) + return; + + NSString *path = [self absoluteGpxPathFromPath:gpxFile.path]; + OAMapViewController *mapViewController = OARootViewController.instance.mapPanel.mapViewController; + BOOL updatedMap = [mapViewController updateWpts:items docPath:path updateMap:YES]; + if (!updatedMap) + { + NSDictionary *activeGpx = OASelectedGPXHelper.instance.activeGpx; + OASGpxFile *activeGpxFile = activeGpx[path]; + if (activeGpxFile == nil) + activeGpxFile = activeGpx[gpxFile.path]; + if (activeGpxFile == nil) + activeGpxFile = activeGpx[path.lastPathComponent]; + if (activeGpxFile != nil) + [self changePoiGroupAppearanceInGpx:activeGpxFile groupKey:groupKey color:color]; + + OASKFile *file = [[OASKFile alloc] initWithFilePath:path]; + [OASGpxUtilities.shared writeGpxFileFile:file gpxFile:gpxFile]; + dispatch_async(dispatch_get_main_queue(), ^{ + [mapViewController.mapLayers.gpxMapLayer updateCachedGpxItem:path]; + [mapViewController.mapLayers.gpxMapLayer refreshGpxWaypoints]; + }); + } + + [self notifyStoredGpxChangedAtPath:path]; +} + - (OAPlanRouteSegmentData *)buildSegmentWithIndex:(NSInteger)segmentIndex pointIndexes:(NSArray *)pointIndexes allPoints:(NSArray *)allPoints @@ -902,6 +998,46 @@ - (void)addDraftWaypointsToGpx:(OASGpxFile *)gpx [gpx addPointsCollection:_draftGpxFile.getPointsList]; } +#pragma mark - OAEditWaypointsGroupOptionsDelegate + +- (void)updateWaypointsGroup:(NSString *)groupName color:(UIColor *)color +{ + if (_editingPoiGroupName.length == 0 || color == nil) + { + _editingPoiGroupName = nil; + return; + } + + NSString *groupKey = [self poiGroupKeyForName:_editingPoiGroupName]; + OAMeasurementEditingContext *ctx = [self editingContext]; + OASGpxFile *gpxFile = ctx.gpxData.gpxFile; + NSArray *changedGpxItems = [self changePoiGroupAppearanceInGpx:gpxFile groupKey:groupKey color:color]; + NSArray *changedDraftItems = [self changePoiGroupAppearanceInGpx:_draftGpxFile groupKey:groupKey color:color]; + BOOL gpxChanged = changedGpxItems.count > 0; + BOOL draftChanged = changedDraftItems.count > 0; + if (!gpxChanged && !draftChanged) + { + _editingPoiGroupName = nil; + return; + } + + OAGPXAppearanceCollection *appearanceCollection = [OAGPXAppearanceCollection sharedInstance]; + [appearanceCollection selectColor:[appearanceCollection getColorItemWithValue:[color toARGBNumber]]]; + if (gpxChanged && gpxFile.path.length > 0) + [self refreshStoredPoiGroupAppearanceWithItems:changedGpxItems gpxFile:gpxFile groupKey:groupKey color:color]; + + if (draftChanged) + [self refreshDraftGpx]; + + if ((gpxChanged && gpxFile.path.length == 0) || draftChanged) + { + if (self.onChange) + self.onChange(); + } + + _editingPoiGroupName = nil; +} + #pragma mark - OAGpxWptEditingHandlerDelegate - (void)saveGpxWpt:(OAGpxWptItem *)gpxWpt gpxFileName:(NSString *)gpxFileName diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index fb97cfcc7f..8418bbfa7d 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -132,6 +132,10 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { bridge.renamePoiGroup(from: oldName, to: newName) } + func openPoiGroupAppearance(_ groupName: String, from presentingViewController: UIViewController) { + bridge.openPoiGroupAppearance(groupName, presenting: presentingViewController) + } + func undo() { bridge.undo() } diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift index ca924de3b8..73181dfee6 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift @@ -184,6 +184,7 @@ extension PlanRoutePoiViewController { } func onChangePoiGroupAppearance(_ group: PlanRoutePoiGroup) { + (dataSource as? PlanRouteEditingContextDataProvider)?.openPoiGroupAppearance(group.name, from: self) } func onSortPoiGroup(_ group: PlanRoutePoiGroup, sortMode: TracksSortMode) { From 83bff4cbcc29c2bfdc3b4e149e3a2b6d478d5a37 Mon Sep 17 00:00:00 2001 From: Dmitry Svetlichny Date: Fri, 26 Jun 2026 16:58:52 +0300 Subject: [PATCH 46/47] Add Delete --- .../PlanRoute/OAPlanRouteEditingBridge.h | 1 + .../PlanRoute/OAPlanRouteEditingBridge.mm | 53 +++++++++++++++++++ .../PlanRouteEditingContextDataProvider.swift | 4 ++ .../Tabs/PlanRoutePoiViewController.swift | 25 +-------- 4 files changed, 59 insertions(+), 24 deletions(-) diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h index d9e885e600..614e922099 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.h @@ -82,6 +82,7 @@ NS_ASSUME_NONNULL_BEGIN - (NSArray *)availableModes; - (void)renamePoiGroupFromName:(NSString *)oldName toName:(NSString *)newName NS_SWIFT_NAME(renamePoiGroup(from:to:)); - (void)openPoiGroupAppearanceForName:(NSString *)groupName presentingViewController:(UIViewController *)presentingViewController NS_SWIFT_NAME(openPoiGroupAppearance(_:presenting:)); +- (void)deletePoiGroupWithName:(NSString *)groupName NS_SWIFT_NAME(deletePoiGroup(_:)); - (void)deletePointAtIndex:(NSInteger)index; - (void)movePointFrom:(NSInteger)from to:(NSInteger)to; diff --git a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm index 439b451113..996992b188 100644 --- a/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm +++ b/Sources/Controllers/PlanRoute/OAPlanRouteEditingBridge.mm @@ -99,6 +99,7 @@ - (BOOL)renamePoiGroupInGpx:(nullable OASGpxFile *)gpxFile fromKey:(NSString *)oldKey toKey:(NSString *)newKey displayName:(NSString *)displayName; +- (BOOL)deletePoiGroupInGpx:(nullable OASGpxFile *)gpxFile groupKey:(NSString *)groupKey; - (NSInteger)getPoiGroupColor:(NSString *)groupName; - (NSArray *)changePoiGroupAppearanceInGpx:(nullable OASGpxFile *)gpxFile groupKey:(NSString *)groupKey @@ -467,6 +468,46 @@ - (void)openPoiGroupAppearanceForName:(NSString *)groupName presentingViewContro [presentingViewController presentViewController:navigationController animated:YES completion:nil]; } +- (void)deletePoiGroupWithName:(NSString *)groupName +{ + NSString *groupKey = [self poiGroupKeyForName:groupName]; + OASGpxFile *gpxFile = [self editingContext].gpxData.gpxFile; + NSMutableArray *gpxItems = [NSMutableArray array]; + for (OASWptPt *point in gpxFile.getPointsList) + { + if ([[self poiGroupKeyForName:point.category] isEqualToString:groupKey]) + [gpxItems addObject:[OAGpxWptItem withGpxWpt:point]]; + } + + NSString *path = gpxItems.count > 0 && gpxFile.path.length > 0 ? [self absoluteGpxPathFromPath:gpxFile.path] : nil; + if (path.length > 0) + { + OAMapViewController *mapViewController = OARootViewController.instance.mapPanel.mapViewController; + if (![mapViewController deleteWpts:gpxItems docPath:path]) + { + [self deletePoiGroupInGpx:gpxFile groupKey:groupKey]; + [OASGpxUtilities.shared writeGpxFileFile:[[OASKFile alloc] initWithFilePath:path] gpxFile:gpxFile]; + dispatch_async(dispatch_get_main_queue(), ^{ + [mapViewController.mapLayers.gpxMapLayer updateCachedGpxItem:path]; + [mapViewController.mapLayers.gpxMapLayer refreshGpxWaypoints]; + }); + } + } + else if (gpxItems.count > 0) + { + [self deletePoiGroupInGpx:gpxFile groupKey:groupKey]; + } + + BOOL draftChanged = [self deletePoiGroupInGpx:_draftGpxFile groupKey:groupKey]; + if (draftChanged) + [self refreshDraftGpx]; + + if (path.length > 0) + [self notifyStoredGpxChangedAtPath:path]; + else if ((gpxItems.count > 0 || draftChanged) && self.onChange) + self.onChange(); +} + - (NSString *)poiGroupKeyForName:(NSString *)groupName { return [groupName isEqualToString:OALocalizedString(@"shared_string_gpx_points")] ? @"" : (groupName ?: @""); @@ -506,6 +547,18 @@ - (BOOL)renamePoiGroupInGpx:(OASGpxFile *)gpxFile return changed; } +- (BOOL)deletePoiGroupInGpx:(OASGpxFile *)gpxFile groupKey:(NSString *)groupKey +{ + NSArray *points = [gpxFile.getPointsList copy]; + for (OASWptPt *point in points) + { + if ([[self poiGroupKeyForName:point.category] isEqualToString:groupKey]) + [gpxFile deleteWptPtPoint:point]; + } + + return points.count != gpxFile.getPointsList.count; +} + - (NSInteger)getPoiGroupColor:(NSString *)groupName { NSString *groupKey = [self poiGroupKeyForName:groupName]; diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index 8418bbfa7d..b2299a2b84 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -136,6 +136,10 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { bridge.openPoiGroupAppearance(groupName, presenting: presentingViewController) } + func deletePoiGroup(_ groupName: String) { + bridge.deletePoiGroup(groupName) + } + func undo() { bridge.undo() } diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift index 73181dfee6..4e4ff25d72 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift @@ -131,7 +131,6 @@ extension PlanRoutePoiViewController { return UIMenu.composedMenu(from: [ [rename, changeAppearance], [makePoiGroupSortMenu(group)], - [makePoiGroupCopyFromMenu(group)], [delete] ]) } @@ -149,20 +148,6 @@ extension PlanRoutePoiViewController { } } - func makePoiGroupCopyFromMenu(_ group: PlanRoutePoiGroup) -> UIMenu { - let favorites = UIAction(title: localizedString("shared_string_favorites"), image: .icCustomFavorites) { [weak self] _ in - self?.onCopyPoiGroupFromFavorites(group) - } - let track = UIAction(title: localizedString("shared_string_gpx_track"), image: .icCustomTrip) { [weak self] _ in - self?.onCopyPoiGroupFromTrack(group) - } - let mapMarkers = UIAction(title: localizedString("map_markers"), image: .icCustomMarker) { [weak self] _ in - self?.onCopyPoiGroupFromMapMarkers(group) - } - - return UIMenu(title: localizedString("shared_string_copy_from"), image: .icCustomCopy, children: [favorites, track, mapMarkers]) - } - func onRenamePoiGroup(_ group: PlanRoutePoiGroup) { let alert = UIAlertController(title: localizedString("shared_string_rename"), message: localizedString("enter_new_name"), preferredStyle: .alert) alert.addTextField { textField in @@ -190,16 +175,8 @@ extension PlanRoutePoiViewController { func onSortPoiGroup(_ group: PlanRoutePoiGroup, sortMode: TracksSortMode) { } - func onCopyPoiGroupFromFavorites(_ group: PlanRoutePoiGroup) { - } - - func onCopyPoiGroupFromTrack(_ group: PlanRoutePoiGroup) { - } - - func onCopyPoiGroupFromMapMarkers(_ group: PlanRoutePoiGroup) { - } - func onDeletePoiGroup(_ group: PlanRoutePoiGroup) { + (dataSource as? PlanRouteEditingContextDataProvider)?.deletePoiGroup(group.name) } } From 10fa0f2647ae0cc89a27fe82e2b56d3e4425680e Mon Sep 17 00:00:00 2001 From: Dmitry Svetlichny Date: Fri, 26 Jun 2026 17:29:31 +0300 Subject: [PATCH 47/47] Add Tap on point should open context menu --- .../PlanRoute/PlanRouteEditingContextDataProvider.swift | 3 ++- Sources/Controllers/PlanRoute/PlanRouteModels.swift | 1 + .../PlanRoute/Tabs/PlanRoutePoiViewController.swift | 5 +++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift index b2299a2b84..6fcc43c968 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteEditingContextDataProvider.swift @@ -258,7 +258,8 @@ final class PlanRouteEditingContextDataProvider: PlanRouteDataProvider { let name = item.point.name ?? "" return PlanRoutePoiPoint(name: name.isEmpty ? localizedString("shared_string_waypoint") : name, subtitle: item.point.getAddress() ?? "", - icon: item.compositeIconWithDefaultColor()) + icon: item.compositeIconWithDefaultColor(), + item: item) } private func mapGroup(_ group: OAPlanRouteGroupData) -> PlanRouteProfileGroup { diff --git a/Sources/Controllers/PlanRoute/PlanRouteModels.swift b/Sources/Controllers/PlanRoute/PlanRouteModels.swift index 10cc7216e1..2e976da3bb 100644 --- a/Sources/Controllers/PlanRoute/PlanRouteModels.swift +++ b/Sources/Controllers/PlanRoute/PlanRouteModels.swift @@ -168,6 +168,7 @@ struct PlanRoutePoiPoint { let name: String let subtitle: String let icon: UIImage + let item: OAGpxWptItem } struct PlanRouteElevationData { diff --git a/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift index 4e4ff25d72..f646a948a3 100644 --- a/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift +++ b/Sources/Controllers/PlanRoute/Tabs/PlanRoutePoiViewController.swift @@ -114,6 +114,11 @@ extension PlanRoutePoiViewController: UITableViewDelegate { header.configure(title: group.name, subtitle: poiCountText(group.points.count), menu: makePoiGroupMenu(for: group)) return header } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + OARootViewController.instance().mapPanel?.openTargetView(withWpt: groups[indexPath.section].points[indexPath.row].item, pushed: false) + } } extension PlanRoutePoiViewController {