From 5143238788d8904914bcfd560d089f8b440dfb28 Mon Sep 17 00:00:00 2001 From: serverlele30 Date: Fri, 23 Oct 2026 15:34:16 +0200 Subject: [PATCH] =?UTF-8?q?BVGNearby=20v10=20=E2=80=93=20iOS-9-Abfahrtsmon?= =?UTF-8?q?itor=20f=C3=BCrs=20iPhone=204S?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 + BVGNearby.xcodeproj/project.pbxproj | 164 +++++- BVGNearby/AlarmsViewController.h | 4 + BVGNearby/AlarmsViewController.m | 97 +++ BVGNearby/AppDelegate.h | 11 +- BVGNearby/AppDelegate.m | 185 +++++- BVGNearby/AppIcon.appiconset/Contents.json | 62 ++ BVGNearby/AppIcon.appiconset/icon-1024.png | Bin 0 -> 16385 bytes BVGNearby/AppIcon.appiconset/icon-20@2x.png | Bin 0 -> 2174 bytes BVGNearby/AppIcon.appiconset/icon-20@3x.png | Bin 0 -> 3397 bytes BVGNearby/AppIcon.appiconset/icon-29@2x.png | Bin 0 -> 3362 bytes BVGNearby/AppIcon.appiconset/icon-29@3x.png | Bin 0 -> 5218 bytes BVGNearby/AppIcon.appiconset/icon-40@2x.png | Bin 0 -> 4820 bytes BVGNearby/AppIcon.appiconset/icon-40@3x.png | Bin 0 -> 7522 bytes BVGNearby/AppIcon.appiconset/icon-60@2x.png | Bin 0 -> 7522 bytes BVGNearby/AppIcon.appiconset/icon-60@3x.png | Bin 0 -> 11466 bytes .../AppIcon.appiconset/Contents.json | 118 ++-- .../AppIcon.appiconset/icon-1024.png | Bin 0 -> 16385 bytes .../AppIcon.appiconset/icon-20@2x.png | Bin 0 -> 2174 bytes .../AppIcon.appiconset/icon-20@3x.png | Bin 0 -> 3397 bytes .../AppIcon.appiconset/icon-29@2x.png | Bin 0 -> 3362 bytes .../AppIcon.appiconset/icon-29@3x.png | Bin 0 -> 5218 bytes .../AppIcon.appiconset/icon-40@2x.png | Bin 0 -> 4820 bytes .../AppIcon.appiconset/icon-40@3x.png | Bin 0 -> 7522 bytes .../AppIcon.appiconset/icon-60@2x.png | Bin 0 -> 7522 bytes .../AppIcon.appiconset/icon-60@3x.png | Bin 0 -> 11466 bytes BVGNearby/BVGAPI.h | 57 ++ BVGNearby/BVGAPI.m | 554 ++++++++++++++++++ BVGNearby/BVGAnnotation.h | 16 + BVGNearby/BVGAnnotation.m | 7 + BVGNearby/BVGSettings.h | 23 + BVGNearby/BVGSettings.m | 61 ++ BVGNearby/BVGTheme.h | 31 + BVGNearby/BVGTheme.m | 72 +++ BVGNearby/Base.lproj/Main.storyboard | 24 - BVGNearby/CardCell.h | 10 + BVGNearby/CardCell.m | 110 ++++ BVGNearby/CardTableViewController.h | 21 + BVGNearby/CardTableViewController.m | 215 +++++++ BVGNearby/ChipBar.h | 9 + BVGNearby/ChipBar.m | 68 +++ BVGNearby/DepartureCell.h | 8 + BVGNearby/DepartureCell.m | 195 ++++++ BVGNearby/DisruptionsViewController.h | 9 + BVGNearby/DisruptionsViewController.m | 112 ++++ BVGNearby/GuidanceManager.h | 41 ++ BVGNearby/GuidanceManager.m | 503 ++++++++++++++++ BVGNearby/GuidanceViewController.h | 4 + BVGNearby/GuidanceViewController.m | 305 ++++++++++ BVGNearby/Info.plist | 21 +- BVGNearby/JourneyDetailViewController.h | 5 + BVGNearby/JourneyDetailViewController.m | 185 ++++++ BVGNearby/JourneyViewController.h | 4 + BVGNearby/JourneyViewController.m | 493 ++++++++++++++++ BVGNearby/LIESMICH.txt | 20 + BVGNearby/MapViewController.h | 4 + BVGNearby/MapViewController.m | 301 ++++++++++ BVGNearby/SceneDelegate.h | 15 - BVGNearby/SceneDelegate.m | 57 -- BVGNearby/SearchViewController.h | 8 + BVGNearby/SearchViewController.m | 222 +++++++ BVGNearby/SettingsViewController.h | 4 + BVGNearby/SettingsViewController.m | 167 ++++++ BVGNearby/StopViewController.h | 5 + BVGNearby/StopViewController.m | 303 ++++++++++ BVGNearby/TripMapViewController.h | 5 + BVGNearby/TripMapViewController.m | 158 +++++ BVGNearby/TripViewController.h | 7 + BVGNearby/TripViewController.m | 251 ++++++++ BVGNearby/ViewController.h | 14 +- BVGNearby/ViewController.m | 546 ++++++++++++++++- BVGNearby/add_files.rb | 22 + deploy.sh | 17 + 73 files changed, 5669 insertions(+), 268 deletions(-) create mode 100644 .gitignore create mode 100644 BVGNearby/AlarmsViewController.h create mode 100644 BVGNearby/AlarmsViewController.m create mode 100644 BVGNearby/AppIcon.appiconset/Contents.json create mode 100644 BVGNearby/AppIcon.appiconset/icon-1024.png create mode 100644 BVGNearby/AppIcon.appiconset/icon-20@2x.png create mode 100644 BVGNearby/AppIcon.appiconset/icon-20@3x.png create mode 100644 BVGNearby/AppIcon.appiconset/icon-29@2x.png create mode 100644 BVGNearby/AppIcon.appiconset/icon-29@3x.png create mode 100644 BVGNearby/AppIcon.appiconset/icon-40@2x.png create mode 100644 BVGNearby/AppIcon.appiconset/icon-40@3x.png create mode 100644 BVGNearby/AppIcon.appiconset/icon-60@2x.png create mode 100644 BVGNearby/AppIcon.appiconset/icon-60@3x.png create mode 100644 BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-1024.png create mode 100644 BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png create mode 100644 BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png create mode 100644 BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png create mode 100644 BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png create mode 100644 BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png create mode 100644 BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png create mode 100644 BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png create mode 100644 BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png create mode 100644 BVGNearby/BVGAPI.h create mode 100644 BVGNearby/BVGAPI.m create mode 100644 BVGNearby/BVGAnnotation.h create mode 100644 BVGNearby/BVGAnnotation.m create mode 100644 BVGNearby/BVGSettings.h create mode 100644 BVGNearby/BVGSettings.m create mode 100644 BVGNearby/BVGTheme.h create mode 100644 BVGNearby/BVGTheme.m delete mode 100644 BVGNearby/Base.lproj/Main.storyboard create mode 100644 BVGNearby/CardCell.h create mode 100644 BVGNearby/CardCell.m create mode 100644 BVGNearby/CardTableViewController.h create mode 100644 BVGNearby/CardTableViewController.m create mode 100644 BVGNearby/ChipBar.h create mode 100644 BVGNearby/ChipBar.m create mode 100644 BVGNearby/DepartureCell.h create mode 100644 BVGNearby/DepartureCell.m create mode 100644 BVGNearby/DisruptionsViewController.h create mode 100644 BVGNearby/DisruptionsViewController.m create mode 100644 BVGNearby/GuidanceManager.h create mode 100644 BVGNearby/GuidanceManager.m create mode 100644 BVGNearby/GuidanceViewController.h create mode 100644 BVGNearby/GuidanceViewController.m create mode 100644 BVGNearby/JourneyDetailViewController.h create mode 100644 BVGNearby/JourneyDetailViewController.m create mode 100644 BVGNearby/JourneyViewController.h create mode 100644 BVGNearby/JourneyViewController.m create mode 100644 BVGNearby/LIESMICH.txt create mode 100644 BVGNearby/MapViewController.h create mode 100644 BVGNearby/MapViewController.m delete mode 100644 BVGNearby/SceneDelegate.h delete mode 100644 BVGNearby/SceneDelegate.m create mode 100644 BVGNearby/SearchViewController.h create mode 100644 BVGNearby/SearchViewController.m create mode 100644 BVGNearby/SettingsViewController.h create mode 100644 BVGNearby/SettingsViewController.m create mode 100644 BVGNearby/StopViewController.h create mode 100644 BVGNearby/StopViewController.m create mode 100644 BVGNearby/TripMapViewController.h create mode 100644 BVGNearby/TripMapViewController.m create mode 100644 BVGNearby/TripViewController.h create mode 100644 BVGNearby/TripViewController.m create mode 100644 BVGNearby/add_files.rb create mode 100755 deploy.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb2ab1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build/ +DerivedData/ +xcuserdata/ +*.xcuserstate +*.ipa +Payload/ +.DS_Store diff --git a/BVGNearby.xcodeproj/project.pbxproj b/BVGNearby.xcodeproj/project.pbxproj index f14851f..49a07c8 100644 --- a/BVGNearby.xcodeproj/project.pbxproj +++ b/BVGNearby.xcodeproj/project.pbxproj @@ -8,27 +8,88 @@ /* Begin PBXBuildFile section */ 659F88D5308B5CF700D46649 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 659F88D4308B5CF700D46649 /* AppDelegate.m */; }; - 659F88D8308B5CF700D46649 /* SceneDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 659F88D7308B5CF700D46649 /* SceneDelegate.m */; }; 659F88DB308B5CF700D46649 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 659F88DA308B5CF700D46649 /* ViewController.m */; }; - 659F88DE308B5CF700D46649 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 659F88DC308B5CF700D46649 /* Main.storyboard */; }; 659F88E0308B5CFA00D46649 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 659F88DF308B5CFA00D46649 /* Assets.xcassets */; }; 659F88E3308B5CFA00D46649 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 659F88E1308B5CFA00D46649 /* LaunchScreen.storyboard */; }; 659F88E6308B5CFA00D46649 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 659F88E5308B5CFA00D46649 /* main.m */; }; + 65AABC6C308B75F30050A7AC /* TripViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC6B308B75F30050A7AC /* TripViewController.m */; }; + 65AABC77308B7A0E0050A7AC /* DepartureCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC6D308B7A0D0050A7AC /* DepartureCell.m */; }; + 65AABC78308B7A0E0050A7AC /* SearchViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC6E308B7A0D0050A7AC /* SearchViewController.m */; }; + 65AABC79308B7A0E0050A7AC /* StopViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC6F308B7A0D0050A7AC /* StopViewController.m */; }; + 65AABC7A308B7A0E0050A7AC /* AlarmsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC70308B7A0D0050A7AC /* AlarmsViewController.m */; }; + 65AABC7B308B7A0E0050A7AC /* BVGAPI.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC76308B7A0E0050A7AC /* BVGAPI.m */; }; + 65AABC87308B7D8C0050A7AC /* BVGAnnotation.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC7C308B7D8A0050A7AC /* BVGAnnotation.m */; }; + 65AABC88308B7D8C0050A7AC /* TripMapViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC7D308B7D8A0050A7AC /* TripMapViewController.m */; }; + 65AABC89308B7D8C0050A7AC /* JourneyDetailViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC7F308B7D8B0050A7AC /* JourneyDetailViewController.m */; }; + 65AABC8A308B7D8C0050A7AC /* JourneyViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC80308B7D8B0050A7AC /* JourneyViewController.m */; }; + 65AABC8B308B7D8C0050A7AC /* LIESMICH.txt in Resources */ = {isa = PBXBuildFile; fileRef = 65AABC85308B7D8B0050A7AC /* LIESMICH.txt */; }; + 65AABC8C308B7D8C0050A7AC /* MapViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC86308B7D8C0050A7AC /* MapViewController.m */; }; + 65AABC91308B81050050A7AC /* GuidanceViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC8E308B81040050A7AC /* GuidanceViewController.m */; }; + 65AABC92308B81050050A7AC /* GuidanceManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC90308B81050050A7AC /* GuidanceManager.m */; }; + 65AABC98308B83BE0050A7AC /* SettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC94308B83BE0050A7AC /* SettingsViewController.m */; }; + 65AABC99308B83BE0050A7AC /* BVGSettings.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC95308B83BE0050A7AC /* BVGSettings.m */; }; + 65AABC9C308B87630050A7AC /* AppIcon.appiconset in Resources */ = {isa = PBXBuildFile; fileRef = 65AABC9B308B87630050A7AC /* AppIcon.appiconset */; }; + 65AABCA5308B87E00050A7AC /* CardCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABC9E308B87E00050A7AC /* CardCell.m */; }; + 65AABCA6308B87E00050A7AC /* ChipBar.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABCA1308B87E00050A7AC /* ChipBar.m */; }; + 65AABCA7308B87E00050A7AC /* BVGTheme.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABCA3308B87E00050A7AC /* BVGTheme.m */; }; + 65AABCA8308B87E00050A7AC /* CardTableViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABCA4308B87E00050A7AC /* CardTableViewController.m */; }; + 65AABCAC308B91540050A7AC /* add_files.rb in Resources */ = {isa = PBXBuildFile; fileRef = 65AABCA9308B91530050A7AC /* add_files.rb */; }; + 65AABCAD308B91540050A7AC /* DisruptionsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 65AABCAB308B91540050A7AC /* DisruptionsViewController.m */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 659F88D0308B5CF700D46649 /* BVGNearby.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BVGNearby.app; sourceTree = BUILT_PRODUCTS_DIR; }; 659F88D3308B5CF700D46649 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 659F88D4308B5CF700D46649 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; - 659F88D6308B5CF700D46649 /* SceneDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SceneDelegate.h; sourceTree = ""; }; - 659F88D7308B5CF700D46649 /* SceneDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SceneDelegate.m; sourceTree = ""; }; 659F88D9308B5CF700D46649 /* ViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ViewController.h; sourceTree = ""; }; 659F88DA308B5CF700D46649 /* ViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ViewController.m; sourceTree = ""; }; - 659F88DD308B5CF700D46649 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 659F88DF308B5CFA00D46649 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 659F88E2308B5CFA00D46649 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 659F88E4308B5CFA00D46649 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 659F88E5308B5CFA00D46649 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 65AABC6A308B75CF0050A7AC /* TripViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TripViewController.h; sourceTree = ""; }; + 65AABC6B308B75F30050A7AC /* TripViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TripViewController.m; sourceTree = ""; }; + 65AABC6D308B7A0D0050A7AC /* DepartureCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DepartureCell.m; sourceTree = ""; }; + 65AABC6E308B7A0D0050A7AC /* SearchViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SearchViewController.m; sourceTree = ""; }; + 65AABC6F308B7A0D0050A7AC /* StopViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = StopViewController.m; sourceTree = ""; }; + 65AABC70308B7A0D0050A7AC /* AlarmsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AlarmsViewController.m; sourceTree = ""; }; + 65AABC71308B7A0D0050A7AC /* StopViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = StopViewController.h; sourceTree = ""; }; + 65AABC72308B7A0E0050A7AC /* SearchViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SearchViewController.h; sourceTree = ""; }; + 65AABC73308B7A0E0050A7AC /* DepartureCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DepartureCell.h; sourceTree = ""; }; + 65AABC74308B7A0E0050A7AC /* BVGAPI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BVGAPI.h; sourceTree = ""; }; + 65AABC75308B7A0E0050A7AC /* AlarmsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AlarmsViewController.h; sourceTree = ""; }; + 65AABC76308B7A0E0050A7AC /* BVGAPI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BVGAPI.m; sourceTree = ""; }; + 65AABC7C308B7D8A0050A7AC /* BVGAnnotation.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BVGAnnotation.m; sourceTree = ""; }; + 65AABC7D308B7D8A0050A7AC /* TripMapViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TripMapViewController.m; sourceTree = ""; }; + 65AABC7E308B7D8A0050A7AC /* TripMapViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TripMapViewController.h; sourceTree = ""; }; + 65AABC7F308B7D8B0050A7AC /* JourneyDetailViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = JourneyDetailViewController.m; sourceTree = ""; }; + 65AABC80308B7D8B0050A7AC /* JourneyViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = JourneyViewController.m; sourceTree = ""; }; + 65AABC81308B7D8B0050A7AC /* JourneyViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = JourneyViewController.h; sourceTree = ""; }; + 65AABC82308B7D8B0050A7AC /* BVGAnnotation.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BVGAnnotation.h; sourceTree = ""; }; + 65AABC83308B7D8B0050A7AC /* JourneyDetailViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = JourneyDetailViewController.h; sourceTree = ""; }; + 65AABC84308B7D8B0050A7AC /* MapViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MapViewController.h; sourceTree = ""; }; + 65AABC85308B7D8B0050A7AC /* LIESMICH.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LIESMICH.txt; sourceTree = ""; }; + 65AABC86308B7D8C0050A7AC /* MapViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MapViewController.m; sourceTree = ""; }; + 65AABC8D308B81040050A7AC /* GuidanceManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GuidanceManager.h; sourceTree = ""; }; + 65AABC8E308B81040050A7AC /* GuidanceViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GuidanceViewController.m; sourceTree = ""; }; + 65AABC8F308B81050050A7AC /* GuidanceViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GuidanceViewController.h; sourceTree = ""; }; + 65AABC90308B81050050A7AC /* GuidanceManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GuidanceManager.m; sourceTree = ""; }; + 65AABC93308B83BE0050A7AC /* BVGSettings.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BVGSettings.h; sourceTree = ""; }; + 65AABC94308B83BE0050A7AC /* SettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsViewController.m; sourceTree = ""; }; + 65AABC95308B83BE0050A7AC /* BVGSettings.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BVGSettings.m; sourceTree = ""; }; + 65AABC97308B83BE0050A7AC /* SettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsViewController.h; sourceTree = ""; }; + 65AABC9B308B87630050A7AC /* AppIcon.appiconset */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.appiconset; sourceTree = ""; }; + 65AABC9D308B87E00050A7AC /* CardCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CardCell.h; sourceTree = ""; }; + 65AABC9E308B87E00050A7AC /* CardCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CardCell.m; sourceTree = ""; }; + 65AABC9F308B87E00050A7AC /* ChipBar.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ChipBar.h; sourceTree = ""; }; + 65AABCA0308B87E00050A7AC /* BVGTheme.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BVGTheme.h; sourceTree = ""; }; + 65AABCA1308B87E00050A7AC /* ChipBar.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ChipBar.m; sourceTree = ""; }; + 65AABCA2308B87E00050A7AC /* CardTableViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CardTableViewController.h; sourceTree = ""; }; + 65AABCA3308B87E00050A7AC /* BVGTheme.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BVGTheme.m; sourceTree = ""; }; + 65AABCA4308B87E00050A7AC /* CardTableViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CardTableViewController.m; sourceTree = ""; }; + 65AABCA9308B91530050A7AC /* add_files.rb */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.ruby; path = add_files.rb; sourceTree = ""; }; + 65AABCAA308B91530050A7AC /* DisruptionsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DisruptionsViewController.h; sourceTree = ""; }; + 65AABCAB308B91540050A7AC /* DisruptionsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DisruptionsViewController.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -61,17 +122,57 @@ 659F88D2308B5CF700D46649 /* BVGNearby */ = { isa = PBXGroup; children = ( + 65AABC6A308B75CF0050A7AC /* TripViewController.h */, + 65AABC6B308B75F30050A7AC /* TripViewController.m */, 659F88D3308B5CF700D46649 /* AppDelegate.h */, 659F88D4308B5CF700D46649 /* AppDelegate.m */, - 659F88D6308B5CF700D46649 /* SceneDelegate.h */, - 659F88D7308B5CF700D46649 /* SceneDelegate.m */, 659F88D9308B5CF700D46649 /* ViewController.h */, 659F88DA308B5CF700D46649 /* ViewController.m */, - 659F88DC308B5CF700D46649 /* Main.storyboard */, 659F88DF308B5CFA00D46649 /* Assets.xcassets */, 659F88E1308B5CFA00D46649 /* LaunchScreen.storyboard */, 659F88E4308B5CFA00D46649 /* Info.plist */, 659F88E5308B5CFA00D46649 /* main.m */, + 65AABC75308B7A0E0050A7AC /* AlarmsViewController.h */, + 65AABC70308B7A0D0050A7AC /* AlarmsViewController.m */, + 65AABC74308B7A0E0050A7AC /* BVGAPI.h */, + 65AABC93308B83BE0050A7AC /* BVGSettings.h */, + 65AABC95308B83BE0050A7AC /* BVGSettings.m */, + 65AABC9B308B87630050A7AC /* AppIcon.appiconset */, + 65AABC97308B83BE0050A7AC /* SettingsViewController.h */, + 65AABC94308B83BE0050A7AC /* SettingsViewController.m */, + 65AABC76308B7A0E0050A7AC /* BVGAPI.m */, + 65AABCA9308B91530050A7AC /* add_files.rb */, + 65AABCAA308B91530050A7AC /* DisruptionsViewController.h */, + 65AABCAB308B91540050A7AC /* DisruptionsViewController.m */, + 65AABC73308B7A0E0050A7AC /* DepartureCell.h */, + 65AABC6D308B7A0D0050A7AC /* DepartureCell.m */, + 65AABC72308B7A0E0050A7AC /* SearchViewController.h */, + 65AABC6E308B7A0D0050A7AC /* SearchViewController.m */, + 65AABCA0308B87E00050A7AC /* BVGTheme.h */, + 65AABCA3308B87E00050A7AC /* BVGTheme.m */, + 65AABC9D308B87E00050A7AC /* CardCell.h */, + 65AABC9E308B87E00050A7AC /* CardCell.m */, + 65AABCA2308B87E00050A7AC /* CardTableViewController.h */, + 65AABCA4308B87E00050A7AC /* CardTableViewController.m */, + 65AABC9F308B87E00050A7AC /* ChipBar.h */, + 65AABCA1308B87E00050A7AC /* ChipBar.m */, + 65AABC71308B7A0D0050A7AC /* StopViewController.h */, + 65AABC6F308B7A0D0050A7AC /* StopViewController.m */, + 65AABC8D308B81040050A7AC /* GuidanceManager.h */, + 65AABC90308B81050050A7AC /* GuidanceManager.m */, + 65AABC8F308B81050050A7AC /* GuidanceViewController.h */, + 65AABC8E308B81040050A7AC /* GuidanceViewController.m */, + 65AABC82308B7D8B0050A7AC /* BVGAnnotation.h */, + 65AABC7C308B7D8A0050A7AC /* BVGAnnotation.m */, + 65AABC83308B7D8B0050A7AC /* JourneyDetailViewController.h */, + 65AABC7F308B7D8B0050A7AC /* JourneyDetailViewController.m */, + 65AABC81308B7D8B0050A7AC /* JourneyViewController.h */, + 65AABC80308B7D8B0050A7AC /* JourneyViewController.m */, + 65AABC85308B7D8B0050A7AC /* LIESMICH.txt */, + 65AABC84308B7D8B0050A7AC /* MapViewController.h */, + 65AABC86308B7D8C0050A7AC /* MapViewController.m */, + 65AABC7E308B7D8A0050A7AC /* TripMapViewController.h */, + 65AABC7D308B7D8A0050A7AC /* TripMapViewController.m */, ); path = BVGNearby; sourceTree = ""; @@ -133,9 +234,11 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 65AABCAC308B91540050A7AC /* add_files.rb in Resources */, + 65AABC9C308B87630050A7AC /* AppIcon.appiconset in Resources */, 659F88E3308B5CFA00D46649 /* LaunchScreen.storyboard in Resources */, 659F88E0308B5CFA00D46649 /* Assets.xcassets in Resources */, - 659F88DE308B5CF700D46649 /* Main.storyboard in Resources */, + 65AABC8B308B7D8C0050A7AC /* LIESMICH.txt in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -146,24 +249,35 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 65AABC91308B81050050A7AC /* GuidanceViewController.m in Sources */, + 65AABC8A308B7D8C0050A7AC /* JourneyViewController.m in Sources */, + 65AABC7B308B7A0E0050A7AC /* BVGAPI.m in Sources */, + 65AABCA8308B87E00050A7AC /* CardTableViewController.m in Sources */, + 65AABC88308B7D8C0050A7AC /* TripMapViewController.m in Sources */, + 65AABCA5308B87E00050A7AC /* CardCell.m in Sources */, + 65AABC89308B7D8C0050A7AC /* JourneyDetailViewController.m in Sources */, + 65AABC7A308B7A0E0050A7AC /* AlarmsViewController.m in Sources */, + 65AABC87308B7D8C0050A7AC /* BVGAnnotation.m in Sources */, + 65AABC92308B81050050A7AC /* GuidanceManager.m in Sources */, 659F88DB308B5CF700D46649 /* ViewController.m in Sources */, + 65AABC98308B83BE0050A7AC /* SettingsViewController.m in Sources */, + 65AABC77308B7A0E0050A7AC /* DepartureCell.m in Sources */, + 65AABCAD308B91540050A7AC /* DisruptionsViewController.m in Sources */, + 65AABC6C308B75F30050A7AC /* TripViewController.m in Sources */, 659F88D5308B5CF700D46649 /* AppDelegate.m in Sources */, + 65AABC79308B7A0E0050A7AC /* StopViewController.m in Sources */, + 65AABC99308B83BE0050A7AC /* BVGSettings.m in Sources */, + 65AABC78308B7A0E0050A7AC /* SearchViewController.m in Sources */, + 65AABCA7308B87E00050A7AC /* BVGTheme.m in Sources */, + 65AABCA6308B87E00050A7AC /* ChipBar.m in Sources */, 659F88E6308B5CFA00D46649 /* main.m in Sources */, - 659F88D8308B5CF700D46649 /* SceneDelegate.m in Sources */, + 65AABC8C308B7D8C0050A7AC /* MapViewController.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ - 659F88DC308B5CF700D46649 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 659F88DD308B5CF700D46649 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; 659F88E1308B5CFA00D46649 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -226,7 +340,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -279,7 +393,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -292,7 +406,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "Apple Development: aaronkastl@web.de (P8TY627ZUW)"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 95DARFF4KP; GENERATE_INFOPLIST_FILE = YES; @@ -319,7 +436,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "Apple Development: aaronkastl@web.de (P8TY627ZUW)"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 95DARFF4KP; GENERATE_INFOPLIST_FILE = YES; diff --git a/BVGNearby/AlarmsViewController.h b/BVGNearby/AlarmsViewController.h new file mode 100644 index 0000000..19784e3 --- /dev/null +++ b/BVGNearby/AlarmsViewController.h @@ -0,0 +1,4 @@ +#import "CardTableViewController.h" + +@interface AlarmsViewController : CardTableViewController +@end diff --git a/BVGNearby/AlarmsViewController.m b/BVGNearby/AlarmsViewController.m new file mode 100644 index 0000000..7685e58 --- /dev/null +++ b/BVGNearby/AlarmsViewController.m @@ -0,0 +1,97 @@ +#import "AlarmsViewController.h" +#import "BVGAPI.h" + +@interface AlarmsViewController () +@property (nonatomic, strong) NSArray *alarms; +@end + +@implementation AlarmsViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.largeTitle = @"Wecker"; + self.tableView.rowHeight = 72; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(reload) + name:BVGAlarmsChangedNotification object:nil]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self reload]; +} + +- (void)reload { + self.alarms = [[BVGAPI userAlarms] sortedArrayUsingComparator:^NSComparisonResult(UILocalNotification *a, UILocalNotification *b) { + return [a.fireDate compare:b.fireDate]; + }]; + if (self.alarms.count == 0) { + self.largeSubtitle = nil; + [self showEmptyIcon:@"⏰" title:@"Keine Wecker" + text:@"Wische bei einer Abfahrt nach links und tippe auf „Wecker“. Bei Stummschalter wird nur vibriert."]; + } else { + self.largeSubtitle = self.alarms.count == 1 ? @"1 Erinnerung aktiv" + : [NSString stringWithFormat:@"%lu Erinnerungen aktiv", (unsigned long)self.alarms.count]; + [self showEmptyIcon:nil title:nil text:nil]; + } + [self.tableView reloadData]; +} + +#pragma mark - Table + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return self.alarms.count; +} + +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section { + return self.alarms.count ? @"Zum Löschen nach links wischen." : nil; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + CardCell *cell = [tableView dequeueReusableCellWithIdentifier:@"alarm"]; + if (!cell) cell = [[CardCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"alarm"]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + + UILocalNotification *n = self.alarms[indexPath.row]; + NSString *line = n.userInfo[@"line"]; + NSString *dir = n.userInfo[@"direction"]; + NSString *stop = n.userInfo[@"stop"]; + NSDate *dep = n.userInfo[@"departure"]; + + // grosse Weckzeit links, darunter die Fahrt + NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:[BVGAPI hhmm:n.fireDate] + attributes:@{ NSFontAttributeName: [BVGTheme digits:26 weight:UIFontWeightSemibold], + NSForegroundColorAttributeName: [BVGTheme textPrimary] }]; + NSInteger mins = (NSInteger)floor([n.fireDate timeIntervalSinceNow] / 60.0); + NSString *rest = mins >= 60 ? [NSString stringWithFormat:@" in %ld:%02ld h", (long)(mins / 60), (long)(mins % 60)] + : [NSString stringWithFormat:@" in %ld min", (long)MAX(mins, 0)]; + [title appendAttributedString:[[NSAttributedString alloc] initWithString:rest + attributes:@{ NSFontAttributeName: [BVGTheme font:14 weight:UIFontWeightMedium], + NSForegroundColorAttributeName: [BVGTheme orange] }]]; + cell.textLabel.attributedText = title; + + NSMutableString *sub = [NSMutableString string]; + if (line) [sub appendFormat:@"%@ → %@", line, dir ?: @""]; + else [sub appendString:n.alertBody ?: @""]; + if ([dep isKindOfClass:[NSDate class]]) [sub appendFormat:@" · ab %@", [BVGAPI hhmm:dep]]; + if (stop.length) [sub appendFormat:@" %@", stop]; + cell.detailTextLabel.text = sub; + cell.detailTextLabel.font = [BVGTheme font:14 weight:UIFontWeightRegular]; + return cell; +} + +- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle + forRowAtIndexPath:(NSIndexPath *)indexPath { + if (editingStyle != UITableViewCellEditingStyleDelete) return; + [[UIApplication sharedApplication] cancelLocalNotification:self.alarms[indexPath.row]]; + [[NSNotificationCenter defaultCenter] postNotificationName:BVGAlarmsChangedNotification object:nil]; +} + +- (NSString *)tableView:(UITableView *)tableView titleForDeleteConfirmationButtonForRowAtIndexPath:(NSIndexPath *)indexPath { + return @"Löschen"; +} + +@end diff --git a/BVGNearby/AppDelegate.h b/BVGNearby/AppDelegate.h index b3af2c3..2e23d96 100644 --- a/BVGNearby/AppDelegate.h +++ b/BVGNearby/AppDelegate.h @@ -1,14 +1,5 @@ -// -// AppDelegate.h -// BVGNearby -// -// Created by Jan Aaron Titus Kastl on 23.10.26. -// - #import @interface AppDelegate : UIResponder - - +@property (strong, nonatomic) UIWindow *window; @end - diff --git a/BVGNearby/AppDelegate.m b/BVGNearby/AppDelegate.m index ab7a54f..a2b796e 100644 --- a/BVGNearby/AppDelegate.m +++ b/BVGNearby/AppDelegate.m @@ -1,40 +1,179 @@ -// -// AppDelegate.m -// BVGNearby -// -// Created by Jan Aaron Titus Kastl on 23.10.26. -// - #import "AppDelegate.h" +#import "ViewController.h" +#import "MapViewController.h" +#import "JourneyViewController.h" +#import "SearchViewController.h" +#import "AlarmsViewController.h" +#import "StopViewController.h" +#import "GuidanceManager.h" +#import "GuidanceViewController.h" +#import "BVGAPI.h" +#import "BVGTheme.h" +#import +#import @interface AppDelegate () - +@property (nonatomic, strong) UITabBarController *tabs; @end @implementation AppDelegate - - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { - // Override point for customization after application launch. + [application registerUserNotificationSettings: + [UIUserNotificationSettings settingsForTypes:(UIUserNotificationTypeAlert | UIUserNotificationTypeSound | UIUserNotificationTypeBadge) + categories:nil]]; + + [BVGTheme applyAppearance]; // modernes Design (Farben, Leisten, Schriften) + + NSArray *roots = @[ + @[[[ViewController alloc] initWithStyle:UITableViewStylePlain], @"In der Nähe", @"nearby"], + @[[[MapViewController alloc] init], @"Karte", @"map"], + @[[[JourneyViewController alloc] initWithStyle:UITableViewStyleGrouped], @"Route", @"route"], + @[[[SearchViewController alloc] initWithStyle:UITableViewStylePlain], @"Suche", @"search"], + @[[[AlarmsViewController alloc] initWithStyle:UITableViewStylePlain], @"Wecker", @"alarm"], + ]; + NSMutableArray *navs = [NSMutableArray array]; + NSInteger tag = 0; + for (NSArray *r in roots) { + UINavigationController *n = [[UINavigationController alloc] initWithRootViewController:r[0]]; + n.tabBarItem = [[UITabBarItem alloc] initWithTitle:r[1] image:[BVGAPI tabIconNamed:r[2]] tag:tag++]; + [navs addObject:n]; + } + + self.tabs = [[UITabBarController alloc] init]; + self.tabs.viewControllers = navs; + + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + self.window.backgroundColor = [BVGTheme background]; + self.window.tintColor = [BVGTheme textPrimary]; + self.window.rootViewController = self.tabs; + [self.window makeKeyAndVisible]; + + // Favoriten in die iOS-Suche aufnehmen (falls frisch installiert) + for (NSDictionary *f in [BVGAPI favorites]) [BVGAPI indexStopInSpotlight:f[@"id"] name:f[@"name"]]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateAlarmBadge) + name:BVGAlarmsChangedNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateGuidanceBadge) + name:GuidanceChangedNotification object:nil]; + [self updateAlarmBadge]; + [GuidanceManager shared]; // laufende Navigation nach App-Neustart wiederherstellen + [self updateGuidanceBadge]; + + // App wurde durch Antippen einer Navigations-Mitteilung gestartet + UILocalNotification *launchNote = launchOptions[UIApplicationLaunchOptionsLocalNotificationKey]; + if (launchNote.userInfo[@"kind"]) [self openGuidance]; return YES; } - -#pragma mark - UISceneSession lifecycle - - -- (UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession options:(UISceneConnectionOptions *)options { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return [[UISceneConfiguration alloc] initWithName:@"Default Configuration" sessionRole:connectingSceneSession.role]; +- (void)updateGuidanceBadge { + UIViewController *routeTab = self.tabs.viewControllers[2]; + GuidanceManager *g = [GuidanceManager shared]; + if (!g.active) routeTab.tabBarItem.badgeValue = nil; + else routeTab.tabBarItem.badgeValue = (g.status == GuidanceStatusBroken || g.alternative) ? @"!" : @"▶"; } - -- (void)application:(UIApplication *)application didDiscardSceneSessions:(NSSet *)sceneSessions { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. +- (void)openGuidance { + if (![GuidanceManager shared].active) return; + [self.tabs dismissViewControllerAnimated:NO completion:nil]; + self.tabs.selectedIndex = 2; + UINavigationController *nav = (UINavigationController *)self.tabs.selectedViewController; + if ([nav.topViewController isKindOfClass:[GuidanceViewController class]]) return; + [nav popToRootViewControllerAnimated:NO]; + [nav pushViewController:[[GuidanceViewController alloc] initWithStyle:UITableViewStyleGrouped] animated:NO]; } ++ (NSString *)speakableNotification:(NSString *)text { + for (NSString *icon in @[@"🚏 ", @"🔔 ", @"⚠ ", @"→"]) { + text = [text stringByReplacingOccurrencesOfString:icon withString:([icon isEqualToString:@"→"] ? @"Richtung" : @"")]; + } + return [text stringByReplacingOccurrencesOfString:@"Antippen zum Entscheiden." withString:@""]; +} + +- (void)updateAlarmBadge { + NSUInteger count = [BVGAPI userAlarms].count; + UIViewController *alarmsTab = self.tabs.viewControllers.lastObject; + alarmsTab.tabBarItem.badgeValue = count ? [@(count) stringValue] : nil; +} + +- (void)applicationDidBecomeActive:(UIApplication *)application { + application.applicationIconBadgeNumber = 0; + [self updateAlarmBadge]; +} + +#pragma mark - Spotlight: Haltestelle aus der iOS-Suche oeffnen + +- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity + restorationHandler:(void (^)(NSArray *))restorationHandler { + if (![userActivity.activityType isEqualToString:CSSearchableItemActionType]) return NO; + NSString *stopId = [BVGAPI stopIdFromSpotlightIdentifier:userActivity.userInfo[CSSearchableItemActivityIdentifier]]; + if (!stopId) return NO; + + [self.tabs dismissViewControllerAnimated:NO completion:nil]; + self.tabs.selectedIndex = 0; + UINavigationController *nav = (UINavigationController *)self.tabs.selectedViewController; + [nav popToRootViewControllerAnimated:NO]; + + StopViewController *vc = [[StopViewController alloc] initWithStopId:stopId + name:[BVGAPI nameForStop:stopId] ?: @"Haltestelle"]; + [nav pushViewController:vc animated:NO]; + return YES; +} + +#pragma mark - Wecker + +- (void)application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification { + [[NSNotificationCenter defaultCenter] postNotificationName:BVGAlarmsChangedNotification object:nil]; + + // Navigation: Erinnerung oder Alternative + if (notification.userInfo[@"kind"]) { + if (application.applicationState != UIApplicationStateActive) { // Mitteilung angetippt + [self openGuidance]; + return; + } + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); + [BVGAPI speak:[AppDelegate speakableNotification:notification.alertBody]]; // bei Stumm: still + + BOOL isAlternative = [notification.userInfo[@"kind"] isEqualToString:@"alternative"]; + UIAlertController *a = [UIAlertController alertControllerWithTitle:(isAlternative ? @"⚠ Route gefährdet" : @"Navigation") + message:notification.alertBody + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:(isAlternative ? @"Ansehen & entscheiden" : @"Navigation öffnen") + style:UIAlertActionStyleDefault handler:^(UIAlertAction *x) { + [BVGAPI stopSpeaking]; + [self openGuidance]; + }]]; + [a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:^(UIAlertAction *x) { + [BVGAPI stopSpeaking]; + }]]; + UIViewController *top = self.window.rootViewController; + while (top.presentedViewController) top = top.presentedViewController; + if (![top isKindOfClass:[UIAlertController class]]) [top presentViewController:a animated:YES completion:nil]; + return; + } + + if (application.applicationState != UIApplicationStateActive) return; + + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); + NSDictionary *info = notification.userInfo; + NSDate *dep = info[@"departure"]; + if (info[@"line"] && [dep isKindOfClass:[NSDate class]]) { + NSInteger mins = MAX(1, (NSInteger)round([dep timeIntervalSinceNow] / 60.0)); + [BVGAPI speak:[NSString stringWithFormat:@"Achtung! %@ Richtung %@ fährt in %ld Minuten ab %@.", + info[@"line"], info[@"direction"] ?: @"", (long)mins, info[@"stop"] ?: @""]]; + } else { + [BVGAPI speak:notification.alertBody]; + } + + UIAlertController *a = [UIAlertController alertControllerWithTitle:@"⏰ Gleich geht's los" + message:notification.alertBody + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction *x) { + [BVGAPI stopSpeaking]; + }]]; + UIViewController *top = self.window.rootViewController; + while (top.presentedViewController) top = top.presentedViewController; + [top presentViewController:a animated:YES completion:nil]; +} @end diff --git a/BVGNearby/AppIcon.appiconset/Contents.json b/BVGNearby/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..b7130bc --- /dev/null +++ b/BVGNearby/AppIcon.appiconset/Contents.json @@ -0,0 +1,62 @@ +{ + "images": [ + { + "idiom": "iphone", + "size": "20x20", + "scale": "2x", + "filename": "icon-20@2x.png" + }, + { + "idiom": "iphone", + "size": "20x20", + "scale": "3x", + "filename": "icon-20@3x.png" + }, + { + "idiom": "iphone", + "size": "29x29", + "scale": "2x", + "filename": "icon-29@2x.png" + }, + { + "idiom": "iphone", + "size": "29x29", + "scale": "3x", + "filename": "icon-29@3x.png" + }, + { + "idiom": "iphone", + "size": "40x40", + "scale": "2x", + "filename": "icon-40@2x.png" + }, + { + "idiom": "iphone", + "size": "40x40", + "scale": "3x", + "filename": "icon-40@3x.png" + }, + { + "idiom": "iphone", + "size": "60x60", + "scale": "2x", + "filename": "icon-60@2x.png" + }, + { + "idiom": "iphone", + "size": "60x60", + "scale": "3x", + "filename": "icon-60@3x.png" + }, + { + "idiom": "ios-marketing", + "size": "1024x1024", + "scale": "1x", + "filename": "icon-1024.png" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} \ No newline at end of file diff --git a/BVGNearby/AppIcon.appiconset/icon-1024.png b/BVGNearby/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..801d39dec9b1350ab800d33f611eceaf9fc6bf92 GIT binary patch literal 16385 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 z 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 AHUIzs literal 0 HcmV?d00001 diff --git a/BVGNearby/AppIcon.appiconset/icon-20@2x.png b/BVGNearby/AppIcon.appiconset/icon-20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..eaa3f1857609ee46c68ac88bc90bc9e689276637 GIT binary patch literal 2174 zcmV-^2!Z#BP)$wQ#U+(0S~gaC4t`a6(Te z1R(EXd9%Iv^!S>dXw7LUEXs+}>c6i^(V=x8I0p#8f-vNN4Z63y)bz~3pe2`{za(1M z9xuuoA{;L|Zwz@a9}7pP+>4gSKfhq|?9QZIRtg~cFq=Ls#taA$`h4$>+)bNXdfVei zH@<&)cQFhYB#=r-1wb6>zrCBebL)b2547L4Vf@x}rlW*JK8$x19XPAugv_FT|M`~t zwl)3qq7!#~ehf-%qEZW)aR&e*z)|A(45nY~Y`NvR_HTZ6>bIASmlGmabNUR`bib~p5a`SnkXBN^c{?wK8_@4FS#j?_> zF|dNj{BEseef!MeVAG2&_g^t^_3Fvul%o}Lp$$VDcr}R-cpQc{7g|T5IOSa4JN59D z@87$%`H6SJtZ$8Nus%en^!A!Y+kq%q{$STx9nsTQzgH?co|a-tFZ73ODIf6qu5!g> zMR=({7>_h5S-&{Sd7K!^3dc5n~&o3kb01;@C8((a@>hZ2CpX%(N){Yh;*hJlX{>Vs4J-9FHdu+IY z#cGyDb6r8e^6NdhOI8-U7sg4dDw?1O8K1S!d5^3AkoQFU?E73%E=7{0s%t^Av3L46 zdva_=&4U0$iEwpiW?%Ea$LP&L_xhe`5vpdN6;wjz&`c+S$jtc3MkK;?eb4lPVfXNe z3N%~hI-vSIW`Uz*&j}Zx&ve9OXubkl!9=1e{&Th1d1*la89KW?hQjV4wX$F&tzX5F zT3}&-_{bPJo^)ia$c#-Vw*`j}j=Q}NFR3_!2qR?$mu(e;h`>w>LmT>XcuWB-tff{* z>$O@y5Sfa_Q}DD%BSNN@21ebvXOHL01Ou*;K+9Al5EiI9phl7@l2K`FQ8pAhj9OD* zY1xAG{tYK8^mLVc=Y@p_hCNlA+zKcfDp|_v6e57s0oA)2B48}d#x|zoMW>LlV&^6a zfKcA!i@S?6^wiR_y#OJqT|;RZD7kS&r)MKDLAcJo`aC9)l<5<#F(lnS5Q@Xq>0i-a z79_EXVpWcnjg=MDX)+6mBRd8I7_+)HGOTER9@h;JBr;gm%&VKne*bn8F;rD-W|RQX zG}oo#rs{M82=dU|&CBx>D_d=3K=Tx2o^nB~W#9L1SU<k?{5RLf@ZbtfQb6oLVY5 z1g+rA9tKv(_junx@cg074c*5xp=T`5#JJkEle?GIIv*n9IC^||Km#)H(M&G#mm2>j0{qYw@I9A0(X%ecvP{xJ?uuOp? zVbRCXo!?urb7K$3(Ay>~eH)%wAOO$Mn- z44JB=)dXJ)LOVLGf4F(kL%SDz>-5)eUOPhB4mL(r311Dw`0I=3k6MP-Py|wXdU)s$ z`!hc|c(x9Lo7Rn7+cUYU#lV?WD*=o;JmEZgApgsM6iUV9&Xv2rcIH%mVTZ6~hGjIo zgwXh|uDT%s@)=B*P%MK8q;)zm{$_vtyJOu?4lT_EdQE%O+Y+CawE%`n`mIrK&k29J zjLSQYePhYtPj$I|ORHEbf^3Lf22-U5&ZeXCP}GTLW+PWPLd=^GGVA4CrJ{>?5QLIfQVScbKOeS%#7A1*?6Hjtxd{N zO@IN=$o2wi8w)%K*-QgXBO+2H%pyY6xJIZ^Bto%-#55?K>u(?eC?H7@jW=GldWQ{v zQP(O3`}_i~LG4t@1pV>eFf?jX^*slCs^0qi2XVs*dC AjQ{`u literal 0 HcmV?d00001 diff --git a/BVGNearby/AppIcon.appiconset/icon-20@3x.png b/BVGNearby/AppIcon.appiconset/icon-20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..0d6ba14e46581b36bc4d29fbd30fbb595e7191a7 GIT binary patch literal 3397 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 sKo|Ha^drkmSpGl%TjYO1E;8%>4*}V^>3_ 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 ue1Le{eapjNzVzVF9dA1czwN%b0E38 literal 0 HcmV?d00001 diff --git a/BVGNearby/AppIcon.appiconset/icon-40@3x.png b/BVGNearby/AppIcon.appiconset/icon-40@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..cadb06c18c7d29982de593eef1304bc858073d3d GIT binary patch literal 7522 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 literal 0 HcmV?d00001 diff --git a/BVGNearby/AppIcon.appiconset/icon-60@2x.png b/BVGNearby/AppIcon.appiconset/icon-60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..cadb06c18c7d29982de593eef1304bc858073d3d GIT binary patch literal 7522 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 literal 0 HcmV?d00001 diff --git a/BVGNearby/AppIcon.appiconset/icon-60@3x.png b/BVGNearby/AppIcon.appiconset/icon-60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9ffdab4bbc5be4a1cfece42858b5975d607ecb4e GIT binary patch literal 11466 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 b6-l6HR*pyr`Q(kG4EQYZ1zII$81R1p8w?)l literal 0 HcmV?d00001 diff --git a/BVGNearby/Assets.xcassets/AppIcon.appiconset/Contents.json b/BVGNearby/Assets.xcassets/AppIcon.appiconset/Contents.json index 9221b9b..b7130bc 100644 --- a/BVGNearby/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/BVGNearby/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,98 +1,62 @@ { - "images" : [ + "images": [ { - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" + "idiom": "iphone", + "size": "20x20", + "scale": "2x", + "filename": "icon-20@2x.png" }, { - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" + "idiom": "iphone", + "size": "20x20", + "scale": "3x", + "filename": "icon-20@3x.png" }, { - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" + "idiom": "iphone", + "size": "29x29", + "scale": "2x", + "filename": "icon-29@2x.png" }, { - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" + "idiom": "iphone", + "size": "29x29", + "scale": "3x", + "filename": "icon-29@3x.png" }, { - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" + "idiom": "iphone", + "size": "40x40", + "scale": "2x", + "filename": "icon-40@2x.png" }, { - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" + "idiom": "iphone", + "size": "40x40", + "scale": "3x", + "filename": "icon-40@3x.png" }, { - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" + "idiom": "iphone", + "size": "60x60", + "scale": "2x", + "filename": "icon-60@2x.png" }, { - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" + "idiom": "iphone", + "size": "60x60", + "scale": "3x", + "filename": "icon-60@3x.png" }, { - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" + "idiom": "ios-marketing", + "size": "1024x1024", + "scale": "1x", + "filename": "icon-1024.png" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "version": 1, + "author": "xcode" } -} +} \ No newline at end of file diff --git a/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..801d39dec9b1350ab800d33f611eceaf9fc6bf92 GIT binary patch literal 16385 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 z z5dhvbD+?!gRfSgzUxHp}R2zHBSUXpWBL^AnddW0iitVRKN)=_E3^;nX{AiBi2rqTH z4O8{$yk8}TAqgS6UpXc3BCA?bn`-&#;RBAebzIj(OY+mX{~#b#DR;TMMHdgvzXRyIGXSV*)CUGKqdxfvQ1{Y-IyW)E+YWLr^l z02%Kp%-mP*Q+*Vm?D&A5q{C^J%Vjy(kA5CGBRn44Ls=8!i7f2{I(Rd6m%n5`GQ5Pk zWuFt)jNvWiL%dIt_FHX6Dam0S``bv?@Ay#N3NIq<`AQ@tlZ25OB&doEjedGl(^4wg zJW^>LyWuv7BzN9xkZm?2m7|83CW(_EeC+3J+RIDj3#*?ynoB7G172pok?lwl*3LO@ zY%<-^epL%pMQ2e}qHp7>Up+sL$--SYZ)AU=PW7Gij0l{M0WVc+MdRGjCzn_T_1R=L z(4@7s<~Ez?HRR}fO)tn!`xn%KFU&ziE*@la{9uCW3{NjAGr+XlG77NvQuXEIEbUr` zX4c2yyfIynvbx*kX_kI=bbT#hy`^+{MOMz%~4Lb49Z zmE2%F&MhhntJLs zZbkBrCzPalKMm+(;)H0Ty43Q?)RB(8(}V&EL|)rwjqO9!g=OFHx@rM=Y;wcvF#nu8|c^x_rPWbo0iyLmnfpH zpI}5EoAJwJMB3(@@7vTa|^>9NJOworF^kpp+2w}}*Jo*`uCy~FdHpO7+T zt)@@TzK7|AON0FbBZJI`Z}O7{3{u4*|6mEAw}>df`8=tw<8a8L z;KTuy(oMrOq;);7L~@UO0X+TDeU-CshU?UK z;u;Wf(*FA(EK8lSB15+YP2HwN(bqP4lSb2*a^%f%D$sP+zqM^)v4tl;KVsniF%!&Tn72ns?jWknS%nii7U5I467KZvEBLE{ zD1h!Wx)&8>?LEy!9oGh!Nn8QV?H(XxL$k~0Gqy`q7oxj(H4v^UDIouZNiG;DAXoK- zV#@XC9<=(w**C(~!pDkaNzS4K>qQ%-1EU`Dvx#X!pA_pC z;@74EQnM1Y$hVwuEzUShIK}lIDMnBR3cJPj5&2O`yEsKX7629b8)sRbR{i2dZtQ66 zVH+2XvKRjxpxoF;MC#5PxDY|4qt!tmTZf=jC>9TP5Rh32JN-V?trH9=;B=`@+$dXr zfPk5peKaEfmCc0q%NjCZkjLXExu`%Pu#9%)XiF1SDd3{tWaswN6Xnsl&}nELU8Dp$RyzEA#fw9z>HeA|Q!Gexp{{ z-zD~2-`9<~C_yj0vn4}?@BR5f7@TM$A?tl-Y>(lQru+v)_FZA`yB^8Qgf4|~nn>I8 zc*Lgg1d)HNILj#kJ$0!SNak8dh1kbo#;71*(vL;P#!NBXgZFz&h7%yF&mq(3gXRit z=jR>JuV~s@%Y|0#Uv3AvORH@sqCVW%18oWT2Y{I4b!9e0$8kJ3CxDuwO7X};)3g_Q zyiDpDIM%vg2c_U5p!U<#gd5Q!!mi{dR$A8!#Rkh$1V+UGRX6TQpz$D8@#ybhoFiZ~L5xiD*R<(=Sb7hSN=ft z^6=N!S$0+JuP$_TcX#_P6BfPl?P`7c(T>X<-j~}hHa@uA?*2*S@>jRogF8CB#x)7* zVRIH7P<`inHN3EB1MD(U8qwyHbzFaxn?AQLt&N?^Ce4PDOtbB4u5yX)w5yB}@o{K) zY?@_T0b>A-Coi|1%_zv3V`(h>EXGz0^qEK~+nP5RqPNww(8qhTCYO#jCkh+^}@?z)$^K1;$UMB_x-g>_V|SFj{H+YEiXD4!(%D&{dH?2;2NuoSo&#agH;WDGeIft*w>d^K7|2JI*!12ZX8 z&32^u<|=)4ezfK`;*Xdh6i3~^zq9L(QtU#vbh0RSQA#!rYt(4o-{IVzOk(uCcI|y6 z%8lwI5WD2G<~fkY$d@ENKrSqgL|x6y=R*FMXY<`_Sw+DFjiw5n&MURi?QS21>>isJ zs7#U}Rrpn~lSNbq=ZTEa5VDb!l+<1IsV1e$og3f=twh3|51uQD$$DjtrHIFUiQ&#lR#-~FaN&@%Q*nQNdX?IE z`RX@6Z_4?M{)OpG;{bCj_*%_k_yVm))+}f6UWS)V zz;&|fP_~h9S7}94Q&Z=MzXCPp=-2sF=tXRVuNNCn636dKFxTu$zyD% zTl0UuQyqR~6_&om^m%tm!d;J++Sr`)-`^dKSR*WK_R^_Mefq2G{Lsq|monL-=I%WN zP-oAPf|se3jEAzvv+4ANKy$FZcQ=<#H(3Q@23#4li=aMH-SXn>2#CRAp>uUvgj`{# zKfLi+uty^K?#JPR(?0zQ)ekh8AGcd5gsiLNiN;|vDdtmkY%=hkNFb^YW!JW>F_5|h z4yUnT_+o}v&t{;bs*mF0;L!hhbB5Z$*&=!ex5=6GCTcZeQm;oi8xXP-nd=hCrI(V_O8$6!r&uXEl|4r?xhr7~Ww zLOC6`fM8dB zmA*u<2K{j>zjZDp9IH?K%z0Vxq+KDcm_S;%wsh;x;a6P_UO9U)Rh7%7KWr>yPAX$q z5heqI*Ecr1IPaW16DpRR>9hUZ2**|TX0Nd^xM!8w?oiB=298P2woYWYf8Llp2!^}Cr~Z(( z$VKLCFX751Zu$c+PL;+y?|d+3;Zv>7&Q|g~>f@1BYV(6x+9S&8 zX&&G`&iAJ#-wkwTq}-+~e4;G8m)01ilQMcMoPEYy+oy80-fYqYBdImDdr<#P>yyU( z;CPap_ZbKj=lYlS2_D=7=#;fYT%~GUu-muW240=we(MwmljklI`$Hu4G{E&%Mk*zz z%BlUhZ-p^bmenom>|S#B28s;-%!ld-OqyI?a%vMfAd=h>q> zPhwN0{B33(a}Cd)Jv&9Z*kEe+LgD@q=1d@KCM%BN*hSdxU1ruk_SjDSY%Pls5|Q8i zV8qKVtHZ*$9*nw>(OOvl>ZG`xpr9aTSZBMx>|SV&V>E+mw|?BgmIR6Y>esTlz1w5M z*SVG}WVlzH^<~s4H=cl4)R?UhD`8t1gR#0UZ~18tD=RDMIIpqtNF~C~Z`44_LYMNj z2C*qHV+|4ZC-)<$lTQdfKd&nfYccAQH7<4q?8s5bd^7iri}cFA=Ant4!sX!; zB14AethvUF4tc{=E5&hk&< z6s6XIqHAl2odHAWHKwhtKC*T24?;GxCeSClKHabt$F(jKe5IB-VcmO+g%1{xYV9*y z18E{vBoE||X^5U&!L~`V%JU0dAD7DI{{)Yi*As3Ap7&{5nkZxkq~2XsASH9xZ&$gt zU%baf^!kaZ$Bx3;3KpY-#hA>XsKZtYrrYQ5TitbU8!$gQlB!YVGnF;J7D1-t3Z`Ol z<@-&{yS{L!pP88fNATLZGz8NXbuh{S?_SWXL0c{!Y-D9(OpTSLOVGy!*h3JAV=eR+ z&U}!2YMLcAc}&Fx_$^9V7=799F%V99jw#PCz3#&MUJ`4z=2LjI5=23aw~XnI!dZic3=2w` z?8~nZPkMn=J&U<7SWHpYe<6D;(-LxojxiFBAL@$CWuc*=5hpJ-W4X825|W3H@Y zzJ1#FoC86HPCKM^FFrtc#~aA8SRZ%1QK+c$VhmUmGNxLqAlR+FoK1AA-o4twYm}Rf z-M<@>DYMqhQ^i7dF{8}sLKZ}!W%Z1V6R*c+x&k}QrwdCz1_&FdPu3lJI+j3W&@EV# z7B3qa&MpvZV%&d37W<5c#!e)1c38(;zntGCQhS*ded1!hF_s#h^cF*gK+scHYwM;fp$X-8< ziFiyF1|DBR4+kdgOOErI`$k$Ak7MPx8*4E73<%6(ET_xt=!m{M_~v3C2~Du-l+n~opgA+2o0kso^K`+8_lPHv(F-A9{6|GJ zr1@6F`6iB~s|E{MOsG>l8nR-QowHxcksvmEvgSJ|?|7u|j7gdbZAW!i)Zc*>k_O+# z_jRnSw%XBt#VWv{zDXW;8bFwW4d8snYUXOD*6mt|I&ISM1k3+&jnhpqX#*P!b}+sV zHfP1nmfOAQH_lkwvyElJsJHMQ{mbIS8!vYywuAlO{fMGdGc#b6nMYGEbes=3+B&5f zkQbMpmd0@56kDrJrJk3Pmc|kc;6Nb-9`%CIRL6|Pil(=RB#(5_bpl?@DxYlt$vwE# za;7P8q6nBN>NE3wE%9sSDb23yI1gQfI&;B^VueTsX#1oFGlDW30qLAsmgfG4TIkAR zj-(SXT{qQrxBXPT&i?4SnLzw|3AFdwLSX~Uxz(1ACmxJVaA*KpTRWIj9)y#T>Yh_I z+Qhb*pxmPy07=|{;QkjhY+f#GjFV}+VpGijJh*Bp?x8GnLtj9p^5Se`!fgT_0$8 za@I?J@w*b3l<$Yy;PLd^7K3wK*obY(p){MB%reLt3%VucQb$wa#ZOP><&3_Cp&)kZ zv6!Emb*2lhbPrRYob!az>> zySpB>f08Sh_+tBs4V@F7u5PpskDIUVw1UExqxj4S14X`Mnn4+S<-=z4QU9a)ogamr z7r=m-Sk?#ZSU0MQB{&3cx+a#56$CneeKiUQfY1eAbVimm9bczOJKL4Yg?JMKcnuAw z3zHV>{cJjPeJe+L;HckBuMqJGK_Ye?>MCY;JhhQS#eK}J?q2*Brs?yx?|zTODOrV z0O`Y+0}R6Y^y$;!oLV4ho^G~_W8>TT4NLwpxA;kY_(DP;EPK@9Lia&V5O zffL*Mn&>-LvLVn5s#dS*ybg8cp2>~mQNSTyb0N!5SS^k*=gr{rvcm*+q-Db;wylqN z$==0fEW1{JzLvx=f-fk9*HXX1Fqva3xI&0DgH>KXlrU7N{~3H^z)q#-ueh_|4M++T zz!y(2ZjCOXs$=fic#f zswNzW_GyI&^hRgYjo8pch(Oh!nc;7QW8%<0KlHWPA3mNsAc(4kKdS^vK@ugk^cVEE zHY08EyIR_^7PXwWe5f^N;p;^VQLIZ2%YtF`RvSPK_iXGuU^N&^918Z}wLUl6p`Qo? z81grIENU1^FbX_K)n!LZ9-hzA#3NN(AacD-Gqf=+#Q?4>i8)!r$WmI2QQtCM5sU zY}ukF{cFzt0tR6%?T?czm;v(JK!)F2zyl0({s()K~@$lbfI{(LC zYX4s5*BmGL=&y;^-+=gkpHu$}UuysNXc}YTth9VLtY{EihKWLVpZ?ybdn~X0A4&es AHUIzs literal 0 HcmV?d00001 diff --git a/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..eaa3f1857609ee46c68ac88bc90bc9e689276637 GIT binary patch literal 2174 zcmV-^2!Z#BP)$wQ#U+(0S~gaC4t`a6(Te z1R(EXd9%Iv^!S>dXw7LUEXs+}>c6i^(V=x8I0p#8f-vNN4Z63y)bz~3pe2`{za(1M z9xuuoA{;L|Zwz@a9}7pP+>4gSKfhq|?9QZIRtg~cFq=Ls#taA$`h4$>+)bNXdfVei zH@<&)cQFhYB#=r-1wb6>zrCBebL)b2547L4Vf@x}rlW*JK8$x19XPAugv_FT|M`~t zwl)3qq7!#~ehf-%qEZW)aR&e*z)|A(45nY~Y`NvR_HTZ6>bIASmlGmabNUR`bib~p5a`SnkXBN^c{?wK8_@4FS#j?_> zF|dNj{BEseef!MeVAG2&_g^t^_3Fvul%o}Lp$$VDcr}R-cpQc{7g|T5IOSa4JN59D z@87$%`H6SJtZ$8Nus%en^!A!Y+kq%q{$STx9nsTQzgH?co|a-tFZ73ODIf6qu5!g> zMR=({7>_h5S-&{Sd7K!^3dc5n~&o3kb01;@C8((a@>hZ2CpX%(N){Yh;*hJlX{>Vs4J-9FHdu+IY z#cGyDb6r8e^6NdhOI8-U7sg4dDw?1O8K1S!d5^3AkoQFU?E73%E=7{0s%t^Av3L46 zdva_=&4U0$iEwpiW?%Ea$LP&L_xhe`5vpdN6;wjz&`c+S$jtc3MkK;?eb4lPVfXNe z3N%~hI-vSIW`Uz*&j}Zx&ve9OXubkl!9=1e{&Th1d1*la89KW?hQjV4wX$F&tzX5F zT3}&-_{bPJo^)ia$c#-Vw*`j}j=Q}NFR3_!2qR?$mu(e;h`>w>LmT>XcuWB-tff{* z>$O@y5Sfa_Q}DD%BSNN@21ebvXOHL01Ou*;K+9Al5EiI9phl7@l2K`FQ8pAhj9OD* zY1xAG{tYK8^mLVc=Y@p_hCNlA+zKcfDp|_v6e57s0oA)2B48}d#x|zoMW>LlV&^6a zfKcA!i@S?6^wiR_y#OJqT|;RZD7kS&r)MKDLAcJo`aC9)l<5<#F(lnS5Q@Xq>0i-a z79_EXVpWcnjg=MDX)+6mBRd8I7_+)HGOTER9@h;JBr;gm%&VKne*bn8F;rD-W|RQX zG}oo#rs{M82=dU|&CBx>D_d=3K=Tx2o^nB~W#9L1SU<k?{5RLf@ZbtfQb6oLVY5 z1g+rA9tKv(_junx@cg074c*5xp=T`5#JJkEle?GIIv*n9IC^||Km#)H(M&G#mm2>j0{qYw@I9A0(X%ecvP{xJ?uuOp? zVbRCXo!?urb7K$3(Ay>~eH)%wAOO$Mn- z44JB=)dXJ)LOVLGf4F(kL%SDz>-5)eUOPhB4mL(r311Dw`0I=3k6MP-Py|wXdU)s$ z`!hc|c(x9Lo7Rn7+cUYU#lV?WD*=o;JmEZgApgsM6iUV9&Xv2rcIH%mVTZ6~hGjIo zgwXh|uDT%s@)=B*P%MK8q;)zm{$_vtyJOu?4lT_EdQE%O+Y+CawE%`n`mIrK&k29J zjLSQYePhYtPj$I|ORHEbf^3Lf22-U5&ZeXCP}GTLW+PWPLd=^GGVA4CrJ{>?5QLIfQVScbKOeS%#7A1*?6Hjtxd{N zO@IN=$o2wi8w)%K*-QgXBO+2H%pyY6xJIZ^Bto%-#55?K>u(?eC?H7@jW=GldWQ{v zQP(O3`}_i~LG4t@1pV>eFf?jX^*slCs^0qi2XVs*dC AjQ{`u literal 0 HcmV?d00001 diff --git a/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..0d6ba14e46581b36bc4d29fbd30fbb595e7191a7 GIT binary patch literal 3397 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 sKo|Ha^drkmSpGl%TjYO1E;8%>4*}V^>3_ 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 ue1Le{eapjNzVzVF9dA1czwN%b0E38 literal 0 HcmV?d00001 diff --git a/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..cadb06c18c7d29982de593eef1304bc858073d3d GIT binary patch literal 7522 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 literal 0 HcmV?d00001 diff --git a/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..cadb06c18c7d29982de593eef1304bc858073d3d GIT binary patch literal 7522 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 literal 0 HcmV?d00001 diff --git a/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9ffdab4bbc5be4a1cfece42858b5975d607ecb4e GIT binary patch literal 11466 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 b6-l6HR*pyr`Q(kG4EQYZ1zII$81R1p8w?)l literal 0 HcmV?d00001 diff --git a/BVGNearby/BVGAPI.h b/BVGNearby/BVGAPI.h new file mode 100644 index 0000000..10df00f --- /dev/null +++ b/BVGNearby/BVGAPI.h @@ -0,0 +1,57 @@ +#import + +// NSNull -> nil, damit JSON-Werte sicher abgefragt werden koennen +static inline id NN(id v) { return (v == [NSNull null]) ? nil : v; } + +extern NSString * const BVGFavoritesChangedNotification; +extern NSString * const BVGAlarmsChangedNotification; + +@interface BVGAPI : NSObject + +// Netzwerk ++ (void)getJSON:(NSString *)path completion:(void (^)(id json, NSError *error))completion; ++ (NSString *)encode:(NSString *)s; ++ (NSString *)lastSource; // "serverlele" oder "transport.rest" - wer zuletzt geantwortet hat + +// Zeit ++ (NSDate *)dateFromISO:(id)value; ++ (NSString *)isoFromDate:(NSDate *)date; ++ (NSString *)hhmm:(NSDate *)date; ++ (NSDate *)whenOfDeparture:(NSDictionary *)d; + +// Darstellung ++ (UIColor *)bvgYellow; ++ (UIColor *)bvgBlue; ++ (UIColor *)colorForLine:(NSDictionary *)line; ++ (UIImage *)badgeForLine:(NSDictionary *)line; ++ (UIImage *)badgeStripForLines:(NSArray *)lines; ++ (UIImage *)dotWithColor:(UIColor *)color filled:(BOOL)filled; ++ (UIImage *)tabIconNamed:(NSString *)name; ++ (NSString *)spokenLineName:(NSDictionary *)line; + +// Favoriten & zuletzt angesehen ++ (NSArray *)favorites; ++ (BOOL)isFavorite:(NSString *)stopId; ++ (void)setFavorite:(BOOL)fav stopId:(NSString *)stopId name:(NSString *)name; ++ (NSArray *)recentStops; ++ (void)addRecentStop:(NSString *)stopId name:(NSString *)name; ++ (NSString *)nameForStop:(NSString *)stopId; + +// Spotlight (iOS 9 Suche / Siri-Vorschlaege) ++ (void)indexStopInSpotlight:(NSString *)stopId name:(NSString *)name; ++ (NSString *)stopIdFromSpotlightIdentifier:(NSString *)identifier; + +// Sprachausgabe ++ (void)speak:(NSString *)text; ++ (void)stopSpeaking; ++ (BOOL)isSpeaking; ++ (NSString *)announcementForStop:(NSString *)stopName departures:(NSArray *)deps max:(NSInteger)max; + +// Wecker ++ (NSArray *)userAlarms; // nur vom Nutzer gesetzte Wecker (ohne Navigations-Hinweise) ++ (BOOL)hasAlarmForTrip:(NSString *)tripId; ++ (void)cancelAlarmForTrip:(NSString *)tripId; ++ (void)presentAlarmSheetForDeparture:(NSDictionary *)d stopName:(NSString *)stopName from:(UIViewController *)vc; ++ (NSArray *)editActionsForDeparture:(NSDictionary *)d stopName:(NSString *)stopName from:(UITableViewController *)vc; + +@end diff --git a/BVGNearby/BVGAPI.m b/BVGNearby/BVGAPI.m new file mode 100644 index 0000000..8789a0f --- /dev/null +++ b/BVGNearby/BVGAPI.m @@ -0,0 +1,554 @@ +#import "BVGAPI.h" +#import "BVGSettings.h" +#import +#import +#import + +static NSString * const kOwnBase = @"https://api.serverlele.de/live_navi/v6"; // eigenes Backend (schlank, gecacht) +static NSString * const kPublicBase = @"https://v6.bvg.transport.rest"; // oeffentlicher Rueckfall +static NSString *gLastSource = nil; +static NSString * const kFavoritesKey = @"favorites"; +static NSString * const kRecentsKey = @"recentStops"; +static NSString * const kSpotlightPrefix = @"stop:"; + +NSString * const BVGFavoritesChangedNotification = @"BVGFavoritesChanged"; +NSString * const BVGAlarmsChangedNotification = @"BVGAlarmsChanged"; + +@implementation BVGAPI + +#pragma mark - Netzwerk + ++ (NSURLSession *)session { + static NSURLSession *s; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSURLSessionConfiguration *c = [NSURLSessionConfiguration defaultSessionConfiguration]; + c.timeoutIntervalForRequest = 15; + c.HTTPMaximumConnectionsPerHost = 4; // 4S: nicht zu viele parallele Verbindungen + c.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + c.HTTPAdditionalHeaders = @{ @"User-Agent": @"BVGNearby/1.0 (iOS 9; private app)" }; + s = [NSURLSession sessionWithConfiguration:c]; + }); + return s; +} + ++ (void)networkActivity:(NSInteger)delta { + static NSInteger running = 0; + dispatch_async(dispatch_get_main_queue(), ^{ + running = MAX(0, running + delta); + [UIApplication sharedApplication].networkActivityIndicatorVisible = running > 0; + }); +} + ++ (void)getJSON:(NSString *)path completion:(void (^)(id json, NSError *error))completion { + [self getJSON:path attempt:0 completion:completion]; +} + ++ (NSArray *)bases { + return [BVGSettings useOwnServer] ? @[kOwnBase, kPublicBase] : @[kPublicBase]; +} + ++ (NSString *)lastSource { + return gLastSource; +} + +// attempt 0 = erster Server; bei Fehler sofort der naechste Server, bei nur einem Server +// ein zweiter Versuch nach 1,5 s. Max. zwei Versuche. ++ (void)getJSON:(NSString *)path attempt:(NSInteger)attempt completion:(void (^)(id json, NSError *error))completion { + NSArray *bases = [self bases]; + NSString *base = bases[MIN((NSUInteger)attempt, bases.count - 1)]; + NSURL *url = [NSURL URLWithString:[base stringByAppendingString:path]]; + if (!url) { completion(nil, nil); return; } + + [self networkActivity:+1]; + [[[self session] dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *resp, NSError *error) { + [self networkActivity:-1]; + NSInteger status = [resp isKindOfClass:[NSHTTPURLResponse class]] ? ((NSHTTPURLResponse *)resp).statusCode : 0; + BOOL own = [base isEqualToString:kOwnBase]; + + // Rueckfall: Netzwerkfehler, Server ueberlastet - beim eigenen Server auch 404 (Route evtl. noch nicht deployt) + BOOL failed = error || status >= 500 || status == 429 || (own && status == 404); + if (failed && attempt == 0) { + if (bases.count > 1) { + NSLog(@"%@ -> Rueckfall auf transport.rest (%@)", base, error ?: @(status)); + [self getJSON:path attempt:1 completion:completion]; + } else { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self getJSON:path attempt:1 completion:completion]; + }); + } + return; + } + if (!failed) gLastSource = own ? @"serverlele" : @"transport.rest"; + + id json = nil; + if (data && !error) json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + completion(json, error); + }] resume]; +} + +#pragma mark - Zeit + ++ (NSDateFormatter *)isoFormatter { + static NSDateFormatter *f; + static dispatch_once_t once; + dispatch_once(&once, ^{ + f = [[NSDateFormatter alloc] init]; + f.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + f.dateFormat = @"yyyy-MM-dd'T'HH:mm:ssZZZZZ"; // NSISO8601DateFormatter erst ab iOS 10 + }); + return f; +} + ++ (NSDate *)dateFromISO:(id)value { + NSString *s = NN(value); + if (![s isKindOfClass:[NSString class]]) return nil; + @synchronized (self) { return [[self isoFormatter] dateFromString:s]; } +} + ++ (NSString *)isoFromDate:(NSDate *)date { + @synchronized (self) { return [[self isoFormatter] stringFromDate:date]; } +} + ++ (NSString *)hhmm:(NSDate *)date { + static NSDateFormatter *f; + static dispatch_once_t once; + dispatch_once(&once, ^{ + f = [[NSDateFormatter alloc] init]; + f.dateFormat = @"HH:mm"; + }); + return date ? [f stringFromDate:date] : @"–"; +} + ++ (NSDate *)whenOfDeparture:(NSDictionary *)d { + return [self dateFromISO:d[@"when"]] ?: [self dateFromISO:d[@"plannedWhen"]]; +} + +#pragma mark - Farben & Bilder + ++ (UIColor *)bvgYellow { + return [UIColor colorWithRed:247 / 255.0 green:209 / 255.0 blue:23 / 255.0 alpha:1]; +} + ++ (UIColor *)bvgBlue { + return [UIColor colorWithRed:0.07 green:0.36 blue:0.66 alpha:1]; +} + ++ (UIColor *)colorFromHex:(NSString *)hex { + if (![hex isKindOfClass:[NSString class]]) return nil; + NSString *h = [hex stringByReplacingOccurrencesOfString:@"#" withString:@""]; + if (h.length != 6) return nil; + unsigned int rgb = 0; + [[NSScanner scannerWithString:h] scanHexInt:&rgb]; + return [UIColor colorWithRed:((rgb >> 16) & 0xFF) / 255.0 + green:((rgb >> 8) & 0xFF) / 255.0 + blue:(rgb & 0xFF) / 255.0 alpha:1]; +} + ++ (UIColor *)colorForProduct:(NSString *)product { + if ([product isEqualToString:@"suburban"]) return [UIColor colorWithRed:0.00 green:0.55 blue:0.24 alpha:1]; + if ([product isEqualToString:@"subway"]) return [UIColor colorWithRed:0.07 green:0.36 blue:0.66 alpha:1]; + if ([product isEqualToString:@"tram"]) return [UIColor colorWithRed:0.80 green:0.04 blue:0.13 alpha:1]; + if ([product isEqualToString:@"bus"]) return [UIColor colorWithRed:0.58 green:0.15 blue:0.43 alpha:1]; + if ([product isEqualToString:@"ferry"]) return [UIColor colorWithRed:0.00 green:0.50 blue:0.73 alpha:1]; + if ([product isEqualToString:@"regional"] || [product isEqualToString:@"express"]) + return [UIColor colorWithRed:0.89 green:0.00 blue:0.10 alpha:1]; + return [UIColor darkGrayColor]; +} + ++ (UIColor *)colorForLine:(NSDictionary *)line { + NSDictionary *c = NN(line[@"color"]); + UIColor *bg = [c isKindOfClass:[NSDictionary class]] ? [self colorFromHex:NN(c[@"bg"])] : nil; + return bg ?: [self colorForProduct:NN(line[@"product"]) ?: @""]; +} + ++ (UIImage *)badgeForLine:(NSDictionary *)line { + static NSCache *cache; + static dispatch_once_t once; + dispatch_once(&once, ^{ cache = [[NSCache alloc] init]; }); + + NSString *name = NN(line[@"name"]) ?: @"?"; + NSString *product = NN(line[@"product"]) ?: @""; + NSDictionary *c = NN(line[@"color"]); + UIColor *fg = [c isKindOfClass:[NSDictionary class]] ? [self colorFromHex:NN(c[@"fg"])] : nil; + + NSString *key = [NSString stringWithFormat:@"%@|%@|%@", name, product, c]; + UIImage *cached = [cache objectForKey:key]; + if (cached) return cached; + + NSDictionary *attrs = @{ NSFontAttributeName: [UIFont systemFontOfSize:14 weight:UIFontWeightBold], + NSForegroundColorAttributeName: fg ?: [UIColor whiteColor] }; + CGSize textSize = [name sizeWithAttributes:attrs]; + CGSize size = CGSizeMake(MAX(42, ceil(textSize.width) + 16), 24); + + UIGraphicsBeginImageContextWithOptions(size, NO, 0); + CGFloat radius = [product isEqualToString:@"suburban"] ? size.height / 2 : 7; + [[self colorForLine:line] setFill]; + [[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, size.width, size.height) cornerRadius:radius] fill]; + [name drawAtPoint:CGPointMake((size.width - textSize.width) / 2, (size.height - textSize.height) / 2) + withAttributes:attrs]; + UIImage *img = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + [cache setObject:img forKey:key]; + return img; +} + ++ (UIImage *)badgeStripForLines:(NSArray *)lines { + NSMutableArray *imgs = [NSMutableArray array]; + CGFloat width = 0; + for (NSDictionary *l in lines) { + UIImage *b = [self badgeForLine:l]; + if (width + b.size.width > 210) break; + [imgs addObject:b]; + width += b.size.width + 4; + } + if (!imgs.count) return nil; + + UIGraphicsBeginImageContextWithOptions(CGSizeMake(width - 4, 24), NO, 0); + CGFloat x = 0; + for (UIImage *b in imgs) { + [b drawAtPoint:CGPointMake(x, 0)]; + x += b.size.width + 4; + } + UIImage *img = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return img; +} + ++ (NSString *)spokenLineName:(NSDictionary *)line { + NSString *name = NN(line[@"name"]) ?: @""; + NSString *product = NN(line[@"product"]) ?: @""; + if ([product isEqualToString:@"bus"]) return [@"Bus " stringByAppendingString:name]; + if ([product isEqualToString:@"tram"]) return [@"Tram " stringByAppendingString:name]; + if ([product isEqualToString:@"ferry"]) return [@"Fähre " stringByAppendingString:name]; + return name; +} + ++ (UIImage *)dotWithColor:(UIColor *)color filled:(BOOL)filled { + CGSize size = CGSizeMake(14, 14); + UIGraphicsBeginImageContextWithOptions(size, NO, 0); + UIBezierPath *p = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(2, 2, 10, 10)]; + p.lineWidth = 2.5; + if (filled) { [color setFill]; [p fill]; } + else { [[UIColor whiteColor] setFill]; [p fill]; [color setStroke]; [p stroke]; } + UIImage *img = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return img; +} + ++ (UIImage *)tabIconNamed:(NSString *)name { + CGSize size = CGSizeMake(25, 25); + UIGraphicsBeginImageContextWithOptions(size, NO, 0); + [[UIColor blackColor] set]; + + if ([name isEqualToString:@"nearby"]) { + UIBezierPath *ring = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(3, 3, 19, 19)]; + ring.lineWidth = 2; + [ring stroke]; + [[UIBezierPath bezierPathWithOvalInRect:CGRectMake(9, 9, 7, 7)] fill]; + } else if ([name isEqualToString:@"search"]) { + UIBezierPath *glass = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(3, 3, 14, 14)]; + glass.lineWidth = 2.2; + [glass stroke]; + UIBezierPath *handle = [UIBezierPath bezierPath]; + [handle moveToPoint:CGPointMake(15, 15)]; + [handle addLineToPoint:CGPointMake(22, 22)]; + handle.lineWidth = 3; + handle.lineCapStyle = kCGLineCapRound; + [handle stroke]; + } else if ([name isEqualToString:@"map"]) { + UIBezierPath *fold = [UIBezierPath bezierPath]; + [fold moveToPoint:CGPointMake(2, 6)]; + [fold addLineToPoint:CGPointMake(9, 3)]; + [fold addLineToPoint:CGPointMake(16, 6)]; + [fold addLineToPoint:CGPointMake(23, 3)]; + [fold addLineToPoint:CGPointMake(23, 19)]; + [fold addLineToPoint:CGPointMake(16, 22)]; + [fold addLineToPoint:CGPointMake(9, 19)]; + [fold addLineToPoint:CGPointMake(2, 22)]; + [fold closePath]; + [fold moveToPoint:CGPointMake(9, 3)]; + [fold addLineToPoint:CGPointMake(9, 19)]; + [fold moveToPoint:CGPointMake(16, 6)]; + [fold addLineToPoint:CGPointMake(16, 22)]; + fold.lineWidth = 1.8; + fold.lineJoinStyle = kCGLineJoinRound; + [fold stroke]; + } else if ([name isEqualToString:@"route"]) { + UIBezierPath *a = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(2, 16, 7, 7)]; + a.lineWidth = 2; + [a stroke]; + [[UIBezierPath bezierPathWithOvalInRect:CGRectMake(16, 2, 7, 7)] fill]; + UIBezierPath *path = [UIBezierPath bezierPath]; + [path moveToPoint:CGPointMake(8, 17)]; + [path addCurveToPoint:CGPointMake(17, 8) controlPoint1:CGPointMake(18, 17) controlPoint2:CGPointMake(7, 8)]; + path.lineWidth = 2; + path.lineCapStyle = kCGLineCapRound; + CGFloat dash[] = {3, 3}; + [path setLineDash:dash count:2 phase:0]; + [path stroke]; + } else if ([name isEqualToString:@"alarm"]) { + UIBezierPath *face = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(3, 4, 19, 19)]; + face.lineWidth = 2; + [face stroke]; + UIBezierPath *hands = [UIBezierPath bezierPath]; + [hands moveToPoint:CGPointMake(12.5, 7.5)]; + [hands addLineToPoint:CGPointMake(12.5, 13.5)]; + [hands addLineToPoint:CGPointMake(17, 16)]; + hands.lineWidth = 2; + hands.lineCapStyle = kCGLineCapRound; + hands.lineJoinStyle = kCGLineJoinRound; + [hands stroke]; + } + + UIImage *img = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return [img imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; +} + +#pragma mark - Favoriten + ++ (NSArray *)favorites { + return [[NSUserDefaults standardUserDefaults] arrayForKey:kFavoritesKey] ?: @[]; +} + ++ (BOOL)isFavorite:(NSString *)stopId { + for (NSDictionary *f in [self favorites]) if ([f[@"id"] isEqualToString:stopId]) return YES; + return NO; +} + ++ (void)setFavorite:(BOOL)fav stopId:(NSString *)stopId name:(NSString *)name { + if (!stopId) return; + NSMutableArray *list = [NSMutableArray array]; + for (NSDictionary *f in [self favorites]) if (![f[@"id"] isEqualToString:stopId]) [list addObject:f]; + if (fav) [list addObject:@{ @"id": stopId, @"name": name ?: @"?" }]; + [[NSUserDefaults standardUserDefaults] setObject:list forKey:kFavoritesKey]; + [[NSUserDefaults standardUserDefaults] synchronize]; + if (fav) [self indexStopInSpotlight:stopId name:name]; + [[NSNotificationCenter defaultCenter] postNotificationName:BVGFavoritesChangedNotification object:nil]; +} + +#pragma mark - Zuletzt angesehen + ++ (NSArray *)recentStops { + return [[NSUserDefaults standardUserDefaults] arrayForKey:kRecentsKey] ?: @[]; +} + ++ (void)addRecentStop:(NSString *)stopId name:(NSString *)name { + if (!stopId || !name) return; + NSMutableArray *list = [NSMutableArray arrayWithObject:@{ @"id": stopId, @"name": name }]; + for (NSDictionary *r in [self recentStops]) { + if (![r[@"id"] isEqualToString:stopId] && list.count < 8) [list addObject:r]; + } + [[NSUserDefaults standardUserDefaults] setObject:list forKey:kRecentsKey]; + [self indexStopInSpotlight:stopId name:name]; +} + ++ (NSString *)nameForStop:(NSString *)stopId { + for (NSDictionary *f in [self favorites]) if ([f[@"id"] isEqualToString:stopId]) return f[@"name"]; + for (NSDictionary *r in [self recentStops]) if ([r[@"id"] isEqualToString:stopId]) return r[@"name"]; + return nil; +} + +#pragma mark - Spotlight + ++ (void)indexStopInSpotlight:(NSString *)stopId name:(NSString *)name { + if (!stopId || !name || ![CSSearchableIndex isIndexingAvailable]) return; + + CSSearchableItemAttributeSet *attrs = [[CSSearchableItemAttributeSet alloc] initWithItemContentType:(__bridge NSString *)kUTTypeText]; + attrs.title = name; + attrs.contentDescription = @"Haltestelle · Live-Abfahrten anzeigen"; + attrs.keywords = @[name, @"Abfahrt", @"Abfahrten", @"BVG", @"Haltestelle", @"Bahn", @"Bus"]; + + CSSearchableItem *item = [[CSSearchableItem alloc] + initWithUniqueIdentifier:[kSpotlightPrefix stringByAppendingString:stopId] + domainIdentifier:@"stops" + attributeSet:attrs]; + [[CSSearchableIndex defaultSearchableIndex] indexSearchableItems:@[item] completionHandler:^(NSError *error) { + if (error) NSLog(@"Spotlight-Fehler: %@", error); + }]; +} + ++ (NSString *)stopIdFromSpotlightIdentifier:(NSString *)identifier { + if (![identifier hasPrefix:kSpotlightPrefix]) return nil; + return [identifier substringFromIndex:kSpotlightPrefix.length]; +} + +#pragma mark - Sprachausgabe + ++ (AVSpeechSynthesizer *)synth { + static AVSpeechSynthesizer *s; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [[AVSpeechSynthesizer alloc] init]; }); + return s; +} + ++ (void)speak:(NSString *)text { + if (!text.length || ![BVGSettings speechEnabled]) return; + // Ambient: respektiert den Stummschalter -> bei "stumm" bleibt die Ansage still + [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryAmbient error:nil]; + [[AVAudioSession sharedInstance] setActive:YES error:nil]; + + [[self synth] stopSpeakingAtBoundary:AVSpeechBoundaryImmediate]; + AVSpeechUtterance *u = [AVSpeechUtterance speechUtteranceWithString:text]; + u.voice = [AVSpeechSynthesisVoice voiceWithLanguage:@"de-DE"]; + u.rate = AVSpeechUtteranceDefaultSpeechRate; + u.postUtteranceDelay = 0.2; + [[self synth] speakUtterance:u]; +} + ++ (void)stopSpeaking { + [[self synth] stopSpeakingAtBoundary:AVSpeechBoundaryWord]; +} + ++ (BOOL)isSpeaking { + return [self synth].isSpeaking; +} + ++ (NSString *)speakable:(NSString *)s { + if (!s) return @""; + NSDictionary *map = @{ @"S+U ": @"", @"(Berlin)": @"", @"Bhf": @"Bahnhof", @"Str.": @"Straße", + @"str.": @"straße", @"Pl.": @"Platz", @"/": @" ", @" ": @" " }; + for (NSString *k in map) s = [s stringByReplacingOccurrencesOfString:k withString:map[k]]; + return [s stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; +} + ++ (NSString *)announcementForStop:(NSString *)stopName departures:(NSArray *)deps max:(NSInteger)max { + NSMutableArray *parts = [NSMutableArray arrayWithObject: + [NSString stringWithFormat:@"Nächste Abfahrten an %@", [self speakable:stopName]]]; + NSInteger count = 0; + + for (NSDictionary *d in deps) { + if (count >= max) break; + NSString *line = [self spokenLineName:NN(d[@"line"])]; + NSString *dir = [self speakable:NN(d[@"direction"])]; + NSMutableString *s = [NSMutableString stringWithFormat:@"%@ Richtung %@, ", line, dir]; + + if ([NN(d[@"cancelled"]) boolValue]) { + [s appendString:@"fällt aus"]; + } else { + NSDate *when = [self whenOfDeparture:d]; + NSInteger mins = when ? (NSInteger)floor([when timeIntervalSinceNow] / 60.0) : -1; + if (mins <= 0) [s appendString:@"jetzt"]; + else if (mins == 1) [s appendString:@"in einer Minute"]; + else if (mins < 60) [s appendFormat:@"in %ld Minuten", (long)mins]; + else [s appendFormat:@"um %@ Uhr", [self hhmm:when]]; + + NSInteger delay = [NN(d[@"delay"]) integerValue] / 60; + if (delay == 1) [s appendString:@", eine Minute verspätet"]; + else if (delay > 1) [s appendFormat:@", %ld Minuten verspätet", (long)delay]; + } + [parts addObject:s]; + count++; + } + if (count == 0) [parts addObject:@"Aktuell keine Abfahrten"]; + return [[parts componentsJoinedByString:@". "] stringByAppendingString:@"."]; +} + +#pragma mark - Wecker + ++ (NSArray *)userAlarms { + NSMutableArray *list = [NSMutableArray array]; + for (UILocalNotification *n in [[UIApplication sharedApplication] scheduledLocalNotifications]) { + if (n.userInfo[@"kind"] == nil) [list addObject:n]; // Navigations-Hinweise ausblenden + } + return list; +} + +// scheduledLocalNotifications ist teuer -> Trip-IDs zwischenspeichern (wichtig beim Scrollen auf dem 4S) +static NSSet *gAlarmTripIds; + ++ (void)invalidateAlarmCache { + gAlarmTripIds = nil; +} + ++ (BOOL)hasAlarmForTrip:(NSString *)tripId { + if (!tripId) return NO; + if (!gAlarmTripIds) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + [nc addObserver:self selector:@selector(invalidateAlarmCache) name:BVGAlarmsChangedNotification object:nil]; + [nc addObserver:self selector:@selector(invalidateAlarmCache) name:UIApplicationDidBecomeActiveNotification object:nil]; + }); + NSMutableSet *ids = [NSMutableSet set]; + for (UILocalNotification *n in [self userAlarms]) { + if (n.userInfo[@"tripId"]) [ids addObject:n.userInfo[@"tripId"]]; + } + gAlarmTripIds = ids; + } + return [gAlarmTripIds containsObject:tripId]; +} + ++ (void)cancelAlarmForTrip:(NSString *)tripId { + for (UILocalNotification *n in [[UIApplication sharedApplication] scheduledLocalNotifications]) { + if ([n.userInfo[@"tripId"] isEqualToString:tripId]) { + [[UIApplication sharedApplication] cancelLocalNotification:n]; + } + } + [[NSNotificationCenter defaultCenter] postNotificationName:BVGAlarmsChangedNotification object:nil]; +} + ++ (void)presentAlarmSheetForDeparture:(NSDictionary *)d stopName:(NSString *)stopName from:(UIViewController *)vc { + NSDate *when = [self whenOfDeparture:d]; + NSString *tripId = NN(d[@"tripId"]); + if (!when || !tripId) return; + + NSDictionary *lineDict = NN(d[@"line"]); + NSString *line = NN(lineDict[@"name"]) ?: @"?"; + NSString *dir = NN(d[@"direction"]) ?: @""; + NSInteger minutesLeft = (NSInteger)floor([when timeIntervalSinceNow] / 60.0); + + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:@"%@ → %@", line, dir] + message:@"Wann soll ich dich erinnern?" + preferredStyle:UIAlertControllerStyleActionSheet]; + BOOL any = NO; + for (NSNumber *offset in @[@2, @5, @10, @15, @20]) { + if (offset.integerValue >= minutesLeft) continue; + any = YES; + [sheet addAction:[UIAlertAction actionWithTitle:[NSString stringWithFormat:@"%@ min vor Abfahrt", offset] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *a) { + UILocalNotification *n = [[UILocalNotification alloc] init]; + n.fireDate = [when dateByAddingTimeInterval:-offset.integerValue * 60]; + n.alertBody = [NSString stringWithFormat:@"%@ → %@ fährt in %@ min ab %@", line, dir, offset, stopName ?: @""]; + n.soundName = UILocalNotificationDefaultSoundName; + n.userInfo = @{ @"tripId": tripId, @"line": line, @"direction": dir, + @"stop": stopName ?: @"", @"departure": when }; + [[UIApplication sharedApplication] scheduleLocalNotification:n]; + [[NSNotificationCenter defaultCenter] postNotificationName:BVGAlarmsChangedNotification object:nil]; + }]]; + } + if (!any) sheet.message = @"Die Abfahrt ist zu knapp für einen Wecker."; + [sheet addAction:[UIAlertAction actionWithTitle:@"Abbrechen" style:UIAlertActionStyleCancel handler:nil]]; + sheet.popoverPresentationController.sourceView = vc.view; + [vc presentViewController:sheet animated:YES completion:nil]; +} + ++ (NSArray *)editActionsForDeparture:(NSDictionary *)d stopName:(NSString *)stopName from:(UITableViewController *)vc { + NSString *tripId = NN(d[@"tripId"]); + if (!tripId) return @[]; + __weak UITableViewController *weakVC = vc; + + if ([self hasAlarmForTrip:tripId]) { + UITableViewRowAction *off = [UITableViewRowAction rowActionWithStyle:UITableViewRowActionStyleDestructive + title:@"Wecker aus" + handler:^(UITableViewRowAction *a, NSIndexPath *ip) { + [weakVC.tableView setEditing:NO animated:YES]; + [BVGAPI cancelAlarmForTrip:tripId]; + }]; + return @[off]; + } + + UITableViewRowAction *on = [UITableViewRowAction rowActionWithStyle:UITableViewRowActionStyleNormal + title:@"Wecker" + handler:^(UITableViewRowAction *a, NSIndexPath *ip) { + [weakVC.tableView setEditing:NO animated:YES]; + if (weakVC) [BVGAPI presentAlarmSheetForDeparture:d stopName:stopName from:weakVC]; + }]; + on.backgroundColor = [UIColor orangeColor]; + return @[on]; +} + +@end diff --git a/BVGNearby/BVGAnnotation.h b/BVGNearby/BVGAnnotation.h new file mode 100644 index 0000000..b0e3b17 --- /dev/null +++ b/BVGNearby/BVGAnnotation.h @@ -0,0 +1,16 @@ +#import + +typedef NS_ENUM(NSInteger, BVGAnnotationKind) { + BVGAnnotationKindStop, + BVGAnnotationKindVehicle, + BVGAnnotationKindTripStop +}; + +@interface BVGAnnotation : NSObject +@property (nonatomic, assign) CLLocationCoordinate2D coordinate; // KVO-faehig -> animierbar +@property (nonatomic, copy) NSString *title; +@property (nonatomic, copy) NSString *subtitle; +@property (nonatomic, assign) BVGAnnotationKind kind; +@property (nonatomic, copy) NSString *identifier; // stopId oder tripId +@property (nonatomic, strong) NSDictionary *line; +@end diff --git a/BVGNearby/BVGAnnotation.m b/BVGNearby/BVGAnnotation.m new file mode 100644 index 0000000..161da4c --- /dev/null +++ b/BVGNearby/BVGAnnotation.m @@ -0,0 +1,7 @@ +#import "BVGAnnotation.h" + +@implementation BVGAnnotation +@synthesize coordinate = _coordinate; +@synthesize title = _title; +@synthesize subtitle = _subtitle; +@end diff --git a/BVGNearby/BVGSettings.h b/BVGNearby/BVGSettings.h new file mode 100644 index 0000000..580869b --- /dev/null +++ b/BVGNearby/BVGSettings.h @@ -0,0 +1,23 @@ +#import + +extern NSString * const BVGSettingsChangedNotification; + +@interface BVGSettings : NSObject ++ (NSInteger)radius; // Suchradius in m (Standard 1000) ++ (NSInteger)departuresPerStop; // Abfahrten pro Haltestelle (Standard 5) ++ (NSInteger)transferMinutes; // Mindest-Umstiegszeit (Standard 5, nie weniger) ++ (NSInteger)boardLeadMinutes; // Einsteige-Erinnerung vorher (Standard 2) ++ (BOOL)speechEnabled; // Sprachansagen (Standard an) ++ (BOOL)useOwnServer; // api.serverlele.de zuerst, transport.rest als Rueckfall (Standard an) + +// Gespeicherte Orte ("home" / "work"): {name, id} fuer Haltestellen oder {name, latitude, longitude} fuer Adressen ++ (NSDictionary *)placeForKey:(NSString *)key; ++ (void)setPlace:(NSDictionary *)place forKey:(NSString *)key; + +// Richtungsfilter pro Haltestelle (nil = alle Richtungen) ++ (NSString *)directionFilterForStop:(NSString *)stopId; ++ (void)setDirectionFilter:(NSString *)direction forStop:(NSString *)stopId; + ++ (void)setInteger:(NSInteger)value forKey:(NSString *)key; ++ (void)setBool:(BOOL)value forKey:(NSString *)key; +@end diff --git a/BVGNearby/BVGSettings.m b/BVGNearby/BVGSettings.m new file mode 100644 index 0000000..2eb27ce --- /dev/null +++ b/BVGNearby/BVGSettings.m @@ -0,0 +1,61 @@ +#import "BVGSettings.h" + +NSString * const BVGSettingsChangedNotification = @"BVGSettingsChanged"; + +@implementation BVGSettings + ++ (void)initialize { + if (self != [BVGSettings class]) return; + [[NSUserDefaults standardUserDefaults] registerDefaults:@{ + @"set.radius": @1000, + @"set.perStop": @5, + @"set.transfer": @5, + @"set.boardLead": @2, + @"set.speech": @YES, + @"set.ownServer": @YES, + }]; +} + ++ (NSInteger)radius { return [[NSUserDefaults standardUserDefaults] integerForKey:@"set.radius"]; } ++ (NSInteger)departuresPerStop { return [[NSUserDefaults standardUserDefaults] integerForKey:@"set.perStop"]; } ++ (NSInteger)transferMinutes { return MAX(5, [[NSUserDefaults standardUserDefaults] integerForKey:@"set.transfer"]); } ++ (NSInteger)boardLeadMinutes { return [[NSUserDefaults standardUserDefaults] integerForKey:@"set.boardLead"]; } ++ (BOOL)speechEnabled { return [[NSUserDefaults standardUserDefaults] boolForKey:@"set.speech"]; } ++ (BOOL)useOwnServer { return [[NSUserDefaults standardUserDefaults] boolForKey:@"set.ownServer"]; } + ++ (NSDictionary *)placeForKey:(NSString *)key { + NSDictionary *p = [[NSUserDefaults standardUserDefaults] dictionaryForKey:[@"place." stringByAppendingString:key]]; + return p[@"name"] ? p : nil; +} + ++ (void)setPlace:(NSDictionary *)place forKey:(NSString *)key { + NSString *k = [@"place." stringByAppendingString:key]; + if (place) [[NSUserDefaults standardUserDefaults] setObject:place forKey:k]; + else [[NSUserDefaults standardUserDefaults] removeObjectForKey:k]; + [[NSNotificationCenter defaultCenter] postNotificationName:BVGSettingsChangedNotification object:nil]; +} + ++ (NSString *)directionFilterForStop:(NSString *)stopId { + if (!stopId) return nil; + return [[NSUserDefaults standardUserDefaults] stringForKey:[@"dir." stringByAppendingString:stopId]]; +} + ++ (void)setDirectionFilter:(NSString *)direction forStop:(NSString *)stopId { + if (!stopId) return; + NSString *k = [@"dir." stringByAppendingString:stopId]; + if (direction) [[NSUserDefaults standardUserDefaults] setObject:direction forKey:k]; + else [[NSUserDefaults standardUserDefaults] removeObjectForKey:k]; + [[NSNotificationCenter defaultCenter] postNotificationName:BVGSettingsChangedNotification object:nil]; +} + ++ (void)setInteger:(NSInteger)value forKey:(NSString *)key { + [[NSUserDefaults standardUserDefaults] setInteger:value forKey:key]; + [[NSNotificationCenter defaultCenter] postNotificationName:BVGSettingsChangedNotification object:nil]; +} + ++ (void)setBool:(BOOL)value forKey:(NSString *)key { + [[NSUserDefaults standardUserDefaults] setBool:value forKey:key]; + [[NSNotificationCenter defaultCenter] postNotificationName:BVGSettingsChangedNotification object:nil]; +} + +@end diff --git a/BVGNearby/BVGTheme.h b/BVGNearby/BVGTheme.h new file mode 100644 index 0000000..503c07e --- /dev/null +++ b/BVGNearby/BVGTheme.h @@ -0,0 +1,31 @@ +#import + +// Zentrales Design: Farben, Schriften, Systemdarstellung. +// Orientiert an aktuellem iOS-Design (inset grouped, grosse Titel), umgesetzt mit iOS-9-Mitteln. +@interface BVGTheme : NSObject + ++ (void)applyAppearance; + +// Farben ++ (UIColor *)background; // hellgrauer Hintergrund (#F2F2F7) ++ (UIColor *)card; // Kartenflaeche (weiss) ++ (UIColor *)cardPressed; // Karte beim Antippen ++ (UIColor *)separator; ++ (UIColor *)textPrimary; ++ (UIColor *)textSecondary; ++ (UIColor *)textTertiary; ++ (UIColor *)accent; // BVG-Gelb ++ (UIColor *)accentSoft; // helles Gelb fuer Hervorhebungen ++ (UIColor *)link; // iOS-Blau ++ (UIColor *)green; ++ (UIColor *)red; ++ (UIColor *)orange; + +// Schriften (San Francisco) ++ (UIFont *)font:(CGFloat)size weight:(CGFloat)weight; ++ (UIFont *)digits:(CGFloat)size weight:(CGFloat)weight; // gleich breite Ziffern -> kein Springen beim Herunterzaehlen + ++ (CGFloat)cardInset; // Abstand Karte <-> Bildschirmrand ++ (CGFloat)cardRadius; + +@end diff --git a/BVGNearby/BVGTheme.m b/BVGNearby/BVGTheme.m new file mode 100644 index 0000000..bca85c3 --- /dev/null +++ b/BVGNearby/BVGTheme.m @@ -0,0 +1,72 @@ +#import "BVGTheme.h" + +@implementation BVGTheme + ++ (UIColor *)rgb:(int)r :(int)g :(int)b { + return [UIColor colorWithRed:r / 255.0 green:g / 255.0 blue:b / 255.0 alpha:1]; +} + ++ (UIColor *)background { return [self rgb:242 :242 :247]; } ++ (UIColor *)card { return [UIColor whiteColor]; } ++ (UIColor *)cardPressed { return [self rgb:229 :229 :234]; } ++ (UIColor *)separator { return [self rgb:224 :224 :228]; } ++ (UIColor *)textPrimary { return [self rgb:17 :17 :20]; } ++ (UIColor *)textSecondary { return [self rgb:120 :120 :128]; } ++ (UIColor *)textTertiary { return [self rgb:174 :174 :178]; } ++ (UIColor *)accent { return [self rgb:247 :209 :23]; } ++ (UIColor *)accentSoft { return [self rgb:255 :246 :204]; } ++ (UIColor *)link { return [self rgb:0 :122 :255]; } ++ (UIColor *)green { return [self rgb:52 :168 :83]; } ++ (UIColor *)red { return [self rgb:255 :59 :48]; } ++ (UIColor *)orange { return [self rgb:255 :149 :0]; } + ++ (UIFont *)font:(CGFloat)size weight:(CGFloat)weight { + return [UIFont systemFontOfSize:size weight:weight]; +} + ++ (UIFont *)digits:(CGFloat)size weight:(CGFloat)weight { + return [UIFont monospacedDigitSystemFontOfSize:size weight:weight]; +} + ++ (CGFloat)cardInset { return 14; } ++ (CGFloat)cardRadius { return 12; } + ++ (UIImage *)imageWithColor:(UIColor *)color { + UIGraphicsBeginImageContextWithOptions(CGSizeMake(1, 1), YES, 0); + [color setFill]; + UIRectFill(CGRectMake(0, 0, 1, 1)); + UIImage *img = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return img; +} + ++ (void)applyAppearance { + // Navigationsleiste: flach, gleiche Farbe wie der Hintergrund, keine Trennlinie + UINavigationBar *nav = [UINavigationBar appearance]; + [nav setBackgroundImage:[self imageWithColor:[self background]] forBarMetrics:UIBarMetricsDefault]; + nav.shadowImage = [[UIImage alloc] init]; + nav.translucent = NO; + nav.barTintColor = [self background]; + nav.tintColor = [self textPrimary]; + nav.titleTextAttributes = @{ NSForegroundColorAttributeName: [self textPrimary], + NSFontAttributeName: [self font:17 weight:UIFontWeightSemibold] }; + [[UIBarButtonItem appearance] setTitleTextAttributes:@{ NSFontAttributeName: [self font:17 weight:UIFontWeightRegular] } + forState:UIControlStateNormal]; + + // Tab-Leiste: weiss, Auswahl dunkel + UITabBar *tab = [UITabBar appearance]; + tab.barTintColor = [UIColor whiteColor]; + tab.tintColor = [self textPrimary]; + tab.translucent = NO; + [[UITabBarItem appearance] setTitleTextAttributes:@{ NSFontAttributeName: [self font:10 weight:UIFontWeightMedium], + NSForegroundColorAttributeName: [self textSecondary] } + forState:UIControlStateNormal]; + [[UITabBarItem appearance] setTitleTextAttributes:@{ NSFontAttributeName: [self font:10 weight:UIFontWeightSemibold], + NSForegroundColorAttributeName: [self textPrimary] } + forState:UIControlStateSelected]; + + [UISwitch appearance].onTintColor = [self green]; + [UISegmentedControl appearance].tintColor = [self textPrimary]; +} + +@end diff --git a/BVGNearby/Base.lproj/Main.storyboard b/BVGNearby/Base.lproj/Main.storyboard deleted file mode 100644 index 808a21c..0000000 --- a/BVGNearby/Base.lproj/Main.storyboard +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/BVGNearby/CardCell.h b/BVGNearby/CardCell.h new file mode 100644 index 0000000..08aae73 --- /dev/null +++ b/BVGNearby/CardCell.h @@ -0,0 +1,10 @@ +#import + +// Zelle im "inset grouped"-Stil: eingerueckt, Karte mit runden Ecken oben/unten pro Abschnitt. +// Die Position (erste/letzte Zeile) setzt CardTableViewController automatisch. +@interface CardCell : UITableViewCell +@property (nonatomic, strong) UIColor *cardColor; // Standard: weiss +@property (nonatomic, assign) BOOL firstInSection; +@property (nonatomic, assign) BOOL lastInSection; +- (void)applyCardStyle; +@end diff --git a/BVGNearby/CardCell.m b/BVGNearby/CardCell.m new file mode 100644 index 0000000..51cee5a --- /dev/null +++ b/BVGNearby/CardCell.m @@ -0,0 +1,110 @@ +#import "CardCell.h" +#import "BVGTheme.h" + +#pragma mark - Hintergrund der Karte + +@interface CardBackgroundView : UIView +@property (nonatomic, strong) UIColor *fillColor; +@property (nonatomic, assign) BOOL roundTop; +@property (nonatomic, assign) BOOL roundBottom; +@property (nonatomic, assign) BOOL separator; +@end + +@implementation CardBackgroundView + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self) { + self.opaque = NO; + self.backgroundColor = [UIColor clearColor]; + self.contentMode = UIViewContentModeRedraw; + } + return self; +} + +- (void)drawRect:(CGRect)rect { + UIRectCorner corners = 0; + if (self.roundTop) corners |= UIRectCornerTopLeft | UIRectCornerTopRight; + if (self.roundBottom) corners |= UIRectCornerBottomLeft | UIRectCornerBottomRight; + CGFloat r = [BVGTheme cardRadius]; + UIBezierPath *p = [UIBezierPath bezierPathWithRoundedRect:self.bounds byRoundingCorners:corners cornerRadii:CGSizeMake(r, r)]; + [(self.fillColor ?: [BVGTheme card]) setFill]; + [p fill]; + + if (self.separator) { + CGFloat h = 1.0 / [UIScreen mainScreen].scale; + [[BVGTheme separator] setFill]; + UIRectFill(CGRectMake(16, self.bounds.size.height - h, self.bounds.size.width - 16, h)); + } +} + +@end + +#pragma mark - Zelle + +@implementation CardCell + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) { + self.backgroundColor = [UIColor clearColor]; + self.backgroundView = [[CardBackgroundView alloc] init]; + self.selectedBackgroundView = [[CardBackgroundView alloc] init]; + self.textLabel.font = [BVGTheme font:16 weight:UIFontWeightRegular]; + self.textLabel.textColor = [BVGTheme textPrimary]; + self.detailTextLabel.font = [BVGTheme font:14 weight:UIFontWeightRegular]; + self.detailTextLabel.textColor = [BVGTheme textSecondary]; + self.firstInSection = YES; + self.lastInSection = YES; + [self applyCardStyle]; + } + return self; +} + +// der eigentliche Trick: Zelle seitlich einruecken -> Karten-Optik wie ab iOS 13 +- (void)setFrame:(CGRect)frame { + CGFloat inset = [BVGTheme cardInset]; + if (frame.size.width > 2 * inset + 100) { + frame.origin.x += inset; + frame.size.width -= 2 * inset; + } + [super setFrame:frame]; +} + +- (void)setCardColor:(UIColor *)cardColor { + _cardColor = cardColor; + [self applyCardStyle]; +} + +- (void)applyCardStyle { + CardBackgroundView *bg = (CardBackgroundView *)self.backgroundView; + CardBackgroundView *sel = (CardBackgroundView *)self.selectedBackgroundView; + if (![bg isKindOfClass:[CardBackgroundView class]] || ![sel isKindOfClass:[CardBackgroundView class]]) return; + + bg.fillColor = self.cardColor ?: [BVGTheme card]; + sel.fillColor = [BVGTheme cardPressed]; + for (CardBackgroundView *v in @[bg, sel]) { + v.roundTop = self.firstInSection; + v.roundBottom = self.lastInSection; + v.separator = !self.lastInSection; + [v setNeedsDisplay]; + } +} + +- (void)prepareForReuse { + [super prepareForReuse]; + self.cardColor = nil; + self.accessoryType = UITableViewCellAccessoryNone; + self.accessoryView = nil; + self.imageView.image = nil; + self.textLabel.text = nil; + self.detailTextLabel.text = nil; + self.textLabel.font = [BVGTheme font:16 weight:UIFontWeightRegular]; + self.textLabel.textColor = [BVGTheme textPrimary]; + self.textLabel.textAlignment = NSTextAlignmentLeft; + self.detailTextLabel.font = [BVGTheme font:14 weight:UIFontWeightRegular]; + self.detailTextLabel.textColor = [BVGTheme textSecondary]; + self.contentView.alpha = 1; +} + +@end diff --git a/BVGNearby/CardTableViewController.h b/BVGNearby/CardTableViewController.h new file mode 100644 index 0000000..f8fef0c --- /dev/null +++ b/BVGNearby/CardTableViewController.h @@ -0,0 +1,21 @@ +#import +#import "CardCell.h" +#import "BVGTheme.h" + +// Basis fuer alle Listen: Karten-Layout, moderne Abschnittstitel, grosser Titel, leere Zustaende. +@interface CardTableViewController : UITableViewController + +// grosser Titel oben in der Liste (wird beim Scrollen in die Leiste uebernommen) +@property (nonatomic, copy) NSString *largeTitle; +@property (nonatomic, copy) NSString *largeSubtitle; +@property (nonatomic, strong) UIColor *largeSubtitleColor; +@property (nonatomic, strong) UIView *headerAccessory; // z.B. Suchfeld oder Filter unter dem Titel +- (void)updateLargeHeader; + +// leerer Zustand mittig in der Liste; icon/title/text = nil blendet ihn aus +- (void)showEmptyIcon:(NSString *)icon title:(NSString *)title text:(NSString *)text; + +// Abschnittstitel im gleichen Stil, fuer eigene Header ++ (UILabel *)sectionLabelWithText:(NSString *)text; + +@end diff --git a/BVGNearby/CardTableViewController.m b/BVGNearby/CardTableViewController.m new file mode 100644 index 0000000..9f99cc6 --- /dev/null +++ b/BVGNearby/CardTableViewController.m @@ -0,0 +1,215 @@ +#import "CardTableViewController.h" + +@interface CardTableViewController () +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *subtitleLabel; +@end + +@implementation CardTableViewController + +- (instancetype)initWithStyle:(UITableViewStyle)style { + return [super initWithStyle:UITableViewStyleGrouped]; // nicht klebende Abschnittstitel +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.tableView.backgroundView = nil; + self.tableView.backgroundColor = [BVGTheme background]; + self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone; + self.tableView.sectionHeaderHeight = 0; + self.tableView.sectionFooterHeight = 0; + self.tableView.contentInset = UIEdgeInsetsMake(0, 0, 16, 0); + self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 8)]; + if (self.largeTitle) [self updateLargeHeader]; + else self.tableView.tableHeaderView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 6)]; // sonst 35 pt Luft (grouped) +} + +#pragma mark - Grosser Titel + +- (void)setLargeTitle:(NSString *)largeTitle { + _largeTitle = [largeTitle copy]; + if (self.isViewLoaded) [self updateLargeHeader]; +} + +- (void)setLargeSubtitle:(NSString *)largeSubtitle { + BOOL changed = !(largeSubtitle == _largeSubtitle || [largeSubtitle isEqualToString:_largeSubtitle]); + _largeSubtitle = [largeSubtitle copy]; + if (!self.isViewLoaded || !changed) return; + // nur Text tauschen, wenn sich die Hoehe nicht aendert (kein Springen) + if (self.subtitleLabel && largeSubtitle.length) { + self.subtitleLabel.text = largeSubtitle; + self.subtitleLabel.textColor = self.largeSubtitleColor ?: [BVGTheme textSecondary]; + } else { + [self updateLargeHeader]; + } +} + +- (void)setLargeSubtitleColor:(UIColor *)largeSubtitleColor { + _largeSubtitleColor = largeSubtitleColor; + self.subtitleLabel.textColor = largeSubtitleColor ?: [BVGTheme textSecondary]; +} + +- (void)setHeaderAccessory:(UIView *)headerAccessory { + _headerAccessory = headerAccessory; + if (self.isViewLoaded) [self updateLargeHeader]; +} + +- (void)updateLargeHeader { + CGFloat w = self.tableView.bounds.size.width ?: [UIScreen mainScreen].bounds.size.width; + CGFloat x = [BVGTheme cardInset] + 4; + UIView *header = [[UIView alloc] initWithFrame:CGRectZero]; + CGFloat y = 2; + + if (self.largeTitle) { + self.titleLabel = [[UILabel alloc] initWithFrame:CGRectMake(x, y, w - 2 * x, 40)]; + self.titleLabel.font = [BVGTheme font:32 weight:UIFontWeightBold]; + self.titleLabel.textColor = [BVGTheme textPrimary]; + self.titleLabel.text = self.largeTitle; + self.titleLabel.adjustsFontSizeToFitWidth = YES; + self.titleLabel.minimumScaleFactor = 0.7; + self.titleLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth; + [header addSubview:self.titleLabel]; + y += 40; + } + if (self.largeSubtitle.length) { + self.subtitleLabel = [[UILabel alloc] initWithFrame:CGRectMake(x, y, w - 2 * x, 18)]; + self.subtitleLabel.font = [BVGTheme font:14 weight:UIFontWeightMedium]; + self.subtitleLabel.textColor = self.largeSubtitleColor ?: [BVGTheme textSecondary]; + self.subtitleLabel.text = self.largeSubtitle; + self.subtitleLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth; + [header addSubview:self.subtitleLabel]; + y += 20; + } else { + self.subtitleLabel = nil; + } + if (self.headerAccessory) { + y += 8; + CGRect f = self.headerAccessory.frame; + f.origin = CGPointMake(0, y); + f.size.width = w; + self.headerAccessory.frame = f; + self.headerAccessory.autoresizingMask = UIViewAutoresizingFlexibleWidth; + [header addSubview:self.headerAccessory]; + y += f.size.height; + } + header.frame = CGRectMake(0, 0, w, y + 4); + self.tableView.tableHeaderView = header; +} + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView { + if (!self.largeTitle) return; + BOOL scrolledAway = scrollView.contentOffset.y + scrollView.contentInset.top > 36; + NSString *wanted = scrolledAway ? self.largeTitle : nil; + if (!(wanted == self.navigationItem.title || [wanted isEqualToString:self.navigationItem.title])) { + self.navigationItem.title = wanted; + } +} + +#pragma mark - Leerer Zustand + +- (void)showEmptyIcon:(NSString *)icon title:(NSString *)title text:(NSString *)text { + if (!icon && !title && !text) { self.tableView.backgroundView = nil; return; } + + UIView *v = [[UIView alloc] initWithFrame:self.tableView.bounds]; + UILabel *l = [[UILabel alloc] init]; + l.numberOfLines = 0; + l.textAlignment = NSTextAlignmentCenter; + NSMutableAttributedString *s = [[NSMutableAttributedString alloc] init]; + NSMutableParagraphStyle *ps = [[NSMutableParagraphStyle alloc] init]; + ps.alignment = NSTextAlignmentCenter; + ps.paragraphSpacing = 6; + if (icon) [s appendAttributedString:[[NSAttributedString alloc] initWithString:[icon stringByAppendingString:@"\n"] + attributes:@{ NSFontAttributeName: [UIFont systemFontOfSize:46], NSParagraphStyleAttributeName: ps }]]; + if (title) [s appendAttributedString:[[NSAttributedString alloc] initWithString:[title stringByAppendingString:@"\n"] + attributes:@{ NSFontAttributeName: [BVGTheme font:19 weight:UIFontWeightSemibold], + NSForegroundColorAttributeName: [BVGTheme textPrimary], NSParagraphStyleAttributeName: ps }]]; + if (text) [s appendAttributedString:[[NSAttributedString alloc] initWithString:text + attributes:@{ NSFontAttributeName: [BVGTheme font:15 weight:UIFontWeightRegular], + NSForegroundColorAttributeName: [BVGTheme textSecondary], NSParagraphStyleAttributeName: ps }]]; + l.attributedText = s; + CGFloat w = v.bounds.size.width - 64; + CGSize size = [l sizeThatFits:CGSizeMake(w, CGFLOAT_MAX)]; + l.frame = CGRectMake(32, MAX(60, v.bounds.size.height * 0.38 - size.height / 2), w, size.height); + l.autoresizingMask = UIViewAutoresizingFlexibleWidth; + [v addSubview:l]; + self.tableView.backgroundView = v; +} + +#pragma mark - Abschnittstitel & -fuss + ++ (UILabel *)sectionLabelWithText:(NSString *)text { + UILabel *l = [[UILabel alloc] init]; + l.text = text; + l.font = [BVGTheme font:15 weight:UIFontWeightSemibold]; + l.textColor = [BVGTheme textSecondary]; + return l; +} + +- (NSString *)headerTitleFor:(NSInteger)section { + if (![self respondsToSelector:@selector(tableView:titleForHeaderInSection:)]) return nil; + return [(id)self tableView:self.tableView titleForHeaderInSection:section]; +} + +- (NSString *)footerTitleFor:(NSInteger)section { + if (![self respondsToSelector:@selector(tableView:titleForFooterInSection:)]) return nil; + return [(id)self tableView:self.tableView titleForFooterInSection:section]; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { + if ([self headerTitleFor:section].length) return 42; + return section == 0 ? 8 : 20; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { + NSString *title = [self headerTitleFor:section]; + UIView *v = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.bounds.size.width, title.length ? 42 : 8)]; + if (!title.length) return v; + UILabel *l = [CardTableViewController sectionLabelWithText:title]; + l.frame = CGRectMake([BVGTheme cardInset] + 4, 16, v.bounds.size.width - 2 * [BVGTheme cardInset] - 8, 20); + l.autoresizingMask = UIViewAutoresizingFlexibleWidth; + [v addSubview:l]; + return v; +} + +- (CGFloat)footerTextHeight:(NSString *)text { + CGFloat w = self.tableView.bounds.size.width - 2 * ([BVGTheme cardInset] + 4); + CGRect r = [text boundingRectWithSize:CGSizeMake(w, CGFLOAT_MAX) + options:NSStringDrawingUsesLineFragmentOrigin + attributes:@{ NSFontAttributeName: [BVGTheme font:13 weight:UIFontWeightRegular] } + context:nil]; + return ceil(r.size.height); +} + +- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section { + NSString *text = [self footerTitleFor:section]; + return text.length ? [self footerTextHeight:text] + 16 : 0.01; +} + +- (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section { + NSString *text = [self footerTitleFor:section]; + if (!text.length) return [[UIView alloc] initWithFrame:CGRectZero]; + CGFloat h = [self footerTextHeight:text]; + UIView *v = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.bounds.size.width, h + 16)]; + UILabel *l = [[UILabel alloc] initWithFrame:CGRectMake([BVGTheme cardInset] + 4, 8, + v.bounds.size.width - 2 * ([BVGTheme cardInset] + 4), h)]; + l.numberOfLines = 0; + l.font = [BVGTheme font:13 weight:UIFontWeightRegular]; + l.textColor = [BVGTheme textSecondary]; + l.text = text; + l.autoresizingMask = UIViewAutoresizingFlexibleWidth; + [v addSubview:l]; + return v; +} + +#pragma mark - Kartenposition + +- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { + if (![cell isKindOfClass:[CardCell class]]) return; + CardCell *c = (CardCell *)cell; + NSInteger rows = [self tableView:tableView numberOfRowsInSection:indexPath.section]; + c.firstInSection = indexPath.row == 0; + c.lastInSection = indexPath.row == rows - 1; + [c applyCardStyle]; +} + +@end diff --git a/BVGNearby/ChipBar.h b/BVGNearby/ChipBar.h new file mode 100644 index 0000000..977fb73 --- /dev/null +++ b/BVGNearby/ChipBar.h @@ -0,0 +1,9 @@ +#import + +// Horizontale Filter-Chips (wie in aktuellen Apps) statt des alten iOS-7-Segmented-Controls. +@interface ChipBar : UIView +- (instancetype)initWithItems:(NSArray *)titles; +@property (nonatomic, assign) NSInteger selectedIndex; +@property (nonatomic, copy) void (^onChange)(NSInteger index); +@property (nonatomic, strong) UIColor *idleColor; // Hintergrund nicht gewaehlter Chips +@end diff --git a/BVGNearby/ChipBar.m b/BVGNearby/ChipBar.m new file mode 100644 index 0000000..0c583f9 --- /dev/null +++ b/BVGNearby/ChipBar.m @@ -0,0 +1,68 @@ +#import "ChipBar.h" +#import "BVGTheme.h" + +@interface ChipBar () +@property (nonatomic, strong) UIScrollView *scroll; +@property (nonatomic, strong) NSArray *buttons; +@end + +@implementation ChipBar + +- (instancetype)initWithItems:(NSArray *)titles { + self = [super initWithFrame:CGRectMake(0, 0, 320, 44)]; + if (self) { + _scroll = [[UIScrollView alloc] initWithFrame:self.bounds]; + _scroll.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + _scroll.showsHorizontalScrollIndicator = NO; + [self addSubview:_scroll]; + + NSMutableArray *buttons = [NSMutableArray array]; + CGFloat x = [BVGTheme cardInset]; + for (NSUInteger i = 0; i < titles.count; i++) { + UIButton *b = [UIButton buttonWithType:UIButtonTypeCustom]; + [b setTitle:titles[i] forState:UIControlStateNormal]; + b.titleLabel.font = [BVGTheme font:15 weight:UIFontWeightSemibold]; + CGFloat w = ceil([titles[i] sizeWithAttributes:@{ NSFontAttributeName: b.titleLabel.font }].width) + 28; + b.frame = CGRectMake(x, 6, w, 32); + b.layer.cornerRadius = 16; + b.tag = i; + [b addTarget:self action:@selector(tapped:) forControlEvents:UIControlEventTouchUpInside]; + [_scroll addSubview:b]; + [buttons addObject:b]; + x += w + 8; + } + _scroll.contentSize = CGSizeMake(x + [BVGTheme cardInset] - 8, 44); + _buttons = buttons; + [self refresh]; + } + return self; +} + +- (void)tapped:(UIButton *)b { + if (b.tag == self.selectedIndex) return; + self.selectedIndex = b.tag; + if (self.onChange) self.onChange(b.tag); +} + +- (void)setIdleColor:(UIColor *)idleColor { + _idleColor = idleColor; + [self refresh]; +} + +- (void)setSelectedIndex:(NSInteger)selectedIndex { + _selectedIndex = selectedIndex; + [self refresh]; +} + +- (void)refresh { + for (UIButton *b in self.buttons) { + BOOL on = b.tag == self.selectedIndex; + [UIView animateWithDuration:0.15 animations:^{ + b.backgroundColor = on ? [BVGTheme textPrimary] : (self.idleColor ?: [BVGTheme card]); + }]; + [b setTitleColor:(on ? [UIColor whiteColor] : [BVGTheme textPrimary]) forState:UIControlStateNormal]; + b.accessibilityTraits = UIAccessibilityTraitButton | (on ? UIAccessibilityTraitSelected : 0); + } +} + +@end diff --git a/BVGNearby/DepartureCell.h b/BVGNearby/DepartureCell.h new file mode 100644 index 0000000..83d3b10 --- /dev/null +++ b/BVGNearby/DepartureCell.h @@ -0,0 +1,8 @@ +#import "CardCell.h" + +@interface DepartureCell : CardCell ++ (CGFloat)height; +- (void)configureWithDeparture:(NSDictionary *)d; +- (void)configureEmpty:(NSString *)text; +- (void)setDimmed:(BOOL)dimmed; // z.B. zu Fuss nicht mehr erreichbar +@end diff --git a/BVGNearby/DepartureCell.m b/BVGNearby/DepartureCell.m new file mode 100644 index 0000000..7fcf475 --- /dev/null +++ b/BVGNearby/DepartureCell.m @@ -0,0 +1,195 @@ +#import "DepartureCell.h" +#import "BVGAPI.h" +#import "BVGTheme.h" + +@interface DepartureCell () +@property (nonatomic, strong) UIImageView *badgeView; +@property (nonatomic, strong) UILabel *directionLabel; +@property (nonatomic, strong) UILabel *infoLabel; +@property (nonatomic, strong) UILabel *minutesLabel; +@property (nonatomic, strong) UILabel *subLabel; +@property (nonatomic, strong) UILabel *emptyLabel; +@end + +@implementation DepartureCell + ++ (CGFloat)height { return 64; } + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { + self = [super initWithStyle:UITableViewCellStyleDefault reuseIdentifier:reuseIdentifier]; + if (self) { + _badgeView = [[UIImageView alloc] init]; + _badgeView.contentMode = UIViewContentModeLeft; + [self.contentView addSubview:_badgeView]; + + _directionLabel = [[UILabel alloc] init]; + _directionLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold]; + _directionLabel.textColor = [BVGTheme textPrimary]; + [self.contentView addSubview:_directionLabel]; + + _infoLabel = [[UILabel alloc] init]; + _infoLabel.font = [BVGTheme font:13 weight:UIFontWeightRegular]; + [self.contentView addSubview:_infoLabel]; + + _minutesLabel = [[UILabel alloc] init]; + _minutesLabel.textAlignment = NSTextAlignmentRight; + [self.contentView addSubview:_minutesLabel]; + + _subLabel = [[UILabel alloc] init]; + _subLabel.font = [BVGTheme digits:12 weight:UIFontWeightMedium]; + _subLabel.textAlignment = NSTextAlignmentRight; + [self.contentView addSubview:_subLabel]; + + _emptyLabel = [[UILabel alloc] init]; + _emptyLabel.font = [BVGTheme font:15 weight:UIFontWeightRegular]; + _emptyLabel.textColor = [BVGTheme textSecondary]; + _emptyLabel.hidden = YES; + [self.contentView addSubview:_emptyLabel]; + } + return self; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + CGFloat w = self.contentView.bounds.size.width; + CGFloat h = self.contentView.bounds.size.height; + CGFloat right = 76; + CGFloat textX = 80; + + self.badgeView.frame = CGRectMake(14, (h - 24) / 2, 62, 24); + self.directionLabel.frame = CGRectMake(textX, 12, w - textX - right - 6, 20); + self.infoLabel.frame = CGRectMake(textX, 34, w - textX - right - 6, 17); + self.minutesLabel.frame = CGRectMake(w - right - 14, 9, right, 28); + self.subLabel.frame = CGRectMake(w - right - 14, 37, right, 16); + self.emptyLabel.frame = CGRectMake(16, 0, w - 32, h); +} + +- (void)setDimmed:(BOOL)dimmed { + self.contentView.alpha = dimmed ? 0.38 : 1.0; +} + +- (void)setAllHidden:(BOOL)hidden { + for (UIView *v in @[self.badgeView, self.directionLabel, self.infoLabel, self.minutesLabel, self.subLabel]) v.hidden = hidden; + self.emptyLabel.hidden = !hidden; +} + +- (void)configureEmpty:(NSString *)text { + [self setAllHidden:YES]; + self.contentView.alpha = 1.0; + self.emptyLabel.text = text; + self.accessibilityLabel = text; + self.selectionStyle = UITableViewCellSelectionStyleNone; +} + +- (void)configureWithDeparture:(NSDictionary *)d { + [self setAllHidden:NO]; + NSDictionary *line = NN(d[@"line"]); + NSString *tripId = NN(d[@"tripId"]); + NSString *dir = NN(d[@"direction"]) ?: @""; + BOOL cancelled = [NN(d[@"cancelled"]) boolValue]; + NSDate *when = [BVGAPI whenOfDeparture:d]; + + self.badgeView.image = [BVGAPI badgeForLine:line]; + self.selectionStyle = tripId ? UITableViewCellSelectionStyleDefault : UITableViewCellSelectionStyleNone; + + // Richtung + if (cancelled) { + self.directionLabel.attributedText = [[NSAttributedString alloc] initWithString:dir attributes:@{ + NSStrikethroughStyleAttributeName: @(NSUnderlineStyleSingle), + NSFontAttributeName: [BVGTheme font:16 weight:UIFontWeightSemibold], + NSForegroundColorAttributeName: [BVGTheme textTertiary] }]; + } else { + self.directionLabel.attributedText = nil; + self.directionLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold]; + self.directionLabel.textColor = [BVGTheme textPrimary]; + self.directionLabel.text = dir; + } + + // Infozeile: Wecker, Gleis/Gleiswechsel, Stoerung + NSMutableArray *info = [NSMutableArray array]; + UIColor *infoColor = [BVGTheme textSecondary]; + if ([BVGAPI hasAlarmForTrip:tripId]) [info addObject:@"⏰ Wecker"]; + + NSString *platform = NN(d[@"platform"]); + NSString *planned = NN(d[@"plannedPlatform"]); + if (platform && planned && ![platform isEqualToString:planned]) { + [info addObject:[NSString stringWithFormat:@"Gleis %@ statt %@", platform, planned]]; + infoColor = [BVGTheme orange]; + } else if (platform ?: planned) { + [info addObject:[NSString stringWithFormat:@"Gleis %@", platform ?: planned]]; + } + + id remarks = NN(d[@"remarks"]); + if ([remarks isKindOfClass:[NSArray class]]) { + for (NSDictionary *r in remarks) { + if ([r isKindOfClass:[NSDictionary class]] && [NN(r[@"type"]) isEqualToString:@"warning"]) { + [info addObject:@"⚠︎ Störung"]; + infoColor = [BVGTheme orange]; + break; + } + } + } + if (cancelled) { [info insertObject:@"Fällt aus" atIndex:0]; infoColor = [BVGTheme red]; } + if (!info.count && when) [info addObject:[NSString stringWithFormat:@"Abfahrt %@", [BVGAPI hhmm:when]]]; + self.infoLabel.text = [info componentsJoinedByString:@" · "]; + self.infoLabel.textColor = infoColor; + + // rechte Spalte + if (cancelled) { + self.minutesLabel.attributedText = nil; + self.minutesLabel.font = [BVGTheme font:15 weight:UIFontWeightSemibold]; + self.minutesLabel.textColor = [BVGTheme red]; + self.minutesLabel.text = @"Ausfall"; + self.subLabel.attributedText = [[NSAttributedString alloc] initWithString:[BVGAPI hhmm:when] attributes:@{ + NSStrikethroughStyleAttributeName: @(NSUnderlineStyleSingle), + NSForegroundColorAttributeName: [BVGTheme textTertiary] }]; + self.accessibilityLabel = [NSString stringWithFormat:@"%@ Richtung %@ fällt aus", [BVGAPI spokenLineName:line], dir]; + return; + } + + NSInteger mins = when ? (NSInteger)floor([when timeIntervalSinceNow] / 60.0) : -1; + NSInteger delay = [NN(d[@"delay"]) integerValue] / 60; + BOOL realtime = NN(d[@"delay"]) != nil; + UIColor *numberColor = delay > 0 ? [BVGTheme red] : [BVGTheme textPrimary]; + + if (mins <= 0) { + self.minutesLabel.attributedText = nil; + self.minutesLabel.font = [BVGTheme font:18 weight:UIFontWeightBold]; + self.minutesLabel.textColor = delay > 0 ? [BVGTheme red] : [BVGTheme green]; + self.minutesLabel.text = @"jetzt"; + } else if (mins >= 60) { + self.minutesLabel.attributedText = nil; + self.minutesLabel.font = [BVGTheme digits:20 weight:UIFontWeightSemibold]; + self.minutesLabel.textColor = numberColor; + self.minutesLabel.text = [BVGAPI hhmm:when]; + } else { + NSMutableAttributedString *s = [[NSMutableAttributedString alloc] + initWithString:[NSString stringWithFormat:@"%ld", (long)mins] + attributes:@{ NSFontAttributeName: [BVGTheme digits:24 weight:UIFontWeightSemibold], + NSForegroundColorAttributeName: numberColor }]; + [s appendAttributedString:[[NSAttributedString alloc] initWithString:@" min" attributes:@{ + NSFontAttributeName: [BVGTheme font:13 weight:UIFontWeightMedium], + NSForegroundColorAttributeName: [BVGTheme textSecondary] }]]; + self.minutesLabel.attributedText = s; + } + + self.subLabel.attributedText = nil; + if (delay > 0) { + self.subLabel.text = [NSString stringWithFormat:@"+%ld %@", (long)delay, [BVGAPI hhmm:when]]; + self.subLabel.textColor = [BVGTheme red]; + } else { + self.subLabel.text = [BVGAPI hhmm:when]; + self.subLabel.textColor = realtime ? [BVGTheme green] : [BVGTheme textSecondary]; // gruen = Echtzeit, puenktlich + } + + // VoiceOver + NSMutableString *ax = [NSMutableString stringWithFormat:@"%@ Richtung %@, ", [BVGAPI spokenLineName:line], dir]; + if (mins <= 0) [ax appendString:@"jetzt"]; + else [ax appendFormat:@"in %ld Minuten", (long)mins]; + if (delay > 0) [ax appendFormat:@", %ld Minuten verspätet", (long)delay]; + if (platform ?: planned) [ax appendFormat:@", Gleis %@", platform ?: planned]; + self.accessibilityLabel = ax; + self.accessibilityHint = tripId ? @"Doppeltippen für den Fahrtverlauf" : nil; +} + +@end diff --git a/BVGNearby/DisruptionsViewController.h b/BVGNearby/DisruptionsViewController.h new file mode 100644 index 0000000..d43c50b --- /dev/null +++ b/BVGNearby/DisruptionsViewController.h @@ -0,0 +1,9 @@ +#import "CardTableViewController.h" + +// Sammelansicht aller Stoerungen/Hinweise an den angezeigten Haltestellen +@interface DisruptionsViewController : CardTableViewController +// stops: [{name, items: [{summary, text, lines}]}] +- (instancetype)initWithStops:(NSArray *)stops; ++ (NSArray *)collectFromStops:(NSArray *)stops; // aus Haltestellen mit "departures" zusammenstellen ++ (NSInteger)countInCollection:(NSArray *)collection; +@end diff --git a/BVGNearby/DisruptionsViewController.m b/BVGNearby/DisruptionsViewController.m new file mode 100644 index 0000000..eade8ad --- /dev/null +++ b/BVGNearby/DisruptionsViewController.m @@ -0,0 +1,112 @@ +#import "DisruptionsViewController.h" +#import "BVGAPI.h" + +@interface DisruptionsViewController () +@property (nonatomic, strong) NSArray *stops; +@end + +@implementation DisruptionsViewController + ++ (NSArray *)collectFromStops:(NSArray *)stops { + NSMutableArray *result = [NSMutableArray array]; + for (NSDictionary *stop in stops) { + NSMutableDictionary *byKey = [NSMutableDictionary dictionary]; + NSMutableArray *order = [NSMutableArray array]; + for (NSDictionary *d in stop[@"departures"] ?: @[]) { + id remarks = NN(d[@"remarks"]); + if (![remarks isKindOfClass:[NSArray class]]) continue; + NSString *line = NN(NN(d[@"line"])[@"name"]); + for (NSDictionary *r in remarks) { + if (![r isKindOfClass:[NSDictionary class]] || ![NN(r[@"type"]) isEqualToString:@"warning"]) continue; + NSString *summary = NN(r[@"summary"]) ?: @"Hinweis"; + NSString *text = NN(r[@"text"]) ?: @""; + NSString *key = [summary stringByAppendingString:text]; + NSMutableDictionary *item = byKey[key]; + if (!item) { + item = [@{ @"summary": summary, @"text": text, @"lines": [NSMutableOrderedSet orderedSet] } mutableCopy]; + byKey[key] = item; + [order addObject:key]; + } + if (line) [item[@"lines"] addObject:line]; + } + } + if (!order.count) continue; + NSMutableArray *items = [NSMutableArray array]; + for (NSString *k in order) [items addObject:byKey[k]]; + [result addObject:@{ @"name": stop[@"name"] ?: @"?", @"items": items }]; + } + return result; +} + ++ (NSInteger)countInCollection:(NSArray *)collection { + NSMutableSet *unique = [NSMutableSet set]; + for (NSDictionary *s in collection) for (NSDictionary *i in s[@"items"]) [unique addObject:i[@"summary"]]; + return unique.count; +} + ++ (NSString *)plain:(NSString *)s { + if (!s.length) return @""; + NSString *t = [s stringByReplacingOccurrencesOfString:@"
" withString:@"\n" + options:NSRegularExpressionSearch | NSCaseInsensitiveSearch range:NSMakeRange(0, s.length)]; + t = [t stringByReplacingOccurrencesOfString:@"<[^>]+>" withString:@"" options:NSRegularExpressionSearch range:NSMakeRange(0, t.length)]; + return [[t stringByReplacingOccurrencesOfString:@" " withString:@" "] + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; +} + +- (instancetype)initWithStops:(NSArray *)stops { + self = [super initWithStyle:UITableViewStyleGrouped]; + if (self) _stops = stops; + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.largeTitle = @"Hinweise"; + NSInteger n = [DisruptionsViewController countInCollection:self.stops]; + self.largeSubtitle = n == 1 ? @"1 aktuelle Meldung" : [NSString stringWithFormat:@"%ld aktuelle Meldungen", (long)n]; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 80; + if (!self.stops.count) [self showEmptyIcon:@"✅" title:@"Alles ruhig" text:@"Keine Störungen an deinen Haltestellen."]; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + return self.stops.count; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { + return self.stops[section][@"name"]; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return [self.stops[section][@"items"] count]; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + CardCell *cell = [[CardCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:nil]; + NSDictionary *item = self.stops[indexPath.section][@"items"][indexPath.row]; + NSArray *lines = [item[@"lines"] array]; + + cell.textLabel.text = [@"⚠︎ " stringByAppendingString:item[@"summary"]]; + cell.textLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold]; + cell.textLabel.numberOfLines = 0; + + NSString *body = [DisruptionsViewController plain:item[@"text"]]; + if (body.length > 160) body = [[body substringToIndex:157] stringByAppendingString:@" …"]; + NSString *lineText = lines.count ? [NSString stringWithFormat:@"Betrifft: %@\n", [lines componentsJoinedByString:@", "]] : @""; + cell.detailTextLabel.text = [lineText stringByAppendingString:body]; + cell.detailTextLabel.numberOfLines = 0; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + NSDictionary *item = self.stops[indexPath.section][@"items"][indexPath.row]; + UIAlertController *a = [UIAlertController alertControllerWithTitle:item[@"summary"] + message:[DisruptionsViewController plain:item[@"text"]] + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:a animated:YES completion:nil]; +} + +@end diff --git a/BVGNearby/GuidanceManager.h b/BVGNearby/GuidanceManager.h new file mode 100644 index 0000000..70b6e93 --- /dev/null +++ b/BVGNearby/GuidanceManager.h @@ -0,0 +1,41 @@ +#import +#import + +typedef NS_ENUM(NSInteger, GuidanceStatus) { + GuidanceStatusOK, // alle Anschluesse erreichbar + GuidanceStatusAtRisk, // Anschluss knapp (< 1 min Puffer) + GuidanceStatusBroken, // Anschluss nicht erreichbar oder Ausfall + GuidanceStatusDone // Ziel erreicht +}; + +extern NSString * const GuidanceChangedNotification; + +// Begleitet eine Verbindung: Ein-/Aussteige-Erinnerungen, regelmaessige Echtzeit-Pruefung, +// Alternativsuche bei Problemen. Die Entscheidung trifft immer der Nutzer. +@interface GuidanceManager : NSObject + ++ (instancetype)shared; + +@property (nonatomic, readonly) BOOL active; +@property (nonatomic, readonly, strong) NSDictionary *journey; +@property (nonatomic, readonly) GuidanceStatus status; +@property (nonatomic, readonly, copy) NSString *problemText; +@property (nonatomic, readonly, strong) NSDictionary *alternative; +@property (nonatomic, readonly, strong) NSDate *lastCheck; +@property (nonatomic, readonly) BOOL checking; + +- (void)startWithJourney:(NSDictionary *)journey; +- (void)stop; +- (void)checkNow; +- (void)acceptAlternative; +- (void)dismissAlternative; + +- (NSString *)currentInstruction; +- (NSInteger)currentLegIndex; + ++ (BOOL)isWalk:(NSDictionary *)leg; ++ (NSDate *)legDeparture:(NSDictionary *)leg; ++ (NSDate *)legArrival:(NSDictionary *)leg; ++ (NSTimeInterval)minTransferSlack:(NSArray *)legs; + +@end diff --git a/BVGNearby/GuidanceManager.m b/BVGNearby/GuidanceManager.m new file mode 100644 index 0000000..c4edd3e --- /dev/null +++ b/BVGNearby/GuidanceManager.m @@ -0,0 +1,503 @@ +#import "GuidanceManager.h" +#import "BVGAPI.h" +#import "BVGSettings.h" +#import + +NSString * const GuidanceChangedNotification = @"GuidanceChanged"; + +static NSString * const kSavedJourneyKey = @"guidanceJourney"; +static const NSTimeInterval kCheckInterval = 60; // Echtzeit-Pruefung jede Minute +#define kBoardLead ((NSTimeInterval)[BVGSettings boardLeadMinutes] * 60) // Einsteigen-Hinweis (Einstellung) +static const CLLocationDistance kExitRadius = 400; // Backup: Aussteigen per GPS-Naehe +#define kMinTransfer ((NSTimeInterval)[BVGSettings transferMinutes] * 60) // Umstiegspuffer, nie unter 5 min + +@interface GuidanceManager () +@property (nonatomic, readwrite) BOOL active; +@property (nonatomic, readwrite, strong) NSDictionary *journey; +@property (nonatomic, readwrite) GuidanceStatus status; +@property (nonatomic, readwrite, copy) NSString *problemText; +@property (nonatomic, readwrite, strong) NSDictionary *alternative; +@property (nonatomic, readwrite, strong) NSDate *lastCheck; +@property (nonatomic, readwrite) BOOL checking; + +@property (nonatomic, copy) NSString *lastSearchedProblem; // pro Problem nur einmal suchen/melden +@property (nonatomic, strong) NSMutableSet *exitAlertedLegs; +@property (nonatomic, strong) NSTimer *timer; +@property (nonatomic, strong) CLLocationManager *locationManager; +@property (nonatomic, strong) CLLocation *lastLocation; +@end + +@implementation GuidanceManager + ++ (instancetype)shared { + static GuidanceManager *m; + static dispatch_once_t once; + dispatch_once(&once, ^{ + m = [[GuidanceManager alloc] init]; + [m restore]; + }); + return m; +} + +#pragma mark - Hilfen + ++ (BOOL)isWalk:(NSDictionary *)leg { + return [NN(leg[@"walking"]) boolValue] || !NN(leg[@"line"]); +} + ++ (NSDate *)legDeparture:(NSDictionary *)leg { + return [BVGAPI dateFromISO:leg[@"departure"]] ?: [BVGAPI dateFromISO:leg[@"plannedDeparture"]]; +} + ++ (NSDate *)legArrival:(NSDictionary *)leg { + return [BVGAPI dateFromISO:leg[@"arrival"]] ?: [BVGAPI dateFromISO:leg[@"plannedArrival"]]; +} + +// kleinste Umstiegszeit (Ankunft -> naechste Abfahrt) einer Verbindung; ohne Umstieg: unendlich ++ (NSTimeInterval)minTransferSlack:(NSArray *)legs { + NSTimeInterval min = DBL_MAX; + NSDictionary *prev = nil; + for (NSDictionary *leg in legs) { + if ([self isWalk:leg]) continue; + if (prev) { + NSDate *a = [self legArrival:prev], *d = [self legDeparture:leg]; + if (a && d) min = MIN(min, [d timeIntervalSinceDate:a]); + } + prev = leg; + } + return min; +} + +- (NSArray *)legs { + id legs = NN(self.journey[@"legs"]); + return [legs isKindOfClass:[NSArray class]] ? legs : @[]; +} + +- (void)changed { + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:GuidanceChangedNotification object:nil]; + }); +} + +#pragma mark - Start / Stopp / Wiederherstellen + +- (void)startWithJourney:(NSDictionary *)journey { + self.journey = journey; + self.active = YES; + self.alternative = nil; + self.problemText = nil; + self.lastSearchedProblem = nil; + self.exitAlertedLegs = [NSMutableSet set]; + self.lastCheck = [NSDate date]; + [self save]; + [self startMonitoring]; + [self evaluate]; +} + +- (void)stop { + self.active = NO; + self.journey = nil; + self.alternative = nil; + self.problemText = nil; + [self.timer invalidate]; + self.timer = nil; + [self.locationManager stopUpdatingLocation]; + [self cancelGuidanceNotifications]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kSavedJourneyKey]; + [self changed]; +} + +- (void)save { + NSData *data = [NSJSONSerialization dataWithJSONObject:self.journey options:0 error:nil]; + if (data) [[NSUserDefaults standardUserDefaults] setObject:data forKey:kSavedJourneyKey]; +} + +- (void)restore { + NSData *data = [[NSUserDefaults standardUserDefaults] objectForKey:kSavedJourneyKey]; + if (![data isKindOfClass:[NSData class]]) return; + NSDictionary *j = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + NSArray *legs = NN(j[@"legs"]); + NSDate *end = [legs isKindOfClass:[NSArray class]] ? [GuidanceManager legArrival:legs.lastObject] : nil; + if (!end || [end timeIntervalSinceNow] < -600) { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kSavedJourneyKey]; + return; + } + self.journey = j; + self.active = YES; + self.exitAlertedLegs = [NSMutableSet set]; + [self startMonitoring]; + [self checkNow]; +} + +- (void)startMonitoring { + [self.timer invalidate]; + self.timer = [NSTimer timerWithTimeInterval:kCheckInterval target:self selector:@selector(checkNow) userInfo:nil repeats:YES]; + [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes]; + + if (!self.locationManager) { + self.locationManager = [[CLLocationManager alloc] init]; + self.locationManager.delegate = self; + self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters; + self.locationManager.distanceFilter = 30; + self.locationManager.pausesLocationUpdatesAutomatically = NO; + + // Hintergrund nur, wenn UIBackgroundModes=location in der Info.plist steht (sonst Absturz) + NSArray *modes = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"UIBackgroundModes"]; + if ([modes containsObject:@"location"] && + [self.locationManager respondsToSelector:@selector(setAllowsBackgroundLocationUpdates:)]) { + self.locationManager.allowsBackgroundLocationUpdates = YES; + } + } + if ([CLLocationManager authorizationStatus] == kCLAuthorizationStatusNotDetermined) { + [self.locationManager requestWhenInUseAuthorization]; + } + [self.locationManager startUpdatingLocation]; +} + +#pragma mark - Pruefen + +- (void)checkNow { + if (!self.active || self.checking) return; + self.checking = YES; + [self changed]; + + NSString *token = NN(self.journey[@"refreshToken"]); + if (!token) { + [self finishCheck]; + return; + } + NSString *path = [NSString stringWithFormat:@"/journeys/%@?stopovers=true&remarks=true", [BVGAPI encode:token]]; + [BVGAPI getJSON:path completion:^(id json, NSError *error) { + NSDictionary *fresh = [json isKindOfClass:[NSDictionary class]] ? NN(json[@"journey"]) : nil; + dispatch_async(dispatch_get_main_queue(), ^{ + if ([fresh isKindOfClass:[NSDictionary class]] && [NN(fresh[@"legs"]) isKindOfClass:[NSArray class]]) { + self.journey = fresh; + [self save]; + } else { + NSLog(@"Refresh-Fehler: %@", error); + } + [self finishCheck]; + }); + }]; +} + +- (void)finishCheck { + self.checking = NO; + self.lastCheck = [NSDate date]; + [self evaluate]; +} + +- (void)evaluate { + if (!self.active) return; + NSArray *legs = [self legs]; + NSDate *now = [NSDate date]; + + // Ziel erreicht? + NSDate *end = [GuidanceManager legArrival:legs.lastObject]; + if (end && [end timeIntervalSinceDate:now] < 0) { + self.status = GuidanceStatusDone; + self.problemText = nil; + self.alternative = nil; + [self cancelGuidanceNotifications]; + if ([end timeIntervalSinceDate:now] < -600) { [self stop]; return; } + [self changed]; + return; + } + + GuidanceStatus status = GuidanceStatusOK; + NSString *problem = nil; + + NSDictionary *prevTransit = nil; + NSTimeInterval walk = 0; + for (NSDictionary *leg in legs) { + if ([GuidanceManager isWalk:leg]) { + NSDate *d = [GuidanceManager legDeparture:leg], *a = [GuidanceManager legArrival:leg]; + if (d && a) walk += [a timeIntervalSinceDate:d]; + continue; + } + + NSDate *dep = [GuidanceManager legDeparture:leg]; + NSDate *arr = [GuidanceManager legArrival:leg]; + NSString *lineName = NN(NN(leg[@"line"])[@"name"]) ?: @"?"; + + // Ausfall eines noch nicht beendeten Abschnitts + if ([NN(leg[@"cancelled"]) boolValue] && arr && [arr compare:now] == NSOrderedDescending) { + status = GuidanceStatusBroken; + problem = [NSString stringWithFormat:@"%@ fällt aus.", lineName]; + break; + } + + // Umstieg erreichbar? Gesamtzeit zwischen Ankunft und Anschluss pruefen + if (prevTransit && dep && [dep compare:now] == NSOrderedDescending) { + NSDate *prevArr = [GuidanceManager legArrival:prevTransit]; + NSTimeInterval slack = [dep timeIntervalSinceDate:prevArr]; // verfuegbare Umstiegszeit + NSTimeInterval physical = walk > 0 ? walk : 60; // reine Wegzeit, mind. 1 min + NSTimeInterval wanted = MAX(kMinTransfer, physical); // gewuenschter Puffer + NSString *prevName = NN(NN(prevTransit[@"line"])[@"name"]) ?: @"?"; + NSInteger prevDelay = [NN(prevTransit[@"arrivalDelay"]) integerValue] / 60; + NSString *delayText = prevDelay > 0 ? [NSString stringWithFormat:@" (%@ +%ld min)", prevName, (long)prevDelay] : @""; + + if (slack < physical) { + status = GuidanceStatusBroken; + problem = [NSString stringWithFormat:@"Anschluss an %@ nicht erreichbar%@.", lineName, delayText]; + break; + } + if (slack < wanted && status == GuidanceStatusOK) { + status = GuidanceStatusAtRisk; + problem = [NSString stringWithFormat:@"Umstieg zu %@ nur noch %ld min statt mind. %ld min%@.", + lineName, (long)floor(slack / 60.0), (long)[BVGSettings transferMinutes], delayText]; + } + } + prevTransit = leg; + walk = 0; + } + + self.status = status; + self.problemText = problem; + + if (status == GuidanceStatusOK) { + self.alternative = nil; // Problem hat sich erledigt + self.lastSearchedProblem = nil; + } else if (![problem isEqualToString:self.lastSearchedProblem]) { + // auch bei zu knappem Puffer (< 5 min) schon eine Alternative vorschlagen + self.lastSearchedProblem = problem; + [self findAlternativeForProblem:problem]; + } + + [self scheduleGuidanceNotifications]; + [self changed]; +} + +#pragma mark - Alternativen + +- (NSString *)endpointParam:(NSString *)prefix place:(NSDictionary *)place { + NSString *sid = NN(place[@"id"]); + NSString *type = NN(place[@"type"]); + if (sid && ![type isEqualToString:@"location"]) return [NSString stringWithFormat:@"%@=%@", prefix, [BVGAPI encode:sid]]; + + NSDictionary *loc = [NN(place[@"location"]) isKindOfClass:[NSDictionary class]] ? place[@"location"] : place; + NSNumber *lat = NN(loc[@"latitude"]), *lon = NN(loc[@"longitude"]); + if (!lat || !lon) return nil; + return [NSString stringWithFormat:@"%@.latitude=%f&%@.longitude=%f&%@.address=%@", prefix, lat.doubleValue, + prefix, lon.doubleValue, prefix, [BVGAPI encode:NN(place[@"name"]) ?: NN(place[@"address"]) ?: @"Ziel"]]; +} + +- (void)findAlternativeForProblem:(NSString *)problem { + NSArray *legs = [self legs]; + NSDictionary *destination = NN(((NSDictionary *)legs.lastObject)[@"destination"]); + NSString *toParam = [self endpointParam:@"to" place:destination]; + if (!toParam) return; + + // Start: aktueller Standort (frisch), sonst Start des naechsten offenen Abschnitts + NSString *fromParam = nil; + if (self.lastLocation && [self.lastLocation.timestamp timeIntervalSinceNow] > -300) { + CLLocationCoordinate2D c = self.lastLocation.coordinate; + fromParam = [NSString stringWithFormat:@"from.latitude=%f&from.longitude=%f&from.address=%@", + c.latitude, c.longitude, [BVGAPI encode:@"Mein Standort"]]; + } else { + NSInteger idx = [self currentLegIndex]; + if (idx < (NSInteger)legs.count) fromParam = [self endpointParam:@"from" place:NN(legs[idx][@"origin"])]; + } + if (!fromParam) return; + + NSDate *currentArrival = [GuidanceManager legArrival:legs.lastObject]; + NSString *path = [NSString stringWithFormat:@"/journeys?%@&%@&results=5&stopovers=true&remarks=true&transferTime=%ld", + fromParam, toParam, (long)[BVGSettings transferMinutes]]; + + [BVGAPI getJSON:path completion:^(id json, NSError *error) { + NSArray *list = [json isKindOfClass:[NSDictionary class]] ? NN(json[@"journeys"]) : nil; + NSDictionary *best = nil; + NSDate *bestArr = nil; + for (NSDictionary *j in ([list isKindOfClass:[NSArray class]] ? list : @[])) { + NSArray *jl = NN(j[@"legs"]); + if (![jl isKindOfClass:[NSArray class]] || !jl.count) continue; + BOOL cancelled = NO; + for (NSDictionary *l in jl) if ([NN(l[@"cancelled"]) boolValue]) cancelled = YES; + if (cancelled || [GuidanceManager minTransferSlack:jl] < kMinTransfer) continue; // nur Alternativen mit >= 5 min Umstieg + NSDate *arr = [GuidanceManager legArrival:jl.lastObject]; + if (arr && (!bestArr || [arr compare:bestArr] == NSOrderedAscending)) { best = j; bestArr = arr; } + } + + dispatch_async(dispatch_get_main_queue(), ^{ + if (!self.active) return; + if (!best) { + self.problemText = [problem stringByAppendingString:@" Keine Alternative gefunden."]; + [self notifyNow:[@"⚠ " stringByAppendingString:self.problemText] kind:@"alternative"]; + [self changed]; + return; + } + self.alternative = best; + + NSInteger diff = (currentArrival && bestArr) ? (NSInteger)round([bestArr timeIntervalSinceDate:currentArrival] / 60.0) : 0; + NSMutableArray *lines = [NSMutableArray array]; + for (NSDictionary *l in best[@"legs"]) if (![GuidanceManager isWalk:l]) [lines addObject:NN(l[@"line"][@"name"]) ?: @"?"]; + NSString *text = [NSString stringWithFormat:@"⚠ %@ Alternative: %@, Ankunft %@%@. Antippen zum Entscheiden.", + problem, [lines componentsJoinedByString:@" → "], [BVGAPI hhmm:bestArr], + diff > 0 ? [NSString stringWithFormat:@" (+%ld min)", (long)diff] : @""]; + [self notifyNow:text kind:@"alternative"]; + [self changed]; + }); + }]; +} + +- (void)acceptAlternative { + if (!self.alternative) return; + NSDictionary *alt = self.alternative; + [self startWithJourney:alt]; +} + +- (void)dismissAlternative { + self.alternative = nil; // lastSearchedProblem bleibt -> gleiches Problem meldet sich nicht erneut + [self changed]; +} + +#pragma mark - Anweisungen + +- (NSInteger)currentLegIndex { + NSArray *legs = [self legs]; + NSDate *now = [NSDate date]; + for (NSInteger i = 0; i < (NSInteger)legs.count; i++) { + NSDate *arr = [GuidanceManager legArrival:legs[i]]; + if (!arr || [arr compare:now] != NSOrderedAscending) return i; + } + return legs.count; +} + +- (NSString *)platformText:(NSString *)platform { + return platform ? [NSString stringWithFormat:@", Gleis %@", platform] : @""; +} + +- (NSString *)currentInstruction { + NSArray *legs = [self legs]; + NSInteger idx = [self currentLegIndex]; + if (idx >= (NSInteger)legs.count) return @"Du hast dein Ziel erreicht."; + + NSDictionary *leg = legs[idx]; + NSDate *dep = [GuidanceManager legDeparture:leg]; + NSDate *arr = [GuidanceManager legArrival:leg]; + NSString *origin = NN(NN(leg[@"origin"])[@"name"]) ?: @""; + NSString *dest = NN(NN(leg[@"destination"])[@"name"]) ?: @""; + + if ([GuidanceManager isWalk:leg]) { + NSInteger mins = (dep && arr) ? MAX(1, (NSInteger)round([arr timeIntervalSinceDate:dep] / 60.0)) : 1; + return [NSString stringWithFormat:@"Geh zu Fuß zu %@, etwa %ld Minuten.", dest, (long)mins]; + } + + NSString *line = [BVGAPI spokenLineName:NN(leg[@"line"])]; + NSString *dir = NN(leg[@"direction"]) ?: @""; + if (dep && [dep timeIntervalSinceNow] > 0) { + NSInteger mins = (NSInteger)floor([dep timeIntervalSinceNow] / 60.0); + return [NSString stringWithFormat:@"Steig in %@ Richtung %@ ein, um %@ Uhr ab %@%@. %@", + line, dir, [BVGAPI hhmm:dep], origin, [self platformText:NN(leg[@"departurePlatform"])], + mins > 0 ? [NSString stringWithFormat:@"Noch %ld Minuten.", (long)mins] : @"Jetzt!"]; + } + return [NSString stringWithFormat:@"Du fährst mit %@ bis %@. Ankunft %@ Uhr%@. Dort aussteigen.", + line, dest, [BVGAPI hhmm:arr], [self platformText:NN(leg[@"arrivalPlatform"])]]; +} + +#pragma mark - Mitteilungen + +- (void)cancelGuidanceNotifications { + for (UILocalNotification *n in [[UIApplication sharedApplication] scheduledLocalNotifications]) { + if ([n.userInfo[@"kind"] isEqualToString:@"guidance"]) [[UIApplication sharedApplication] cancelLocalNotification:n]; + } +} + +- (void)notifyNow:(NSString *)text kind:(NSString *)kind { + UILocalNotification *n = [[UILocalNotification alloc] init]; + n.alertBody = text; + n.soundName = UILocalNotificationDefaultSoundName; // bei Stumm: nur Vibration (System) + n.userInfo = @{ @"kind": kind }; + [[UIApplication sharedApplication] presentLocalNotificationNow:n]; +} + +- (void)schedule:(NSString *)text at:(NSDate *)date legIndex:(NSInteger)idx { + if (!date || [date timeIntervalSinceNow] < 5) return; + UILocalNotification *n = [[UILocalNotification alloc] init]; + n.fireDate = date; + n.alertBody = text; + n.soundName = UILocalNotificationDefaultSoundName; + n.userInfo = @{ @"kind": @"guidance", @"leg": @(idx) }; + [[UIApplication sharedApplication] scheduleLocalNotification:n]; +} + +- (void)scheduleGuidanceNotifications { + [self cancelGuidanceNotifications]; + NSArray *legs = [self legs]; + + for (NSInteger i = 0; i < (NSInteger)legs.count; i++) { + NSDictionary *leg = legs[i]; + if ([GuidanceManager isWalk:leg] || [NN(leg[@"cancelled"]) boolValue]) continue; + + NSString *lineName = NN(NN(leg[@"line"])[@"name"]) ?: @"?"; + NSString *dir = NN(leg[@"direction"]) ?: @""; + NSString *origin = NN(NN(leg[@"origin"])[@"name"]) ?: @""; + NSString *dest = NN(NN(leg[@"destination"])[@"name"]) ?: @""; + NSDate *dep = [GuidanceManager legDeparture:leg]; + NSDate *arr = [GuidanceManager legArrival:leg]; + + // 1) Einsteigen + [self schedule:[NSString stringWithFormat:@"🚏 Gleich einsteigen: %@ → %@ um %@ ab %@%@", + lineName, dir, [BVGAPI hhmm:dep], origin, [self platformText:NN(leg[@"departurePlatform"])]] + at:[dep dateByAddingTimeInterval:-kBoardLead] legIndex:i]; + + // 2) Aussteigen: wenn das Fahrzeug den vorletzten Halt verlaesst ("Naechster Halt: ...") + if ([self.exitAlertedLegs containsObject:@(i)]) continue; + NSDate *exitAt = nil; + NSArray *stopovers = NN(leg[@"stopovers"]); + if ([stopovers isKindOfClass:[NSArray class]] && stopovers.count >= 3) { + NSDictionary *pen = stopovers[stopovers.count - 2]; + exitAt = [BVGAPI dateFromISO:pen[@"departure"]] ?: [BVGAPI dateFromISO:pen[@"plannedDeparture"]]; + } + if (!exitAt) exitAt = [arr dateByAddingTimeInterval:-90]; + + NSString *next = @"Das ist dein Ziel."; + for (NSInteger k = i + 1; k < (NSInteger)legs.count; k++) { + NSDictionary *nl = legs[k]; + if ([GuidanceManager isWalk:nl]) continue; + next = [NSString stringWithFormat:@"Weiter mit %@ → %@ um %@%@.", + NN(NN(nl[@"line"])[@"name"]) ?: @"?", NN(nl[@"direction"]) ?: @"", + [BVGAPI hhmm:[GuidanceManager legDeparture:nl]], [self platformText:NN(nl[@"departurePlatform"])]]; + break; + } + [self schedule:[NSString stringWithFormat:@"🔔 Nächster Halt: %@ – bitte aussteigen! %@", dest, next] + at:exitAt legIndex:i]; + } +} + +#pragma mark - Standort + +- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations { + self.lastLocation = locations.lastObject; + if (!self.active) return; + + // Hintergrund: falls der Timer nicht feuert, haelt der Standort die Pruefung am Laufen + if (!self.lastCheck || [self.lastCheck timeIntervalSinceNow] < -kCheckInterval) [self checkNow]; + + // Backup fuers Aussteigen: GPS-Naehe zum Ziel des laufenden Abschnitts + NSArray *legs = [self legs]; + NSInteger idx = [self currentLegIndex]; + if (idx >= (NSInteger)legs.count) return; + NSDictionary *leg = legs[idx]; + if ([GuidanceManager isWalk:leg] || [self.exitAlertedLegs containsObject:@(idx)]) return; + + NSDate *dep = [GuidanceManager legDeparture:leg]; + if (!dep || [dep timeIntervalSinceNow] > 0) return; // noch nicht eingestiegen + + NSDictionary *loc = NN(NN(NN(leg[@"destination"])[@"location"]) ?: nil); + if (![loc isKindOfClass:[NSDictionary class]]) return; + CLLocation *target = [[CLLocation alloc] initWithLatitude:[loc[@"latitude"] doubleValue] + longitude:[loc[@"longitude"] doubleValue]]; + if ([self.lastLocation distanceFromLocation:target] > kExitRadius) return; + + [self.exitAlertedLegs addObject:@(idx)]; + [self notifyNow:[NSString stringWithFormat:@"🔔 Gleich da: %@ – bitte aussteigen!", + NN(NN(leg[@"destination"])[@"name"]) ?: @"Ziel"] kind:@"guidance"]; + [self scheduleGuidanceNotifications]; // geplante Aussteige-Meldung fuer diesen Abschnitt entfernen +} + +- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error { + NSLog(@"Guidance-Location: %@", error); +} + +@end diff --git a/BVGNearby/GuidanceViewController.h b/BVGNearby/GuidanceViewController.h new file mode 100644 index 0000000..9127ac7 --- /dev/null +++ b/BVGNearby/GuidanceViewController.h @@ -0,0 +1,4 @@ +#import "CardTableViewController.h" + +@interface GuidanceViewController : CardTableViewController +@end diff --git a/BVGNearby/GuidanceViewController.m b/BVGNearby/GuidanceViewController.m new file mode 100644 index 0000000..8e332c7 --- /dev/null +++ b/BVGNearby/GuidanceViewController.m @@ -0,0 +1,305 @@ +#import "GuidanceViewController.h" +#import "GuidanceManager.h" +#import "TripViewController.h" +#import "BVGAPI.h" + +typedef NS_ENUM(NSInteger, GSection) { GSectionStatus, GSectionAlternative, GSectionSteps, GSectionEnd }; + +@interface GuidanceViewController () +@property (nonatomic, strong) NSArray *sections; +@property (nonatomic, strong) NSTimer *timer; +@end + +@implementation GuidanceViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.navigationItem.title = @"Navigation"; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 48; + + UIBarButtonItem *check = [[UIBarButtonItem alloc] initWithTitle:@"Prüfen" style:UIBarButtonItemStylePlain + target:self action:@selector(check)]; + UIBarButtonItem *speak = [[UIBarButtonItem alloc] initWithTitle:@"Ansage" style:UIBarButtonItemStylePlain + target:self action:@selector(speak)]; + self.navigationItem.rightBarButtonItems = @[check, speak]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(reload) + name:GuidanceChangedNotification object:nil]; + [self reload]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + self.timer = [NSTimer scheduledTimerWithTimeInterval:15 target:self selector:@selector(reload) userInfo:nil repeats:YES]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [self.timer invalidate]; + self.timer = nil; +} + +- (void)check { + [[GuidanceManager shared] checkNow]; +} + +- (void)speak { + if ([BVGAPI isSpeaking]) { [BVGAPI stopSpeaking]; return; } + GuidanceManager *g = [GuidanceManager shared]; + NSMutableString *s = [NSMutableString stringWithString:[g currentInstruction]]; + if (g.problemText) [s appendFormat:@" Achtung: %@", g.problemText]; + if (g.alternative) [s appendString:@" Es gibt eine Alternative."]; + [BVGAPI speak:s]; +} + +// "Bin um 14:32 an der Seestraße (U8, S41)" per SMS/iMessage/Mail +- (void)shareArrival { + NSArray *legs = [self legs]; + NSDictionary *last = legs.lastObject; + NSDate *arr = [GuidanceManager legArrival:last]; + NSString *dest = NN(NN(last[@"destination"])[@"name"]) ?: @"am Ziel"; + NSMutableArray *lines = [NSMutableArray array]; + for (NSDictionary *l in legs) if (![GuidanceManager isWalk:l]) [lines addObject:NN(NN(l[@"line"])[@"name"]) ?: @"?"]; + NSInteger delay = [NN(last[@"arrivalDelay"]) integerValue] / 60; + + NSMutableString *text = [NSMutableString stringWithFormat:@"Bin voraussichtlich um %@ Uhr an %@", [BVGAPI hhmm:arr], dest]; + if (lines.count) [text appendFormat:@" (mit %@)", [lines componentsJoinedByString:@" → "]]; + if (delay > 0) [text appendFormat:@", aktuell %ld min später als geplant", (long)delay]; + [text appendString:@"."]; + + UIActivityViewController *share = [[UIActivityViewController alloc] initWithActivityItems:@[text] applicationActivities:nil]; + share.popoverPresentationController.sourceView = self.view; + [self presentViewController:share animated:YES completion:nil]; +} + +- (void)reload { + GuidanceManager *g = [GuidanceManager shared]; + if (!g.active) { + self.sections = @[]; + [self showEmptyIcon:@"🧭" title:@"Keine Navigation" + text:@"Plane eine Route und tippe in der Verbindung auf „Navigation starten“."]; + } else { + [self showEmptyIcon:nil title:nil text:nil]; + NSMutableArray *s = [NSMutableArray arrayWithObject:@(GSectionStatus)]; + if (g.alternative) [s addObject:@(GSectionAlternative)]; + [s addObject:@(GSectionSteps)]; + [s addObject:@(GSectionEnd)]; + self.sections = s; + } + [self.tableView reloadData]; +} + +- (NSArray *)legs { + id legs = NN([GuidanceManager shared].journey[@"legs"]); + return [legs isKindOfClass:[NSArray class]] ? legs : @[]; +} + +- (NSArray *)altLegs { + id legs = NN([GuidanceManager shared].alternative[@"legs"]); + return [legs isKindOfClass:[NSArray class]] ? legs : @[]; +} + +#pragma mark - Table + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + return self.sections.count; +} + +- (GSection)kind:(NSInteger)section { + return (GSection)[self.sections[section] integerValue]; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { + switch ([self kind:section]) { + case GSectionAlternative: return @"Alternative – du entscheidest"; + case GSectionSteps: return @"Deine Route"; + default: return nil; + } +} + +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section { + if ([self kind:section] != GSectionStatus) return nil; + GuidanceManager *g = [GuidanceManager shared]; + if (g.checking) return @"Prüfe Echtzeitdaten…"; + return [NSString stringWithFormat:@"Geprüft %@ · automatisch jede Minute. Erinnerungen kommen auch bei gesperrtem Gerät.", + [BVGAPI hhmm:g.lastCheck]]; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + switch ([self kind:section]) { + case GSectionStatus: return 3; + case GSectionAlternative: return 3; + case GSectionSteps: return [self legs].count; + case GSectionEnd: return 2; // Teilen + Beenden + } + return 0; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + CardCell *cell = [[CardCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:nil]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.textLabel.numberOfLines = 0; + cell.detailTextLabel.numberOfLines = 0; + GuidanceManager *g = [GuidanceManager shared]; + NSArray *legs = [self legs]; + + switch ([self kind:indexPath.section]) { + case GSectionStatus: { + if (indexPath.row == 0) { + cell.textLabel.text = [g currentInstruction]; + cell.textLabel.font = [BVGTheme font:20 weight:UIFontWeightSemibold]; + cell.cardColor = [BVGTheme accent]; + } else if (indexPath.row == 1) { + switch (g.status) { + case GuidanceStatusOK: + cell.textLabel.text = @"✓ Alle Anschlüsse erreichbar"; + cell.textLabel.textColor = [BVGTheme green]; + break; + case GuidanceStatusAtRisk: + cell.textLabel.text = [@"⚠ " stringByAppendingString:g.problemText ?: @"Umstieg knapp"]; + cell.textLabel.textColor = [BVGTheme orange]; + break; + case GuidanceStatusBroken: + cell.textLabel.text = [@"✕ " stringByAppendingString:g.problemText ?: @"Route nicht mehr möglich"]; + cell.textLabel.textColor = [BVGTheme red]; + break; + case GuidanceStatusDone: + cell.textLabel.text = @"🎉 Ziel erreicht"; + break; + } + cell.textLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold]; + } else { + NSDictionary *last = legs.lastObject; + NSDate *arr = [GuidanceManager legArrival:last]; + NSInteger delay = [NN(last[@"arrivalDelay"]) integerValue] / 60; + cell.textLabel.text = [NSString stringWithFormat:@"Ankunft %@%@", [BVGAPI hhmm:arr], + delay > 0 ? [NSString stringWithFormat:@" (+%ld)", (long)delay] : @""]; + cell.detailTextLabel.text = NN(NN(last[@"destination"])[@"name"]); + cell.textLabel.font = [BVGTheme digits:17 weight:UIFontWeightMedium]; + } + return cell; + } + + case GSectionAlternative: { + NSArray *alt = [self altLegs]; + if (indexPath.row == 0) { + NSMutableArray *lines = [NSMutableArray array]; + for (NSDictionary *l in alt) if (![GuidanceManager isWalk:l]) [lines addObject:l[@"line"]]; + cell.imageView.image = [BVGAPI badgeStripForLines:lines]; + NSDate *dep = [GuidanceManager legDeparture:alt.firstObject]; + NSDate *arr = [GuidanceManager legArrival:alt.lastObject]; + NSDate *oldArr = [GuidanceManager legArrival:legs.lastObject]; + NSInteger diff = (arr && oldArr) ? (NSInteger)round([arr timeIntervalSinceDate:oldArr] / 60.0) : 0; + cell.textLabel.text = [NSString stringWithFormat:@"%@ → %@", [BVGAPI hhmm:dep], [BVGAPI hhmm:arr]]; + cell.textLabel.font = [BVGTheme digits:17 weight:UIFontWeightSemibold]; + cell.detailTextLabel.text = diff > 0 ? [NSString stringWithFormat:@"%ld min später als geplant", (long)diff] + : @"Ankunft wie geplant oder früher"; + } else if (indexPath.row == 1) { + cell.textLabel.text = @"✓ Alternative übernehmen"; + cell.textLabel.textColor = [BVGTheme green]; + cell.textLabel.font = [BVGTheme font:17 weight:UIFontWeightSemibold]; + cell.textLabel.textAlignment = NSTextAlignmentCenter; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + } else { + cell.textLabel.text = @"Aktuelle Route behalten"; + cell.textLabel.textColor = [BVGTheme textSecondary]; + cell.textLabel.textAlignment = NSTextAlignmentCenter; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + } + return cell; + } + + case GSectionSteps: { + NSDictionary *leg = legs[indexPath.row]; + NSInteger current = [g currentLegIndex]; + BOOL done = indexPath.row < current; + NSDate *dep = [GuidanceManager legDeparture:leg]; + NSDate *arr = [GuidanceManager legArrival:leg]; + NSString *origin = NN(NN(leg[@"origin"])[@"name"]) ?: @""; + NSString *dest = NN(NN(leg[@"destination"])[@"name"]) ?: @""; + + if ([GuidanceManager isWalk:leg]) { + NSInteger mins = (dep && arr) ? (NSInteger)round([arr timeIntervalSinceDate:dep] / 60.0) : 0; + cell.textLabel.text = [NSString stringWithFormat:@"🚶 %ld min zu Fuß", (long)mins]; + cell.detailTextLabel.text = [@"bis " stringByAppendingString:dest]; + } else { + cell.imageView.image = [BVGAPI badgeForLine:leg[@"line"]]; + NSInteger dd = [NN(leg[@"departureDelay"]) integerValue] / 60; + cell.textLabel.text = [NSString stringWithFormat:@"%@%@ %@ → %@", [BVGAPI hhmm:dep], + dd > 0 ? [NSString stringWithFormat:@" (+%ld)", (long)dd] : @"", + origin, NN(leg[@"direction"]) ?: @""]; + NSString *dp = NN(leg[@"departurePlatform"]), *ap = NN(leg[@"arrivalPlatform"]); + cell.detailTextLabel.text = [NSString stringWithFormat:@"%@%@ aussteigen: %@ um %@%@", + dp ? [NSString stringWithFormat:@"Gl. %@ · ", dp] : @"", + [NN(leg[@"cancelled"]) boolValue] ? @"FÄLLT AUS · " : @"", + dest, [BVGAPI hhmm:arr], ap ? [NSString stringWithFormat:@" (Gl. %@)", ap] : @""]; + cell.accessoryType = NN(leg[@"tripId"]) ? UITableViewCellAccessoryDisclosureIndicator : UITableViewCellAccessoryNone; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + } + cell.textLabel.font = indexPath.row == current ? [BVGTheme font:15 weight:UIFontWeightSemibold] : [BVGTheme font:15 weight:UIFontWeightRegular]; + cell.textLabel.textColor = done ? [BVGTheme textTertiary] : [BVGTheme textPrimary]; + cell.detailTextLabel.font = [BVGTheme font:13 weight:UIFontWeightRegular]; + cell.detailTextLabel.textColor = [NN(leg[@"cancelled"]) boolValue] ? [BVGTheme red] : [BVGTheme textSecondary]; + if (indexPath.row == current) cell.cardColor = [BVGTheme accentSoft]; + return cell; + } + + case GSectionEnd: { + if (indexPath.row == 0) { + cell.textLabel.text = @"Ankunftszeit teilen"; + cell.textLabel.textColor = [BVGTheme link]; + cell.textLabel.textAlignment = NSTextAlignmentCenter; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + return cell; + } + cell.textLabel.text = @"Navigation beenden"; + cell.textLabel.textColor = [BVGTheme red]; + cell.textLabel.textAlignment = NSTextAlignmentCenter; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + return cell; + } + } + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + GuidanceManager *g = [GuidanceManager shared]; + + switch ([self kind:indexPath.section]) { + case GSectionAlternative: + if (indexPath.row == 1) [g acceptAlternative]; + if (indexPath.row == 2) [g dismissAlternative]; + break; + case GSectionSteps: { + NSDictionary *leg = [self legs][indexPath.row]; + if ([GuidanceManager isWalk:leg] || !NN(leg[@"tripId"])) break; + TripViewController *trip = [[TripViewController alloc] initWithTripId:leg[@"tripId"]]; + trip.line = leg[@"line"]; + trip.stopName = NN(NN(leg[@"destination"])[@"name"]); + [self.navigationController pushViewController:trip animated:YES]; + break; + } + case GSectionEnd: { + if (indexPath.row == 0) { [self shareArrival]; break; } + UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Navigation beenden?" message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + [a addAction:[UIAlertAction actionWithTitle:@"Beenden" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *x) { + [[GuidanceManager shared] stop]; + }]]; + [a addAction:[UIAlertAction actionWithTitle:@"Abbrechen" style:UIAlertActionStyleCancel handler:nil]]; + a.popoverPresentationController.sourceView = self.view; + [self presentViewController:a animated:YES completion:nil]; + break; + } + default: + break; + } +} + +@end diff --git a/BVGNearby/Info.plist b/BVGNearby/Info.plist index 81ed29b..fce536b 100644 --- a/BVGNearby/Info.plist +++ b/BVGNearby/Info.plist @@ -2,24 +2,7 @@ - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - SceneDelegate - UISceneStoryboardFile - Main - - - - + NSLocationWhenInUseUsageDescription + Zeigt Abfahrten an Haltestellen in deiner Nähe. diff --git a/BVGNearby/JourneyDetailViewController.h b/BVGNearby/JourneyDetailViewController.h new file mode 100644 index 0000000..ba30cba --- /dev/null +++ b/BVGNearby/JourneyDetailViewController.h @@ -0,0 +1,5 @@ +#import "CardTableViewController.h" + +@interface JourneyDetailViewController : CardTableViewController +- (instancetype)initWithJourney:(NSDictionary *)journey; +@end diff --git a/BVGNearby/JourneyDetailViewController.m b/BVGNearby/JourneyDetailViewController.m new file mode 100644 index 0000000..e2dfcc8 --- /dev/null +++ b/BVGNearby/JourneyDetailViewController.m @@ -0,0 +1,185 @@ +#import "JourneyDetailViewController.h" +#import "TripViewController.h" +#import "GuidanceManager.h" +#import "GuidanceViewController.h" +#import "BVGAPI.h" + +@interface JourneyDetailViewController () +@property (nonatomic, strong) NSDictionary *journey; +@property (nonatomic, strong) NSArray *legs; +@end + +@implementation JourneyDetailViewController + +- (instancetype)initWithJourney:(NSDictionary *)journey { + self = [super initWithStyle:UITableViewStyleGrouped]; + if (self) { + _journey = journey; + id legs = NN(journey[@"legs"]); + _legs = [legs isKindOfClass:[NSArray class]] ? legs : @[]; + } + return self; +} + ++ (BOOL)isWalk:(NSDictionary *)leg { return [GuidanceManager isWalk:leg]; } ++ (NSDate *)dep:(NSDictionary *)leg { return [GuidanceManager legDeparture:leg]; } ++ (NSDate *)arr:(NSDictionary *)leg { return [GuidanceManager legArrival:leg]; } + +- (void)viewDidLoad { + [super viewDidLoad]; + self.navigationItem.title = @"Verbindung"; + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Wecker" + style:UIBarButtonItemStylePlain + target:self action:@selector(setAlarm)]; + + // Kopf: Zeiten gross, darunter Dauer + Umstiege + NSDate *dep = [JourneyDetailViewController dep:self.legs.firstObject]; + NSDate *arr = [JourneyDetailViewController arr:self.legs.lastObject]; + NSInteger mins = (dep && arr) ? (NSInteger)round([arr timeIntervalSinceDate:dep] / 60.0) : 0; + NSInteger transit = 0; + for (NSDictionary *l in self.legs) if (![JourneyDetailViewController isWalk:l]) transit++; + self.largeTitle = [NSString stringWithFormat:@"%@ – %@", [BVGAPI hhmm:dep], [BVGAPI hhmm:arr]]; + self.largeSubtitle = [NSString stringWithFormat:@"%ld min · %@", (long)mins, + transit <= 1 ? @"ohne Umstieg" : [NSString stringWithFormat:@"%ld× umsteigen", (long)(transit - 1)]]; + + // grosser Start-Knopf + CGFloat w = self.view.bounds.size.width; + UIView *footer = [[UIView alloc] initWithFrame:CGRectMake(0, 0, w, 96)]; + UIButton *start = [UIButton buttonWithType:UIButtonTypeCustom]; + start.frame = CGRectMake([BVGTheme cardInset], 20, w - 2 * [BVGTheme cardInset], 54); + start.autoresizingMask = UIViewAutoresizingFlexibleWidth; + start.backgroundColor = [BVGTheme accent]; + start.layer.cornerRadius = 14; + start.titleLabel.font = [BVGTheme font:18 weight:UIFontWeightSemibold]; + [start setTitle:@"Navigation starten" forState:UIControlStateNormal]; + [start setTitleColor:[BVGTheme textPrimary] forState:UIControlStateNormal]; + [start setTitleColor:[BVGTheme textSecondary] forState:UIControlStateHighlighted]; + [start addTarget:self action:@selector(startGuidance) forControlEvents:UIControlEventTouchUpInside]; + [footer addSubview:start]; + self.tableView.tableFooterView = footer; +} + +- (void)startGuidance { + void (^go)(void) = ^{ + [[GuidanceManager shared] startWithJourney:self.journey]; + GuidanceViewController *vc = [[GuidanceViewController alloc] initWithStyle:UITableViewStyleGrouped]; + [self.navigationController pushViewController:vc animated:YES]; + }; + if (![GuidanceManager shared].active) { go(); return; } + + UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Navigation läuft bereits" + message:@"Die laufende Navigation durch diese Verbindung ersetzen?" + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:@"Ersetzen" style:UIAlertActionStyleDefault handler:^(UIAlertAction *x) { go(); }]]; + [a addAction:[UIAlertAction actionWithTitle:@"Abbrechen" style:UIAlertActionStyleCancel handler:nil]]; + [self presentViewController:a animated:YES completion:nil]; +} + +// Wecker fuer das erste Verkehrsmittel der Verbindung +- (void)setAlarm { + for (NSDictionary *leg in self.legs) { + if ([JourneyDetailViewController isWalk:leg] || !NN(leg[@"tripId"])) continue; + NSDate *dep = [JourneyDetailViewController dep:leg]; + if (!dep) break; + NSDictionary *pseudoDeparture = @{ @"tripId": leg[@"tripId"], + @"line": leg[@"line"], + @"direction": NN(leg[@"direction"]) ?: @"", + @"when": [BVGAPI isoFromDate:dep] }; + [BVGAPI presentAlarmSheetForDeparture:pseudoDeparture stopName:NN(NN(leg[@"origin"])[@"name"]) from:self]; + return; + } +} + +#pragma mark - Table (ein Abschnitt pro Teilstrecke) + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + return self.legs.count; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { + // Umstiegszeit zwischen zwei Fahrten als Abschnittstitel + if (section == 0 || [JourneyDetailViewController isWalk:self.legs[section]]) return nil; + NSInteger prev = section - 1; + while (prev >= 0 && [JourneyDetailViewController isWalk:self.legs[prev]]) prev--; + if (prev < 0) return nil; + NSDate *a = [JourneyDetailViewController arr:self.legs[prev]]; + NSDate *d = [JourneyDetailViewController dep:self.legs[section]]; + if (!a || !d) return nil; + return [NSString stringWithFormat:@"Umstieg · %ld min Zeit", (long)round([d timeIntervalSinceDate:a] / 60.0)]; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return [JourneyDetailViewController isWalk:self.legs[section]] ? 1 : 3; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { + if ([JourneyDetailViewController isWalk:self.legs[indexPath.section]]) return 48; + return indexPath.row == 0 ? 56 : 52; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + CardCell *cell = [[CardCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:nil]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + NSDictionary *leg = self.legs[indexPath.section]; + + if ([JourneyDetailViewController isWalk:leg]) { + NSDate *d = [JourneyDetailViewController dep:leg], *a = [JourneyDetailViewController arr:leg]; + NSInteger mins = (d && a) ? MAX(1, (NSInteger)round([a timeIntervalSinceDate:d] / 60.0)) : 1; + NSNumber *dist = NN(leg[@"distance"]); + cell.textLabel.text = [NSString stringWithFormat:@"🚶 %ld min zu Fuß%@", (long)mins, + dist ? [NSString stringWithFormat:@" · %@ m", dist] : @""]; + cell.textLabel.font = [BVGTheme font:15 weight:UIFontWeightMedium]; + cell.textLabel.textColor = [BVGTheme textSecondary]; + cell.cardColor = [BVGTheme background]; + return cell; + } + + if (indexPath.row == 0) { + cell.imageView.image = [BVGAPI badgeForLine:leg[@"line"]]; + cell.textLabel.text = NN(leg[@"direction"]) ?: @""; + cell.textLabel.font = [BVGTheme font:17 weight:UIFontWeightSemibold]; + cell.detailTextLabel.text = [NN(leg[@"cancelled"]) boolValue] ? @"Fällt aus" : @"Fahrtverlauf ansehen"; + cell.detailTextLabel.textColor = [NN(leg[@"cancelled"]) boolValue] ? [BVGTheme red] : [BVGTheme link]; + cell.detailTextLabel.font = [BVGTheme font:13 weight:UIFontWeightMedium]; + if (NN(leg[@"tripId"])) { + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + } + return cell; + } + + BOOL isDep = indexPath.row == 1; + NSDictionary *place = NN(leg[isDep ? @"origin" : @"destination"]); + NSDate *t = isDep ? [JourneyDetailViewController dep:leg] : [JourneyDetailViewController arr:leg]; + NSInteger delay = [NN(leg[isDep ? @"departureDelay" : @"arrivalDelay"]) integerValue] / 60; + NSString *platform = NN(leg[isDep ? @"departurePlatform" : @"arrivalPlatform"]); + + NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:[BVGAPI hhmm:t] + attributes:@{ NSFontAttributeName: [BVGTheme digits:17 weight:UIFontWeightSemibold], + NSForegroundColorAttributeName: delay > 0 ? [BVGTheme red] : [BVGTheme textPrimary] }]; + [title appendAttributedString:[[NSAttributedString alloc] initWithString:[@" " stringByAppendingString:NN(place[@"name"]) ?: @"?"] + attributes:@{ NSFontAttributeName: [BVGTheme font:16 weight:UIFontWeightRegular], + NSForegroundColorAttributeName: [BVGTheme textPrimary] }]]; + cell.textLabel.attributedText = title; + + NSMutableArray *sub = [NSMutableArray arrayWithObject:isDep ? @"Einsteigen" : @"Aussteigen"]; + if (platform) [sub addObject:[NSString stringWithFormat:@"Gleis %@", platform]]; + if (delay > 0) [sub addObject:[NSString stringWithFormat:@"+%ld min", (long)delay]]; + cell.detailTextLabel.text = [sub componentsJoinedByString:@" · "]; + cell.detailTextLabel.font = [BVGTheme font:13 weight:UIFontWeightRegular]; + cell.imageView.image = [BVGAPI dotWithColor:[BVGAPI colorForLine:leg[@"line"]] filled:isDep]; + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + NSDictionary *leg = self.legs[indexPath.section]; + if ([JourneyDetailViewController isWalk:leg] || indexPath.row != 0 || !NN(leg[@"tripId"])) return; + + TripViewController *trip = [[TripViewController alloc] initWithTripId:leg[@"tripId"]]; + trip.line = leg[@"line"]; + trip.stopName = NN(NN(leg[@"origin"])[@"name"]); + [self.navigationController pushViewController:trip animated:YES]; +} + +@end diff --git a/BVGNearby/JourneyViewController.h b/BVGNearby/JourneyViewController.h new file mode 100644 index 0000000..59e7555 --- /dev/null +++ b/BVGNearby/JourneyViewController.h @@ -0,0 +1,4 @@ +#import "CardTableViewController.h" + +@interface JourneyViewController : CardTableViewController +@end diff --git a/BVGNearby/JourneyViewController.m b/BVGNearby/JourneyViewController.m new file mode 100644 index 0000000..7ffb888 --- /dev/null +++ b/BVGNearby/JourneyViewController.m @@ -0,0 +1,493 @@ +#import "JourneyViewController.h" +#import "JourneyDetailViewController.h" +#import "SearchViewController.h" +#import "GuidanceManager.h" +#import "GuidanceViewController.h" +#import "BVGAPI.h" +#import "BVGSettings.h" +#import "ChipBar.h" +#import + +#pragma mark - Zelle fuer eine Verbindung + +@interface JourneyCell : CardCell +@property (nonatomic, strong) UILabel *timeLabel; +@property (nonatomic, strong) UILabel *durationLabel; +@property (nonatomic, strong) UIImageView *linesView; +@property (nonatomic, strong) UILabel *infoLabel; +@end + +@implementation JourneyCell + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { + self = [super initWithStyle:UITableViewCellStyleDefault reuseIdentifier:reuseIdentifier]; + if (self) { + _timeLabel = [[UILabel alloc] init]; + _timeLabel.font = [BVGTheme digits:20 weight:UIFontWeightSemibold]; + _timeLabel.textColor = [BVGTheme textPrimary]; + [self.contentView addSubview:_timeLabel]; + + _durationLabel = [[UILabel alloc] init]; + _durationLabel.font = [BVGTheme digits:15 weight:UIFontWeightSemibold]; + _durationLabel.textColor = [BVGTheme textSecondary]; + _durationLabel.textAlignment = NSTextAlignmentRight; + [self.contentView addSubview:_durationLabel]; + + _linesView = [[UIImageView alloc] init]; + _linesView.contentMode = UIViewContentModeLeft; + [self.contentView addSubview:_linesView]; + + _infoLabel = [[UILabel alloc] init]; + _infoLabel.font = [BVGTheme font:13 weight:UIFontWeightRegular]; + _infoLabel.textColor = [BVGTheme textSecondary]; + [self.contentView addSubview:_infoLabel]; + + self.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + } + return self; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + CGFloat w = self.contentView.bounds.size.width; + self.timeLabel.frame = CGRectMake(16, 10, w - 110, 24); + self.durationLabel.frame = CGRectMake(w - 96, 10, 84, 24); + self.linesView.frame = CGRectMake(16, 38, w - 28, 24); + self.infoLabel.frame = CGRectMake(16, 66, w - 28, 17); +} + +@end + +#pragma mark - Routenplaner + +@interface JourneyViewController () +@property (nonatomic, strong) NSDictionary *from; // nil = Mein Standort, sonst {id, name} +@property (nonatomic, strong) NSDictionary *to; +@property (nonatomic, strong) ChipBar *timeControl; // Jetzt / Abfahrt um / Ankunft um +@property (nonatomic, strong) UIDatePicker *datePicker; +@property (nonatomic, strong) NSArray *journeys; +@property (nonatomic, strong) CLLocationManager *locationManager; +@property (nonatomic, assign) BOOL searching; +@property (nonatomic, copy) NSString *errorText; +@end + +@implementation JourneyViewController + +- (instancetype)initWithStyle:(UITableViewStyle)style { + return [super initWithStyle:UITableViewStyleGrouped]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.largeTitle = @"Route"; + self.largeSubtitle = [NSString stringWithFormat:@"Umstiege mit mind. %ld min Puffer", (long)[BVGSettings transferMinutes]]; + self.journeys = @[]; + + __weak JourneyViewController *weakSelf = self; + self.timeControl = [[ChipBar alloc] initWithItems:@[@"Jetzt", @"Abfahrt um", @"Ankunft um"]]; + self.timeControl.idleColor = [BVGTheme background]; + self.timeControl.onChange = ^(NSInteger idx) { [weakSelf timeModeChanged]; }; + + self.datePicker = [[UIDatePicker alloc] init]; + self.datePicker.datePickerMode = UIDatePickerModeDateAndTime; + self.datePicker.locale = [NSLocale localeWithLocaleIdentifier:@"de_DE"]; + self.datePicker.minuteInterval = 1; + self.datePicker.date = [NSDate dateWithTimeIntervalSinceNow:15 * 60]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateNaviButton) + name:GuidanceChangedNotification object:nil]; + [self updateNaviButton]; + [self buildQuickPlaces]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(settingsChanged) + name:BVGSettingsChangedNotification object:nil]; + + self.locationManager = [[CLLocationManager alloc] init]; + self.locationManager.delegate = self; + self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters; + + self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"⇅" + style:UIBarButtonItemStylePlain + target:self action:@selector(swap)]; +} + +#pragma mark - Schnellziele + +- (void)settingsChanged { + self.largeSubtitle = [NSString stringWithFormat:@"Umstiege mit mind. %ld min Puffer", (long)[BVGSettings transferMinutes]]; + [self buildQuickPlaces]; +} + +- (void)buildQuickPlaces { + CGFloat w = self.view.bounds.size.width; + CGFloat inset = [BVGTheme cardInset]; + CGFloat bw = (w - 2 * inset - 10) / 2; + UIView *v = [[UIView alloc] initWithFrame:CGRectMake(0, 0, w, 74)]; + + NSArray *defs = @[@[@"home", @"🏠", @"Nach Hause"], @[@"work", @"💼", @"Zur Arbeit"]]; + for (NSUInteger i = 0; i < defs.count; i++) { + NSDictionary *place = [BVGSettings placeForKey:defs[i][0]]; + UIButton *b = [UIButton buttonWithType:UIButtonTypeCustom]; + b.frame = CGRectMake(inset + i * (bw + 10), 4, bw, 64); + b.autoresizingMask = i == 0 ? UIViewAutoresizingFlexibleRightMargin : UIViewAutoresizingFlexibleLeftMargin; + b.backgroundColor = [BVGTheme card]; + b.layer.cornerRadius = [BVGTheme cardRadius]; + b.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft; + b.contentEdgeInsets = UIEdgeInsetsMake(0, 14, 0, 10); + b.titleLabel.numberOfLines = 2; + b.titleLabel.lineBreakMode = NSLineBreakByTruncatingTail; + b.tag = i; + + NSMutableAttributedString *t = [[NSMutableAttributedString alloc] + initWithString:[NSString stringWithFormat:@"%@ %@\n", defs[i][1], defs[i][2]] + attributes:@{ NSFontAttributeName: [BVGTheme font:16 weight:UIFontWeightSemibold], + NSForegroundColorAttributeName: [BVGTheme textPrimary] }]; + [t appendAttributedString:[[NSAttributedString alloc] initWithString:(place ? place[@"name"] : @"Festlegen …") + attributes:@{ NSFontAttributeName: [BVGTheme font:13 weight:UIFontWeightRegular], + NSForegroundColorAttributeName: place ? [BVGTheme textSecondary] : [BVGTheme link] }]]; + [b setAttributedTitle:t forState:UIControlStateNormal]; + [b addTarget:self action:@selector(quickPlaceTapped:) forControlEvents:UIControlEventTouchUpInside]; + [b addGestureRecognizer:[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(quickPlaceLongPress:)]]; + b.accessibilityHint = @"Gedrückt halten zum Ändern"; + [v addSubview:b]; + } + self.headerAccessory = v; +} + +- (NSString *)quickKey:(NSInteger)idx { + return idx == 0 ? @"home" : @"work"; +} + +- (void)quickPlaceTapped:(UIButton *)b { + NSDictionary *place = [BVGSettings placeForKey:[self quickKey:b.tag]]; + if (!place) { [self editQuickPlace:b.tag]; return; } + self.from = nil; // ab meinem Standort + self.to = place; + self.timeControl.selectedIndex = 0; + [self.tableView reloadData]; + [self startSearch]; +} + +- (void)quickPlaceLongPress:(UILongPressGestureRecognizer *)g { + if (g.state == UIGestureRecognizerStateBegan) [self editQuickPlace:g.view.tag]; +} + +- (void)editQuickPlace:(NSInteger)idx { + NSString *key = [self quickKey:idx]; + __weak JourneyViewController *weakSelf = self; + SearchViewController *search = [[SearchViewController alloc] initWithStyle:UITableViewStylePlain]; + search.allowAddresses = YES; + search.pickHandler = ^(NSDictionary *place) { + [BVGSettings setPlace:place forKey:key]; + [weakSelf buildQuickPlaces]; + }; + [self.navigationController pushViewController:search animated:YES]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)updateNaviButton { + self.navigationItem.rightBarButtonItem = [GuidanceManager shared].active + ? [[UIBarButtonItem alloc] initWithTitle:@"Navigation" style:UIBarButtonItemStyleDone target:self action:@selector(openGuidance)] + : nil; +} + +- (void)openGuidance { + [self.navigationController pushViewController:[[GuidanceViewController alloc] initWithStyle:UITableViewStyleGrouped] + animated:YES]; +} + +- (void)timeModeChanged { + if (self.timeControl.selectedIndex > 0 && [self.datePicker.date timeIntervalSinceNow] < -3600) { + self.datePicker.date = [NSDate dateWithTimeIntervalSinceNow:15 * 60]; + } + [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:1] withRowAnimation:UITableViewRowAnimationFade]; +} + +- (void)swap { + if (!self.to) return; + NSDictionary *oldFrom = self.from; + self.from = self.to; + self.to = oldFrom; // nil (Mein Standort) als Ziel ist nicht erlaubt -> unten abgefangen + if (!self.to) { + self.to = self.from; + self.from = nil; + } + self.journeys = @[]; + [self.tableView reloadData]; +} + +#pragma mark - Auswahl + +- (void)chooseEndpoint:(BOOL)isFrom { + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:(isFrom ? @"Start" : @"Ziel") + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + __weak JourneyViewController *weakSelf = self; + if (isFrom) { + [sheet addAction:[UIAlertAction actionWithTitle:@"📍 Mein Standort" style:UIAlertActionStyleDefault + handler:^(UIAlertAction *a) { weakSelf.from = nil; [weakSelf.tableView reloadData]; }]]; + } + for (NSArray *pl in @[@[@"home", @"🏠 "], @[@"work", @"💼 "]]) { + NSDictionary *place = [BVGSettings placeForKey:pl[0]]; + if (!place) continue; + [sheet addAction:[UIAlertAction actionWithTitle:[pl[1] stringByAppendingString:place[@"name"]] style:UIAlertActionStyleDefault + handler:^(UIAlertAction *a) { [weakSelf setEndpoint:place isFrom:isFrom]; }]]; + } + NSInteger n = 0; + for (NSDictionary *f in [BVGAPI favorites]) { + if (n++ >= 4) break; + [sheet addAction:[UIAlertAction actionWithTitle:[@"★ " stringByAppendingString:f[@"name"]] style:UIAlertActionStyleDefault + handler:^(UIAlertAction *a) { [weakSelf setEndpoint:f isFrom:isFrom]; }]]; + } + [sheet addAction:[UIAlertAction actionWithTitle:@"🔍 Haltestelle oder Adresse suchen …" style:UIAlertActionStyleDefault + handler:^(UIAlertAction *a) { + SearchViewController *search = [[SearchViewController alloc] initWithStyle:UITableViewStylePlain]; + search.allowAddresses = YES; + search.pickHandler = ^(NSDictionary *stop) { [weakSelf setEndpoint:stop isFrom:isFrom]; }; + [weakSelf.navigationController pushViewController:search animated:YES]; + }]]; + [sheet addAction:[UIAlertAction actionWithTitle:@"Abbrechen" style:UIAlertActionStyleCancel handler:nil]]; + sheet.popoverPresentationController.sourceView = self.view; + [self presentViewController:sheet animated:YES completion:nil]; +} + +- (void)setEndpoint:(NSDictionary *)stop isFrom:(BOOL)isFrom { + if (isFrom) self.from = stop; else self.to = stop; + self.journeys = @[]; + self.errorText = nil; + [self.tableView reloadData]; +} + +#pragma mark - Suche + ++ (NSString *)param:(NSString *)prefix place:(NSDictionary *)place { + if (place[@"id"]) return [NSString stringWithFormat:@"%@=%@", prefix, [BVGAPI encode:place[@"id"]]]; + return [NSString stringWithFormat:@"%@.latitude=%f&%@.longitude=%f&%@.address=%@", + prefix, [place[@"latitude"] doubleValue], prefix, [place[@"longitude"] doubleValue], + prefix, [BVGAPI encode:place[@"name"] ?: @"Ort"]]; +} + +- (void)startSearch { + if (!self.to) { [self chooseEndpoint:NO]; return; } + if (self.searching) return; + self.searching = YES; + self.errorText = nil; + self.journeys = @[]; + [self.tableView reloadData]; + + if (self.from) { + [self queryWithFromParams:[JourneyViewController param:@"from" place:self.from]]; + return; + } + CLAuthorizationStatus st = [CLLocationManager authorizationStatus]; + if (st == kCLAuthorizationStatusNotDetermined) { + [self.locationManager requestWhenInUseAuthorization]; + } + [self.locationManager requestLocation]; +} + +- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations { + if (!self.searching) return; + CLLocationCoordinate2D c = locations.lastObject.coordinate; + [self queryWithFromParams:[NSString stringWithFormat:@"from.latitude=%.6f&from.longitude=%.6f&from.address=%@", + c.latitude, c.longitude, [BVGAPI encode:@"Mein Standort"]]]; +} + +- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error { + if (!self.searching) return; + self.searching = NO; + self.errorText = @"Standort nicht verfügbar. Wähle eine Start-Haltestelle."; + [self.tableView reloadData]; +} + +- (void)queryWithFromParams:(NSString *)fromParams { + // stopovers=true: noetig fuer die Aussteige-Erinnerung in der Navigation + NSMutableString *path = [NSMutableString stringWithFormat:@"/journeys?%@&%@&results=5&stopovers=true&remarks=true&transferTime=%ld", + fromParams, [JourneyViewController param:@"to" place:self.to], (long)[BVGSettings transferMinutes]]; + NSInteger mode = self.timeControl.selectedIndex; + if (mode > 0) { + [path appendFormat:@"&%@=%@", mode == 1 ? @"departure" : @"arrival", + [BVGAPI encode:[BVGAPI isoFromDate:self.datePicker.date]]]; + } + + [BVGAPI getJSON:path completion:^(id json, NSError *error) { + NSArray *list = nil; + if ([json isKindOfClass:[NSDictionary class]] && [json[@"journeys"] isKindOfClass:[NSArray class]]) list = json[@"journeys"]; + dispatch_async(dispatch_get_main_queue(), ^{ + self.searching = NO; + if (!list) { + NSLog(@"Journey-Fehler: %@ %@", error, json); + self.errorText = @"Keine Verbindung gefunden oder Server nicht erreichbar."; + } + // Sicherheitsnetz: nur Verbindungen mit mind. 5 min pro Umstieg zeigen + NSMutableArray *ok = [NSMutableArray array]; + for (NSDictionary *j in list ?: @[]) { + id legs = NN(j[@"legs"]); + if ([legs isKindOfClass:[NSArray class]] && [GuidanceManager minTransferSlack:legs] >= [BVGSettings transferMinutes] * 60) [ok addObject:j]; + } + if (list.count && !ok.count) self.errorText = [NSString stringWithFormat:@"Keine Verbindung mit mind. %ld min Umstiegszeit gefunden.", (long)[BVGSettings transferMinutes]]; + self.journeys = ok; + [self.tableView reloadData]; + }); + }]; +} + +#pragma mark - Auswertung einer Verbindung + ++ (NSDate *)legDeparture:(NSDictionary *)leg { + return [BVGAPI dateFromISO:leg[@"departure"]] ?: [BVGAPI dateFromISO:leg[@"plannedDeparture"]]; +} + ++ (NSDate *)legArrival:(NSDictionary *)leg { + return [BVGAPI dateFromISO:leg[@"arrival"]] ?: [BVGAPI dateFromISO:leg[@"plannedArrival"]]; +} + +- (void)configureCell:(JourneyCell *)cell journey:(NSDictionary *)j { + NSArray *legs = NN(j[@"legs"]); + if (![legs isKindOfClass:[NSArray class]] || !legs.count) return; + + NSDate *dep = [JourneyViewController legDeparture:legs.firstObject]; + NSDate *arr = [JourneyViewController legArrival:legs.lastObject]; + NSInteger mins = (dep && arr) ? (NSInteger)round([arr timeIntervalSinceDate:dep] / 60.0) : 0; + + NSMutableArray *lines = [NSMutableArray array]; + BOOL cancelled = NO; + NSInteger firstDelay = -1; + NSString *firstStop = nil; + NSDate *firstTransitDep = nil; + for (NSDictionary *leg in legs) { + if ([NN(leg[@"cancelled"]) boolValue]) cancelled = YES; + if ([NN(leg[@"walking"]) boolValue] || !NN(leg[@"line"])) continue; + [lines addObject:leg[@"line"]]; + if (firstDelay < 0) { + firstDelay = [NN(leg[@"departureDelay"]) integerValue] / 60; + firstStop = NN(NN(leg[@"origin"])[@"name"]); + firstTransitDep = [JourneyViewController legDeparture:leg]; + } + } + + cell.timeLabel.text = [NSString stringWithFormat:@"%@ → %@", [BVGAPI hhmm:dep], [BVGAPI hhmm:arr]]; + cell.durationLabel.text = [NSString stringWithFormat:@"%ld min", (long)mins]; + cell.linesView.image = [BVGAPI badgeStripForLines:lines]; + + NSMutableArray *info = [NSMutableArray array]; + NSInteger transfers = MAX((NSInteger)lines.count - 1, 0); + if (transfers == 0) { + [info addObject:@"direkt"]; + } else { + NSTimeInterval slack = [GuidanceManager minTransferSlack:legs]; + [info addObject:[NSString stringWithFormat:@"%ld× umsteigen (mind. %ld min)", (long)transfers, (long)floor(slack / 60.0)]]; + } + if (firstStop && firstTransitDep) { + NSInteger leave = (NSInteger)floor([firstTransitDep timeIntervalSinceNow] / 60.0); + [info addObject:leave > 0 ? [NSString stringWithFormat:@"in %ld min ab %@", (long)leave, firstStop] + : [NSString stringWithFormat:@"ab %@", firstStop]]; + } + if (firstDelay > 0) [info addObject:[NSString stringWithFormat:@"+%ld", (long)firstDelay]]; + if (cancelled) [info addObject:@"⚠ Ausfall"]; + + cell.infoLabel.text = [info componentsJoinedByString:@" · "]; + cell.infoLabel.textColor = (cancelled || firstDelay > 0) ? [BVGTheme red] : [BVGTheme textSecondary]; +} + +#pragma mark - Table + +// Abschnitte: 0 Von/Nach · 1 Zeit · 2 Suchen-Knopf · 3+ je eine Karte pro Verbindung +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + return 3 + self.journeys.count; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { + if (section == 1) return @"Wann?"; + if (section == 3) return @"Verbindungen"; + return nil; +} + +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section { + if (section != 2) return nil; + if (self.searching) return @"Suche Verbindungen …"; + return self.errorText; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + switch (section) { + case 0: return 2; + case 1: return self.timeControl.selectedIndex == 0 ? 1 : 2; + default: return 1; + } +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.section == 0) return 60; + if (indexPath.section == 1) return indexPath.row == 1 ? 180 : 56; + if (indexPath.section == 2) return 52; + return 94; +} + +// kleine runde Markierung: Start gruen gefuellt, Ziel als Ring ++ (UIImage *)endpointIcon:(BOOL)isFrom { + CGSize size = CGSizeMake(22, 22); + UIGraphicsBeginImageContextWithOptions(size, NO, 0); + UIBezierPath *p = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(4, 4, 14, 14)]; + if (isFrom) { [[BVGTheme green] setFill]; [p fill]; } + else { p.lineWidth = 4; [[BVGTheme red] setStroke]; [p stroke]; } + UIImage *img = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return img; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.section >= 3) { + JourneyCell *cell = [tableView dequeueReusableCellWithIdentifier:@"journey"]; + if (!cell) cell = [[JourneyCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"journey"]; + [self configureCell:cell journey:self.journeys[indexPath.section - 3]]; + return cell; + } + + CardCell *cell = [[CardCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:nil]; + if (indexPath.section == 0) { + BOOL isFrom = indexPath.row == 0; + cell.imageView.image = [JourneyViewController endpointIcon:isFrom]; + cell.textLabel.font = [BVGTheme font:17 weight:UIFontWeightSemibold]; + if (isFrom) cell.textLabel.text = self.from ? self.from[@"name"] : @"Mein Standort"; + else cell.textLabel.text = self.to ? self.to[@"name"] : @"Ziel wählen"; + if (!isFrom && !self.to) cell.textLabel.textColor = [BVGTheme link]; + cell.detailTextLabel.text = isFrom ? @"Start" : @"Ziel"; + cell.detailTextLabel.font = [BVGTheme font:13 weight:UIFontWeightRegular]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + } else if (indexPath.section == 1 && indexPath.row == 1) { + cell.selectionStyle = UITableViewCellSelectionStyleNone; + [self.datePicker removeFromSuperview]; + self.datePicker.frame = CGRectMake(0, 0, tableView.bounds.size.width - 2 * [BVGTheme cardInset], 180); + self.datePicker.autoresizingMask = UIViewAutoresizingFlexibleWidth; + [cell.contentView addSubview:self.datePicker]; + } else if (indexPath.section == 1) { + cell.selectionStyle = UITableViewCellSelectionStyleNone; + [self.timeControl removeFromSuperview]; + self.timeControl.frame = CGRectMake(-[BVGTheme cardInset] + 12, 6, tableView.bounds.size.width - 2 * [BVGTheme cardInset], 44); + [cell.contentView addSubview:self.timeControl]; + } else { + cell.textLabel.text = self.searching ? @"Suche …" : @"Verbindungen suchen"; + cell.textLabel.textAlignment = NSTextAlignmentCenter; + cell.textLabel.font = [BVGTheme font:17 weight:UIFontWeightSemibold]; + cell.textLabel.textColor = [BVGTheme textPrimary]; + cell.cardColor = [BVGTheme accent]; + } + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (indexPath.section == 0) { + [self chooseEndpoint:(indexPath.row == 0)]; + } else if (indexPath.section == 2) { + [self startSearch]; + } else if (indexPath.section >= 3) { + JourneyDetailViewController *vc = [[JourneyDetailViewController alloc] initWithJourney:self.journeys[indexPath.section - 3]]; + [self.navigationController pushViewController:vc animated:YES]; + } +} + +@end diff --git a/BVGNearby/LIESMICH.txt b/BVGNearby/LIESMICH.txt new file mode 100644 index 0000000..e30b91c --- /dev/null +++ b/BVGNearby/LIESMICH.txt @@ -0,0 +1,20 @@ +BVGNearby v8 – Schnellziele, Richtungsfilter, Hinweise, Teilen +=============================================================== + +1) Alle Dateien aus dem ZIP in den Ordner BVGNearby/ kopieren (ersetzen). + +2) Neue Dateien ins Xcode-Projekt aufnehmen – automatisch: + sudo gem install xcodeproj # einmalig + ruby BVGNearby/add_files.rb # im Ordner neben BVGNearby.xcodeproj + + Oder von Hand: File → Add Files … → DisruptionsViewController.h/.m + (Target BVGNearby anhaken, "Copy items" aus). + + Tipp: add_files.rb kannst du auch oben in deploy.sh eintragen, + dann passiert das bei jedem Build automatisch. + +3) ./deploy.sh + +Geändert: BVGSettings, SearchViewController, JourneyViewController, +StopViewController, ViewController, SettingsViewController, GuidanceViewController +Neu: DisruptionsViewController, add_files.rb diff --git a/BVGNearby/MapViewController.h b/BVGNearby/MapViewController.h new file mode 100644 index 0000000..bc84c1c --- /dev/null +++ b/BVGNearby/MapViewController.h @@ -0,0 +1,4 @@ +#import + +@interface MapViewController : UIViewController +@end diff --git a/BVGNearby/MapViewController.m b/BVGNearby/MapViewController.m new file mode 100644 index 0000000..362c038 --- /dev/null +++ b/BVGNearby/MapViewController.m @@ -0,0 +1,301 @@ +#import "MapViewController.h" +#import "StopViewController.h" +#import "TripViewController.h" +#import "BVGAnnotation.h" +#import "BVGAPI.h" +#import "BVGTheme.h" +#import + +static const CLLocationDegrees kMaxRadarSpan = 0.045; // weiter rausgezoomt -> keine Fahrzeuge (Last auf dem 4S) + +@interface MapViewController () +@property (nonatomic, strong) MKMapView *map; +@property (nonatomic, strong) UILabel *statusLabel; +@property (nonatomic, strong) NSMutableDictionary *vehicles; // tripId -> BVGAnnotation +@property (nonatomic, strong) NSMutableDictionary *stops; // stopId -> BVGAnnotation +@property (nonatomic, strong) NSTimer *timer; +@property (nonatomic, assign) BOOL centeredOnUser; +@property (nonatomic, assign) BOOL loadingVehicles; +@end + +@implementation MapViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.navigationItem.title = @"Live-Karte"; + self.vehicles = [NSMutableDictionary dictionary]; + self.stops = [NSMutableDictionary dictionary]; + + self.map = [[MKMapView alloc] initWithFrame:self.view.bounds]; + self.map.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.map.delegate = self; + self.map.showsUserLocation = YES; + self.map.showsPointsOfInterest = NO; + self.map.rotateEnabled = NO; + [self.view addSubview:self.map]; + + // Start: Berlin-Mitte, bis der Standort da ist + [self.map setRegion:MKCoordinateRegionMakeWithDistance(CLLocationCoordinate2DMake(52.5208, 13.4094), 2500, 2500) animated:NO]; + + // schwebende Status-Pille mit Schatten + self.statusLabel = [[UILabel alloc] init]; + self.statusLabel.font = [BVGTheme font:13 weight:UIFontWeightSemibold]; + self.statusLabel.textAlignment = NSTextAlignmentCenter; + self.statusLabel.textColor = [BVGTheme textPrimary]; + self.statusLabel.backgroundColor = [UIColor whiteColor]; + self.statusLabel.layer.cornerRadius = 17; + self.statusLabel.layer.masksToBounds = NO; + self.statusLabel.layer.shadowColor = [UIColor blackColor].CGColor; + self.statusLabel.layer.shadowOpacity = 0.18; + self.statusLabel.layer.shadowRadius = 6; + self.statusLabel.layer.shadowOffset = CGSizeMake(0, 2); + [self.view addSubview:self.statusLabel]; + + self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Mein Standort" + style:UIBarButtonItemStylePlain + target:self action:@selector(centerOnUser)]; + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemRefresh + target:self action:@selector(reloadAll)]; +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + CGFloat w = self.view.bounds.size.width; + CGFloat bottom = self.view.bounds.size.height - self.bottomLayoutGuide.length; + self.statusLabel.frame = CGRectMake(w / 2 - 130, bottom - 50, 260, 34); +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + [self reloadAll]; + self.timer = [NSTimer scheduledTimerWithTimeInterval:12 target:self selector:@selector(loadVehicles) userInfo:nil repeats:YES]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [self.timer invalidate]; + self.timer = nil; +} + +- (void)centerOnUser { + CLLocation *loc = self.map.userLocation.location; + if (!loc) return; + [self.map setRegion:MKCoordinateRegionMakeWithDistance(loc.coordinate, 1200, 1200) animated:YES]; +} + +- (void)reloadAll { + [self loadStops]; + [self loadVehicles]; +} + +- (void)setStatus:(NSString *)text { + self.statusLabel.text = text; + self.statusLabel.hidden = (text.length == 0); +} + +#pragma mark - Haltestellen + +- (void)loadStops { + MKCoordinateRegion r = self.map.region; + if (r.span.latitudeDelta > kMaxRadarSpan * 1.5) return; + if (self.stops.count > 150) { // Speicher schonen: alte Haltestellen wegwerfen + [self.map removeAnnotations:self.stops.allValues]; + [self.stops removeAllObjects]; + } + + CLLocationDistance radius = MIN(1500, r.span.latitudeDelta * 111000 / 2); + NSString *path = [NSString stringWithFormat: + @"/locations/nearby?latitude=%.5f&longitude=%.5f&distance=%.0f&results=35&poi=false&addresses=false", + r.center.latitude, r.center.longitude, radius]; + + [BVGAPI getJSON:path completion:^(id json, NSError *error) { + if (![json isKindOfClass:[NSArray class]]) return; + dispatch_async(dispatch_get_main_queue(), ^{ + for (NSDictionary *s in json) { + NSString *sid = NN(s[@"id"]); + NSDictionary *loc = NN(s[@"location"]); + if (!sid || self.stops[sid] || ![loc isKindOfClass:[NSDictionary class]]) continue; + + BVGAnnotation *a = [[BVGAnnotation alloc] init]; + a.kind = BVGAnnotationKindStop; + a.identifier = sid; + a.title = NN(s[@"name"]) ?: @"Haltestelle"; + a.subtitle = @"Abfahrten anzeigen"; + a.coordinate = CLLocationCoordinate2DMake([loc[@"latitude"] doubleValue], [loc[@"longitude"] doubleValue]); + self.stops[sid] = a; + [self.map addAnnotation:a]; + } + }); + }]; +} + +#pragma mark - Fahrzeuge (Radar) + +- (void)loadVehicles { + MKCoordinateRegion r = self.map.region; + if (r.span.latitudeDelta > kMaxRadarSpan) { + [self.map removeAnnotations:self.vehicles.allValues]; + [self.vehicles removeAllObjects]; + [self setStatus:@"Hineinzoomen für Live-Fahrzeuge"]; + return; + } + if (self.loadingVehicles) return; + self.loadingVehicles = YES; + + double north = r.center.latitude + r.span.latitudeDelta / 2; + double south = r.center.latitude - r.span.latitudeDelta / 2; + double west = r.center.longitude - r.span.longitudeDelta / 2; + double east = r.center.longitude + r.span.longitudeDelta / 2; + NSString *path = [NSString stringWithFormat: + @"/radar?north=%.5f&west=%.5f&south=%.5f&east=%.5f&results=80&duration=30&frames=1&polylines=false", + north, west, south, east]; + + [BVGAPI getJSON:path completion:^(id json, NSError *error) { + NSArray *movements = nil; + if ([json isKindOfClass:[NSArray class]]) movements = json; + else if ([json isKindOfClass:[NSDictionary class]] && [json[@"movements"] isKindOfClass:[NSArray class]]) movements = json[@"movements"]; + + dispatch_async(dispatch_get_main_queue(), ^{ + self.loadingVehicles = NO; + if (!movements) { + NSLog(@"Radar-Fehler: %@", error); + [self setStatus:@"Live-Daten nicht verfügbar"]; + return; + } + [self applyMovements:movements]; + }); + }]; +} + +- (void)applyMovements:(NSArray *)movements { + NSMutableSet *seen = [NSMutableSet set]; + NSMutableArray *added = [NSMutableArray array]; + NSMutableArray *moved = [NSMutableArray array]; + + for (NSDictionary *m in movements) { + NSString *tripId = NN(m[@"tripId"]); + NSDictionary *loc = NN(m[@"location"]); + if (!tripId || ![loc isKindOfClass:[NSDictionary class]]) continue; + [seen addObject:tripId]; + + CLLocationCoordinate2D c = CLLocationCoordinate2DMake([loc[@"latitude"] doubleValue], [loc[@"longitude"] doubleValue]); + NSDictionary *line = NN(m[@"line"]); + NSString *title = [NSString stringWithFormat:@"%@ → %@", NN(line[@"name"]) ?: @"?", NN(m[@"direction"]) ?: @""]; + + BVGAnnotation *a = self.vehicles[tripId]; + if (a) { + [moved addObject:@[a, [NSValue valueWithMKCoordinate:c]]]; + a.title = title; + } else { + a = [[BVGAnnotation alloc] init]; + a.kind = BVGAnnotationKindVehicle; + a.identifier = tripId; + a.line = line; + a.title = title; + a.subtitle = @"Fahrtverlauf anzeigen"; + a.coordinate = c; + self.vehicles[tripId] = a; + [added addObject:a]; + } + } + + // verschwundene Fahrzeuge entfernen + NSMutableArray *gone = [NSMutableArray array]; + for (NSString *tripId in self.vehicles.allKeys) { + if (![seen containsObject:tripId]) { + [gone addObject:self.vehicles[tripId]]; + [self.vehicles removeObjectForKey:tripId]; + } + } + [self.map removeAnnotations:gone]; + [self.map addAnnotations:added]; + + // bekannte Fahrzeuge sanft weiterbewegen + [UIView animateWithDuration:2.0 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{ + for (NSArray *pair in moved) { + BVGAnnotation *a = pair[0]; + a.coordinate = [pair[1] MKCoordinateValue]; + } + } completion:nil]; + + [self setStatus:[NSString stringWithFormat:@"● Live · %lu Fahrzeuge · %@", + (unsigned long)self.vehicles.count, [BVGAPI hhmm:[NSDate date]]]]; +} + +#pragma mark - MKMapViewDelegate + +- (void)mapView:(MKMapView *)mapView didUpdateUserLocation:(MKUserLocation *)userLocation { + if (self.centeredOnUser || !userLocation.location) return; + self.centeredOnUser = YES; + [self.map setRegion:MKCoordinateRegionMakeWithDistance(userLocation.location.coordinate, 1200, 1200) animated:NO]; +} + +- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated { + [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(reloadAll) object:nil]; + [self performSelector:@selector(reloadAll) withObject:nil afterDelay:0.7]; +} + ++ (UIImage *)stopIcon { + static UIImage *img; + static dispatch_once_t once; + dispatch_once(&once, ^{ + CGSize size = CGSizeMake(20, 20); + UIGraphicsBeginImageContextWithOptions(size, NO, 0); + UIBezierPath *c = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(1, 1, 18, 18)]; + [[BVGAPI bvgYellow] setFill]; + [c fill]; + [[UIColor colorWithRed:0 green:0.45 blue:0.25 alpha:1] setStroke]; + c.lineWidth = 2; + [c stroke]; + NSDictionary *attrs = @{ NSFontAttributeName: [UIFont boldSystemFontOfSize:12], + NSForegroundColorAttributeName: [UIColor colorWithRed:0 green:0.45 blue:0.25 alpha:1] }; + CGSize t = [@"H" sizeWithAttributes:attrs]; + [@"H" drawAtPoint:CGPointMake((20 - t.width) / 2, (20 - t.height) / 2) withAttributes:attrs]; + img = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + }); + return img; +} + +- (MKAnnotationView *)mapView:(MKMapView *)mapView viewForAnnotation:(id)annotation { + if (![annotation isKindOfClass:[BVGAnnotation class]]) return nil; // eigener Standort: blauer Punkt + BVGAnnotation *a = (BVGAnnotation *)annotation; + + NSString *reuse = a.kind == BVGAnnotationKindVehicle ? @"vehicle" : @"stop"; + MKAnnotationView *v = [mapView dequeueReusableAnnotationViewWithIdentifier:reuse]; + if (!v) { + v = [[MKAnnotationView alloc] initWithAnnotation:a reuseIdentifier:reuse]; + v.canShowCallout = YES; + v.rightCalloutAccessoryView = [UIButton buttonWithType:UIButtonTypeDetailDisclosure]; + } + v.annotation = a; + + if (a.kind == BVGAnnotationKindVehicle) { + v.image = [BVGAPI badgeForLine:a.line]; + v.layer.shadowColor = [UIColor blackColor].CGColor; + v.layer.shadowOpacity = 0.35; + v.layer.shadowRadius = 2; + v.layer.shadowOffset = CGSizeMake(0, 1); + v.layer.zPosition = 10; + } else { + v.image = [MapViewController stopIcon]; + v.layer.zPosition = 0; + } + return v; +} + +- (void)mapView:(MKMapView *)mapView annotationView:(MKAnnotationView *)view calloutAccessoryControlTapped:(UIControl *)control { + BVGAnnotation *a = (BVGAnnotation *)view.annotation; + if (![a isKindOfClass:[BVGAnnotation class]]) return; + + if (a.kind == BVGAnnotationKindVehicle) { + TripViewController *trip = [[TripViewController alloc] initWithTripId:a.identifier]; + trip.line = a.line; + [self.navigationController pushViewController:trip animated:YES]; + } else { + StopViewController *stop = [[StopViewController alloc] initWithStopId:a.identifier name:a.title]; + [self.navigationController pushViewController:stop animated:YES]; + } +} + +@end diff --git a/BVGNearby/SceneDelegate.h b/BVGNearby/SceneDelegate.h deleted file mode 100644 index 9997ed6..0000000 --- a/BVGNearby/SceneDelegate.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// SceneDelegate.h -// BVGNearby -// -// Created by Jan Aaron Titus Kastl on 23.10.26. -// - -#import - -@interface SceneDelegate : UIResponder - -@property (strong, nonatomic) UIWindow * window; - -@end - diff --git a/BVGNearby/SceneDelegate.m b/BVGNearby/SceneDelegate.m deleted file mode 100644 index 0f539d9..0000000 --- a/BVGNearby/SceneDelegate.m +++ /dev/null @@ -1,57 +0,0 @@ -// -// SceneDelegate.m -// BVGNearby -// -// Created by Jan Aaron Titus Kastl on 23.10.26. -// - -#import "SceneDelegate.h" - -@interface SceneDelegate () - -@end - -@implementation SceneDelegate - - -- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). -} - - -- (void)sceneDidDisconnect:(UIScene *)scene { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). -} - - -- (void)sceneDidBecomeActive:(UIScene *)scene { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. -} - - -- (void)sceneWillResignActive:(UIScene *)scene { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). -} - - -- (void)sceneWillEnterForeground:(UIScene *)scene { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. -} - - -- (void)sceneDidEnterBackground:(UIScene *)scene { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. -} - - -@end diff --git a/BVGNearby/SearchViewController.h b/BVGNearby/SearchViewController.h new file mode 100644 index 0000000..651751a --- /dev/null +++ b/BVGNearby/SearchViewController.h @@ -0,0 +1,8 @@ +#import "CardTableViewController.h" + +@interface SearchViewController : CardTableViewController +// Gesetzt = Auswahlmodus (z.B. fuer den Routenplaner): Tippen liefert die Haltestelle zurueck +@property (nonatomic, copy) void (^pickHandler)(NSDictionary *stop); +// im Auswahlmodus auch Adressen anbieten (z.B. fuer "Zuhause") +@property (nonatomic, assign) BOOL allowAddresses; +@end diff --git a/BVGNearby/SearchViewController.m b/BVGNearby/SearchViewController.m new file mode 100644 index 0000000..e1d5245 --- /dev/null +++ b/BVGNearby/SearchViewController.m @@ -0,0 +1,222 @@ +#import "SearchViewController.h" +#import "StopViewController.h" +#import "BVGAPI.h" + +@interface SearchViewController () +@property (nonatomic, strong) UISearchBar *searchBar; +@property (nonatomic, strong) NSArray *results; +@property (nonatomic, copy) NSString *lastQuery; +@property (nonatomic, assign) BOOL searching; +@end + +@implementation SearchViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.results = @[]; + self.tableView.rowHeight = 58; + + self.searchBar = [[UISearchBar alloc] initWithFrame:CGRectMake(0, 0, self.view.bounds.size.width, 44)]; + self.searchBar.placeholder = @"Haltestelle, z. B. Alexanderplatz"; + self.searchBar.delegate = self; + self.searchBar.autocorrectionType = UITextAutocorrectionTypeNo; + self.searchBar.searchBarStyle = UISearchBarStyleMinimal; + self.searchBar.tintColor = [BVGTheme textPrimary]; + self.searchBar.layoutMargins = UIEdgeInsetsMake(0, [BVGTheme cardInset] - 8, 0, [BVGTheme cardInset] - 8); + self.headerAccessory = self.searchBar; + if (self.pickHandler) self.navigationItem.title = @"Haltestelle wählen"; + else self.largeTitle = @"Suche"; + self.tableView.keyboardDismissMode = UIScrollViewKeyboardDismissModeOnDrag; + + [[NSNotificationCenter defaultCenter] addObserver:self.tableView selector:@selector(reloadData) + name:BVGFavoritesChangedNotification object:nil]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self.tableView]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self.tableView reloadData]; // "Zuletzt" aktualisieren + if (self.pickHandler) [self.searchBar becomeFirstResponder]; +} + +- (BOOL)isSearching { + return self.searchBar.text.length >= 2; +} + +#pragma mark - Suche + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText { + [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(runSearch) object:nil]; + if (searchText.length < 2) { + self.results = @[]; + [self.tableView reloadData]; + return; + } + [self performSelector:@selector(runSearch) withObject:nil afterDelay:0.35]; +} + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { + [searchBar resignFirstResponder]; + [self runSearch]; +} + +- (void)runSearch { + NSString *q = self.searchBar.text; + if (q.length < 2) return; + self.lastQuery = q; + self.searching = YES; + NSString *path = [NSString stringWithFormat:(self.allowAddresses + ? @"/locations?query=%@&results=15&poi=false&addresses=true&fuzzy=true" + : @"/locations?query=%@&results=15&poi=false&addresses=false&fuzzy=true"), + [BVGAPI encode:q]]; + + [BVGAPI getJSON:path completion:^(id json, NSError *error) { + NSMutableArray *stops = [NSMutableArray array]; + if ([json isKindOfClass:[NSArray class]]) { + for (NSDictionary *l in json) { + if (![l isKindOfClass:[NSDictionary class]]) continue; + BOOL isStop = [SearchViewController isStop:l] && NN(l[@"name"]); + BOOL isAddress = self.allowAddresses && ![SearchViewController isStop:l] + && NN(l[@"address"]) && NN(l[@"latitude"]) && NN(l[@"longitude"]); + if (isStop || isAddress) [stops addObject:l]; + } + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (![q isEqualToString:self.lastQuery]) return; // veraltete Antwort + self.searching = NO; + self.results = stops; + [self.tableView reloadData]; + }); + }]; +} + ++ (NSString *)productsText:(NSDictionary *)stop { + NSDictionary *p = NN(stop[@"products"]); + if (![p isKindOfClass:[NSDictionary class]]) return @"Haltestelle"; + NSMutableArray *list = [NSMutableArray array]; + NSArray *order = @[@[@"suburban", @"S-Bahn"], @[@"subway", @"U-Bahn"], @[@"tram", @"Tram"], + @[@"bus", @"Bus"], @[@"ferry", @"Fähre"], @[@"regional", @"Regio"], @[@"express", @"Fernverkehr"]]; + for (NSArray *pair in order) if ([NN(p[pair[0]]) boolValue]) [list addObject:pair[1]]; + return list.count ? [list componentsJoinedByString:@" · "] : @"Haltestelle"; +} + +// runde Haltestellen-Marke links in der Liste ++ (UIImage *)iconWithText:(NSString *)text fav:(BOOL)fav { + static NSCache *cache; + static dispatch_once_t once; + dispatch_once(&once, ^{ cache = [[NSCache alloc] init]; }); + NSString *key = [text stringByAppendingString:fav ? @"1" : @"0"]; + UIImage *img = [cache objectForKey:key]; + if (img) return img; + + CGSize size = CGSizeMake(32, 32); + UIGraphicsBeginImageContextWithOptions(size, NO, 0); + [(fav ? [BVGTheme accentSoft] : [BVGTheme background]) setFill]; + [[UIBezierPath bezierPathWithOvalInRect:CGRectMake(0, 0, 32, 32)] fill]; + UIColor *fg = fav ? [BVGTheme orange] : [UIColor colorWithRed:0 green:0.44 blue:0.29 alpha:1]; + NSDictionary *attrs = @{ NSFontAttributeName: [BVGTheme font:15 weight:UIFontWeightBold], NSForegroundColorAttributeName: fg }; + CGSize t = [text sizeWithAttributes:attrs]; + [text drawAtPoint:CGPointMake((32 - t.width) / 2, (32 - t.height) / 2) withAttributes:attrs]; + img = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + [cache setObject:img forKey:key]; + return img; +} + +// Haltestelle oder Adresse? Massgeblich ist "type" - Adressen haben bei HAFAS ebenfalls eine id! +// Favoriten/Verlauf haben kein "type", dort sind immer Haltestellen mit id gespeichert. ++ (BOOL)isStop:(NSDictionary *)l { + NSString *type = NN(l[@"type"]); + if (type) return [type isEqualToString:@"stop"] || [type isEqualToString:@"station"]; + return NN(l[@"id"]) != nil && NN(l[@"latitude"]) == nil; +} + +// Haltestelle -> {id, name}; Adresse -> {name, latitude, longitude} (ohne NSNull, damit speicherbar) ++ (NSDictionary *)placeFromLocation:(NSDictionary *)l { + NSString *name = NN(l[@"name"]) ?: NN(l[@"address"]) ?: @"Ort"; + if ([SearchViewController isStop:l]) return @{ @"id": l[@"id"], @"name": name }; + // Adressen haben bei HAFAS auch eine id - die ist aber keine Haltestellen-ID, also weglassen + NSDictionary *loc = [NN(l[@"location"]) isKindOfClass:[NSDictionary class]] ? l[@"location"] : l; + return @{ @"name": name, + @"latitude": NN(loc[@"latitude"]) ?: @0, + @"longitude": NN(loc[@"longitude"]) ?: @0 }; +} + +#pragma mark - Daten + +- (NSArray *)sections { + if ([self isSearching]) return @[ @{ @"title": self.results.count ? @"Treffer" : @"", @"rows": self.results } ]; + NSMutableArray *s = [NSMutableArray array]; + NSArray *favs = [BVGAPI favorites]; + NSArray *recents = [BVGAPI recentStops]; + if (favs.count) [s addObject:@{ @"title": @"Favoriten", @"rows": favs }]; + if (recents.count) [s addObject:@{ @"title": @"Zuletzt angesehen", @"rows": recents }]; + return s; +} + +- (NSDictionary *)stopAt:(NSIndexPath *)ip { + return [self sections][ip.section][@"rows"][ip.row]; +} + +#pragma mark - Table + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + NSInteger n = [self sections].count; + BOOL empty = (n == 0) || ([self isSearching] && !self.results.count); + if (empty && !self.searching) { + if ([self isSearching]) { + [self showEmptyIcon:@"🤷" title:@"Nichts gefunden" text:@"Prüfe die Schreibweise oder versuche einen Teil des Namens."]; + } else { + [self showEmptyIcon:@"🔍" title:@"Haltestelle suchen" + text:@"Haltestellen, die du ansiehst, findest du später auch in der iOS-Suche – einfach auf dem Homescreen nach unten wischen."]; + } + } else { + [self showEmptyIcon:nil title:nil text:nil]; + } + return n; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { + NSString *t = [self sections][section][@"title"]; + return t.length ? t : nil; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return [[self sections][section][@"rows"] count]; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + CardCell *cell = [tableView dequeueReusableCellWithIdentifier:@"loc"]; + if (!cell) cell = [[CardCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"loc"]; + NSDictionary *stop = [self stopAt:indexPath]; + BOOL fav = [BVGAPI isFavorite:stop[@"id"]]; + + BOOL isAddress = ![SearchViewController isStop:stop]; + cell.imageView.image = [SearchViewController iconWithText:(isAddress ? @"⌂" : (fav ? @"★" : @"H")) fav:fav]; + cell.textLabel.text = NN(stop[@"name"]) ?: NN(stop[@"address"]); + cell.textLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold]; + cell.detailTextLabel.text = isAddress ? @"Adresse" : [SearchViewController productsText:stop]; + cell.detailTextLabel.font = [BVGTheme font:13 weight:UIFontWeightRegular]; + cell.accessoryType = self.pickHandler ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator; + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + [self.searchBar resignFirstResponder]; + NSDictionary *stop = [self stopAt:indexPath]; + + if (self.pickHandler) { + self.pickHandler([SearchViewController placeFromLocation:stop]); + [self.navigationController popViewControllerAnimated:YES]; + return; + } + if (![SearchViewController isStop:stop]) return; // Adressen haben keine Abfahrten + StopViewController *vc = [[StopViewController alloc] initWithStopId:stop[@"id"] name:stop[@"name"]]; + [self.navigationController pushViewController:vc animated:YES]; +} + +@end diff --git a/BVGNearby/SettingsViewController.h b/BVGNearby/SettingsViewController.h new file mode 100644 index 0000000..a80d902 --- /dev/null +++ b/BVGNearby/SettingsViewController.h @@ -0,0 +1,4 @@ +#import "CardTableViewController.h" + +@interface SettingsViewController : CardTableViewController +@end diff --git a/BVGNearby/SettingsViewController.m b/BVGNearby/SettingsViewController.m new file mode 100644 index 0000000..dd7700d --- /dev/null +++ b/BVGNearby/SettingsViewController.m @@ -0,0 +1,167 @@ +#import "SettingsViewController.h" +#import "BVGSettings.h" +#import "BVGAPI.h" +#import +#import + +static char kSwitchKey; // Zuordnung UISwitch -> Einstellungs-Schluessel + +@interface SettingsViewController () +@property (nonatomic, strong) NSArray *sections; // [{title, footer, rows:[{title, key, values, labels} | {switch}]}] +@end + +@implementation SettingsViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.largeTitle = @"Einstellungen"; + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone + target:self action:@selector(close)]; + self.sections = @[ + @{ @"title": @"Abfahrten", + @"rows": @[ + @{ @"title": @"Suchradius", @"key": @"set.radius", + @"values": @[@500, @1000, @1500], @"labels": @[@"500 m", @"1 km", @"1,5 km"] }, + @{ @"title": @"Abfahrten pro Haltestelle", @"key": @"set.perStop", + @"values": @[@3, @5, @8], @"labels": @[@"3", @"5", @"8"] } ] }, + @{ @"title": @"Navigation", + @"footer": @"Umstiege werden nie mit weniger als 5 Minuten Puffer geplant. Wird der Puffer durch Verspätungen kleiner, schlägt die App eine Alternative vor.", + @"rows": @[ + @{ @"title": @"Mindest-Umstiegszeit", @"key": @"set.transfer", + @"values": @[@5, @7, @10], @"labels": @[@"5 min", @"7 min", @"10 min"] }, + @{ @"title": @"Einsteigen-Hinweis", @"key": @"set.boardLead", + @"values": @[@1, @2, @3, @5], @"labels": @[@"1 min vorher", @"2 min vorher", @"3 min vorher", @"5 min vorher"] } ] }, + @{ @"title": @"Orte", + @"footer": @"Festlegen oder ändern: im Route-Tab das Schnellziel gedrückt halten.", + @"rows": @[ @{ @"title": @"🏠 Zuhause", @"place": @"home" }, + @{ @"title": @"💼 Arbeit", @"place": @"work" } ] }, + @{ @"title": @"Ansagen", + @"footer": @"Steht der Stummschalter auf stumm, bleiben Ansagen automatisch still und Erinnerungen vibrieren nur.", + @"rows": @[ @{ @"title": @"Sprachansagen", @"switch": @"set.speech" } ] }, + @{ @"title": @"Info", + @"footer": @"Eigener Server: api.serverlele.de (schlanker, gecacht). Fällt er aus, nutzt die App automatisch v6.bvg.transport.rest. Keine Gewähr.", + @"rows": @[ @{ @"title": @"Eigener Server", @"switch": @"set.ownServer" }, + @{ @"title": @"Daten zuletzt von", @"info": @"source" }, + @{ @"title": @"Version", @"info": @"version" }, + @{ @"title": @"Spotlight-Einträge löschen", @"action": @"clearSpotlight" }, + @{ @"title": @"Verlauf löschen", @"action": @"clearRecents" } ] }, + ]; +} + +- (void)close { + [self dismissViewControllerAnimated:YES completion:nil]; +} + +#pragma mark - Table + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + return self.sections.count; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { + return self.sections[section][@"title"]; +} + +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section { + return self.sections[section][@"footer"]; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return [self.sections[section][@"rows"] count]; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + NSDictionary *row = self.sections[indexPath.section][@"rows"][indexPath.row]; + CardCell *cell = [[CardCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:nil]; + cell.textLabel.text = row[@"title"]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.detailTextLabel.font = [BVGTheme font:16 weight:UIFontWeightRegular]; + + if (row[@"values"]) { + // aktueller Wert rechts, Antippen oeffnet die Auswahl (wie in den iOS-Einstellungen) + NSInteger current = [[NSUserDefaults standardUserDefaults] integerForKey:row[@"key"]]; + NSUInteger idx = [row[@"values"] indexOfObject:@(current)]; + cell.detailTextLabel.text = row[@"labels"][idx == NSNotFound ? 0 : idx]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + } else if (row[@"place"]) { + NSDictionary *place = [BVGSettings placeForKey:row[@"place"]]; + cell.detailTextLabel.text = place ? place[@"name"] : @"nicht festgelegt"; + if (place) cell.selectionStyle = UITableViewCellSelectionStyleDefault; + } else if (row[@"switch"]) { + UISwitch *sw = [[UISwitch alloc] init]; + sw.on = [[NSUserDefaults standardUserDefaults] boolForKey:row[@"switch"]]; + objc_setAssociatedObject(sw, &kSwitchKey, row[@"switch"], OBJC_ASSOCIATION_COPY_NONATOMIC); + [sw addTarget:self action:@selector(switchChanged:) forControlEvents:UIControlEventValueChanged]; + cell.accessoryView = sw; + } else if (row[@"info"]) { + if ([row[@"info"] isEqualToString:@"source"]) { + cell.detailTextLabel.text = [BVGAPI lastSource] ?: @"–"; + } else { + NSDictionary *info = [NSBundle mainBundle].infoDictionary; + cell.detailTextLabel.text = [NSString stringWithFormat:@"%@ (%@)", + info[@"CFBundleShortVersionString"] ?: @"1.0", info[@"CFBundleVersion"] ?: @"1"]; + } + } else if (row[@"action"]) { + cell.textLabel.textColor = [BVGTheme red]; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + } + return cell; +} + +- (void)chooseValueForRow:(NSDictionary *)row { + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:row[@"title"] message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + NSArray *values = row[@"values"]; + NSInteger current = [[NSUserDefaults standardUserDefaults] integerForKey:row[@"key"]]; + for (NSUInteger i = 0; i < values.count; i++) { + NSString *title = row[@"labels"][i]; + if ([values[i] integerValue] == current) title = [@"✓ " stringByAppendingString:title]; + [sheet addAction:[UIAlertAction actionWithTitle:title style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) { + [BVGSettings setInteger:[values[i] integerValue] forKey:row[@"key"]]; + [self.tableView reloadData]; + }]]; + } + [sheet addAction:[UIAlertAction actionWithTitle:@"Abbrechen" style:UIAlertActionStyleCancel handler:nil]]; + sheet.popoverPresentationController.sourceView = self.view; + [self presentViewController:sheet animated:YES completion:nil]; +} + +- (void)switchChanged:(UISwitch *)sw { + NSString *key = objc_getAssociatedObject(sw, &kSwitchKey); + [BVGSettings setBool:sw.on forKey:key]; + if ([key isEqualToString:@"set.speech"] && !sw.on) [BVGAPI stopSpeaking]; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + NSDictionary *row = self.sections[indexPath.section][@"rows"][indexPath.row]; + if (row[@"values"]) { [self chooseValueForRow:row]; return; } + if (row[@"place"]) { + if (![BVGSettings placeForKey:row[@"place"]]) return; + UIAlertController *a = [UIAlertController alertControllerWithTitle:row[@"title"] message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + [a addAction:[UIAlertAction actionWithTitle:@"Ort entfernen" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *x) { + [BVGSettings setPlace:nil forKey:row[@"place"]]; + [self.tableView reloadData]; + }]]; + [a addAction:[UIAlertAction actionWithTitle:@"Abbrechen" style:UIAlertActionStyleCancel handler:nil]]; + a.popoverPresentationController.sourceView = self.view; + [self presentViewController:a animated:YES completion:nil]; + return; + } + NSString *action = row[@"action"]; + if (!action) return; + + if ([action isEqualToString:@"clearSpotlight"]) { + [[CSSearchableIndex defaultSearchableIndex] deleteAllSearchableItemsWithCompletionHandler:nil]; + } else if ([action isEqualToString:@"clearRecents"]) { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"recentStops"]; + } + UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Erledigt" message:nil + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:a animated:YES completion:nil]; +} + +@end diff --git a/BVGNearby/StopViewController.h b/BVGNearby/StopViewController.h new file mode 100644 index 0000000..fd77053 --- /dev/null +++ b/BVGNearby/StopViewController.h @@ -0,0 +1,5 @@ +#import "CardTableViewController.h" + +@interface StopViewController : CardTableViewController +- (instancetype)initWithStopId:(NSString *)stopId name:(NSString *)name; +@end diff --git a/BVGNearby/StopViewController.m b/BVGNearby/StopViewController.m new file mode 100644 index 0000000..5be6762 --- /dev/null +++ b/BVGNearby/StopViewController.m @@ -0,0 +1,303 @@ +#import "StopViewController.h" +#import "TripViewController.h" +#import "DepartureCell.h" +#import "BVGAPI.h" +#import "ChipBar.h" +#import "BVGSettings.h" + +@interface StopViewController () +@property (nonatomic, copy) NSString *stopId; +@property (nonatomic, copy) NSString *stopName; +@property (nonatomic, strong) NSArray *allDepartures; +@property (nonatomic, strong) NSArray *departures; // gefiltert +@property (nonatomic, strong) NSArray *warnings; // [{summary, text}] +@property (nonatomic, strong) ChipBar *filter; +@property (nonatomic, strong) UIButton *directionButton; +@property (nonatomic, strong) NSTimer *timer; +@property (nonatomic, assign) BOOL loaded; +@end + +@implementation StopViewController + +- (instancetype)initWithStopId:(NSString *)stopId name:(NSString *)name { + self = [super initWithStyle:UITableViewStyleGrouped]; + if (self) { + _stopId = [stopId copy]; + _stopName = [name copy]; + _allDepartures = @[]; + _departures = @[]; + _warnings = @[]; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.tableView.rowHeight = [DepartureCell height]; + + // grosser Titel = Haltestellenname, darunter Filter-Chips + __weak StopViewController *weakSelf = self; + self.filter = [[ChipBar alloc] initWithItems:@[@"Alle", @"S-Bahn", @"U-Bahn", @"Tram", @"Bus", @"Regio"]]; + self.filter.onChange = ^(NSInteger idx) { [weakSelf applyFilter]; }; + + // darunter: Richtungsfilter (wird pro Haltestelle gemerkt, gilt auch in der Startansicht) + UIView *acc = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.view.bounds.size.width, 82)]; + [acc addSubview:self.filter]; + self.directionButton = [UIButton buttonWithType:UIButtonTypeCustom]; + self.directionButton.frame = CGRectMake([BVGTheme cardInset] + 4, 46, acc.bounds.size.width - 2 * [BVGTheme cardInset] - 8, 32); + self.directionButton.autoresizingMask = UIViewAutoresizingFlexibleWidth; + self.directionButton.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft; + self.directionButton.titleLabel.font = [BVGTheme font:15 weight:UIFontWeightMedium]; + self.directionButton.titleLabel.lineBreakMode = NSLineBreakByTruncatingTail; + [self.directionButton addTarget:self action:@selector(chooseDirection) forControlEvents:UIControlEventTouchUpInside]; + [acc addSubview:self.directionButton]; + [self updateDirectionButton]; + self.headerAccessory = acc; + self.largeSubtitle = @"Nächste 60 Minuten"; + self.largeTitle = self.stopName; + + self.refreshControl = [[UIRefreshControl alloc] init]; + [self.refreshControl addTarget:self action:@selector(load) forControlEvents:UIControlEventValueChanged]; + + [self updateStar]; + [[NSNotificationCenter defaultCenter] addObserver:self.tableView selector:@selector(reloadData) + name:BVGAlarmsChangedNotification object:nil]; + [BVGAPI addRecentStop:self.stopId name:self.stopName]; // + Spotlight + [self load]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self.tableView]; +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + self.timer = [NSTimer scheduledTimerWithTimeInterval:30 target:self selector:@selector(load) userInfo:nil repeats:YES]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [self.timer invalidate]; + self.timer = nil; +} + +#pragma mark - Favorit + +- (void)updateStar { + BOOL fav = [BVGAPI isFavorite:self.stopId]; + UIBarButtonItem *star = [[UIBarButtonItem alloc] initWithTitle:(fav ? @"★" : @"☆") + style:UIBarButtonItemStylePlain + target:self action:@selector(toggleStar)]; + [star setTitleTextAttributes:@{ NSFontAttributeName: [UIFont systemFontOfSize:24] } forState:UIControlStateNormal]; + + star.tintColor = fav ? [BVGTheme orange] : [BVGTheme textSecondary]; + UIBarButtonItem *speaker = [[UIBarButtonItem alloc] initWithTitle:@"Ansage" + style:UIBarButtonItemStylePlain + target:self action:@selector(announce)]; + self.navigationItem.rightBarButtonItems = @[star, speaker]; +} + +- (void)announce { + if ([BVGAPI isSpeaking]) { [BVGAPI stopSpeaking]; return; } + [BVGAPI speak:[BVGAPI announcementForStop:self.stopName departures:self.departures max:4]]; +} + +- (void)toggleStar { + [BVGAPI setFavorite:![BVGAPI isFavorite:self.stopId] stopId:self.stopId name:self.stopName]; + [self updateStar]; +} + +#pragma mark - Laden + +- (void)load { + NSString *path = [NSString stringWithFormat:@"/stops/%@/departures?duration=60&results=50", self.stopId]; + [BVGAPI getJSON:path completion:^(id json, NSError *error) { + NSArray *deps = nil; + if ([json isKindOfClass:[NSDictionary class]] && [json[@"departures"] isKindOfClass:[NSArray class]]) { + deps = json[@"departures"]; + } + dispatch_async(dispatch_get_main_queue(), ^{ + [self.refreshControl endRefreshing]; + if (!deps) { + NSLog(@"Stop-Fehler: %@", error); + if (!self.loaded) [self showMessage:@"Abfahrten konnten nicht geladen werden.\nZum Aktualisieren nach unten ziehen."]; + return; + } + self.loaded = YES; + self.allDepartures = deps; + self.warnings = [self collectWarnings:deps]; + [self applyFilter]; + }); + }]; +} + +- (NSArray *)collectWarnings:(NSArray *)deps { + NSMutableArray *list = [NSMutableArray array]; + NSMutableSet *seen = [NSMutableSet set]; + for (NSDictionary *d in deps) { + id remarks = NN(d[@"remarks"]); + if (![remarks isKindOfClass:[NSArray class]]) continue; + for (NSDictionary *r in remarks) { + if (![r isKindOfClass:[NSDictionary class]] || ![NN(r[@"type"]) isEqualToString:@"warning"]) continue; + NSString *summary = NN(r[@"summary"]) ?: @"Hinweis"; + NSString *text = NN(r[@"text"]) ?: @""; + NSString *key = [summary stringByAppendingString:text]; + if ([seen containsObject:key]) continue; + [seen addObject:key]; + [list addObject:@{ @"summary": summary, @"text": text }]; + } + } + return list; +} + +#pragma mark - Richtung + +- (void)updateDirectionButton { + NSString *dir = [BVGSettings directionFilterForStop:self.stopId]; + [self.directionButton setTitle:(dir ? [NSString stringWithFormat:@"Nur Richtung %@ ✕", dir] : @"Alle Richtungen ▾") + forState:UIControlStateNormal]; + [self.directionButton setTitleColor:(dir ? [BVGTheme orange] : [BVGTheme link]) forState:UIControlStateNormal]; +} + +- (void)chooseDirection { + // gesetzter Filter: Antippen hebt ihn auf + if ([BVGSettings directionFilterForStop:self.stopId]) { + [BVGSettings setDirectionFilter:nil forStop:self.stopId]; + [self updateDirectionButton]; + [self applyFilter]; + return; + } + // Richtungen nach Haeufigkeit sortiert anbieten + NSCountedSet *counts = [NSCountedSet set]; + for (NSDictionary *d in self.allDepartures) if (NN(d[@"direction"])) [counts addObject:d[@"direction"]]; + NSArray *dirs = [counts.allObjects sortedArrayUsingComparator:^NSComparisonResult(NSString *a, NSString *b) { + return [@([counts countForObject:b]) compare:@([counts countForObject:a])]; + }]; + if (!dirs.count) return; + + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Nur eine Richtung anzeigen" + message:@"Wird für diese Haltestelle gemerkt – auch in der Startansicht." + preferredStyle:UIAlertControllerStyleActionSheet]; + for (NSString *dir in [dirs subarrayWithRange:NSMakeRange(0, MIN(dirs.count, 8))]) { + [sheet addAction:[UIAlertAction actionWithTitle:dir style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) { + [BVGSettings setDirectionFilter:dir forStop:self.stopId]; + [self updateDirectionButton]; + [self applyFilter]; + }]]; + } + [sheet addAction:[UIAlertAction actionWithTitle:@"Abbrechen" style:UIAlertActionStyleCancel handler:nil]]; + sheet.popoverPresentationController.sourceView = self.view; + [self presentViewController:sheet animated:YES completion:nil]; +} + +- (void)applyFilter { + NSArray *products = @[@"", @"suburban", @"subway", @"tram", @"bus", @"regional"]; + NSString *want = products[self.filter.selectedIndex]; + NSString *dir = [BVGSettings directionFilterForStop:self.stopId]; + NSMutableArray *f = [NSMutableArray array]; + for (NSDictionary *d in self.allDepartures) { + if (want.length && ![NN(NN(d[@"line"])[@"product"]) isEqualToString:want]) continue; + if (dir && ![NN(d[@"direction"]) isEqualToString:dir]) continue; + [f addObject:d]; + } + self.departures = f; + [self showEmptyIcon:nil title:nil text:nil]; + [self.tableView reloadData]; +} + +- (void)showMessage:(NSString *)text { + [self showEmptyIcon:@"📡" title:@"Keine Daten" text:text]; +} + ++ (NSString *)stripHTML:(NSString *)s { + if (!s) return @""; + NSString *t = [s stringByReplacingOccurrencesOfString:@"
" withString:@"\n" + options:NSRegularExpressionSearch | NSCaseInsensitiveSearch + range:NSMakeRange(0, s.length)]; + t = [t stringByReplacingOccurrencesOfString:@"<[^>]+>" withString:@"" + options:NSRegularExpressionSearch range:NSMakeRange(0, t.length)]; + return [t stringByReplacingOccurrencesOfString:@" " withString:@" "]; +} + +#pragma mark - Table + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + return 2; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { + if (section == 0) return self.warnings.count ? @"Hinweise" : nil; + return (self.loaded && self.warnings.count) ? @"Abfahrten" : nil; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + if (section == 0) return self.warnings.count; + return self.loaded ? MAX(self.departures.count, 1) : 0; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { + return indexPath.section == 0 ? 52 : [DepartureCell height]; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.section == 0) { + CardCell *cell = [tableView dequeueReusableCellWithIdentifier:@"warn"]; + if (!cell) cell = [[CardCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"warn"]; + cell.textLabel.text = [@"⚠︎ " stringByAppendingString:self.warnings[indexPath.row][@"summary"]]; + cell.textLabel.font = [BVGTheme font:15 weight:UIFontWeightMedium]; + cell.textLabel.textColor = [UIColor colorWithRed:0.62 green:0.36 blue:0 alpha:1]; + cell.textLabel.numberOfLines = 2; + cell.cardColor = [BVGTheme accentSoft]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + return cell; + } + + DepartureCell *cell = [tableView dequeueReusableCellWithIdentifier:@"dep"]; + if (!cell) cell = [[DepartureCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"dep"]; + if (self.departures.count == 0) { + [cell configureEmpty:@"Keine Abfahrten für diesen Filter"]; + } else { + [cell configureWithDeparture:self.departures[indexPath.row]]; + } + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + if (indexPath.section == 0) { + NSDictionary *w = self.warnings[indexPath.row]; + UIAlertController *a = [UIAlertController alertControllerWithTitle:w[@"summary"] + message:[StopViewController stripHTML:w[@"text"]] + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:a animated:YES completion:nil]; + return; + } + + if (indexPath.row >= (NSInteger)self.departures.count) return; + NSDictionary *d = self.departures[indexPath.row]; + NSString *tripId = NN(d[@"tripId"]); + if (!tripId) return; + + TripViewController *trip = [[TripViewController alloc] initWithTripId:tripId]; + trip.line = NN(d[@"line"]); + trip.stopName = self.stopName; + [self.navigationController pushViewController:trip animated:YES]; +} + +- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.section != 1 || indexPath.row >= (NSInteger)self.departures.count) return NO; + NSDictionary *d = self.departures[indexPath.row]; + return NN(d[@"tripId"]) && ![NN(d[@"cancelled"]) boolValue]; +} + +- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle + forRowAtIndexPath:(NSIndexPath *)indexPath { +} + +- (NSArray *)tableView:(UITableView *)tableView editActionsForRowAtIndexPath:(NSIndexPath *)indexPath { + return [BVGAPI editActionsForDeparture:self.departures[indexPath.row] stopName:self.stopName from:self]; +} + +@end diff --git a/BVGNearby/TripMapViewController.h b/BVGNearby/TripMapViewController.h new file mode 100644 index 0000000..fa79106 --- /dev/null +++ b/BVGNearby/TripMapViewController.h @@ -0,0 +1,5 @@ +#import + +@interface TripMapViewController : UIViewController +- (instancetype)initWithTripId:(NSString *)tripId line:(NSDictionary *)line; +@end diff --git a/BVGNearby/TripMapViewController.m b/BVGNearby/TripMapViewController.m new file mode 100644 index 0000000..2d74126 --- /dev/null +++ b/BVGNearby/TripMapViewController.m @@ -0,0 +1,158 @@ +#import "TripMapViewController.h" +#import "BVGAnnotation.h" +#import "BVGAPI.h" +#import + +@interface TripMapViewController () +@property (nonatomic, copy) NSString *tripId; +@property (nonatomic, strong) NSDictionary *line; +@property (nonatomic, strong) MKMapView *map; +@property (nonatomic, strong) BVGAnnotation *vehicle; +@property (nonatomic, strong) NSTimer *timer; +@property (nonatomic, assign) BOOL drawn; +@end + +@implementation TripMapViewController + +- (instancetype)initWithTripId:(NSString *)tripId line:(NSDictionary *)line { + self = [super init]; + if (self) { + _tripId = [tripId copy]; + _line = line; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.navigationItem.title = [NSString stringWithFormat:@"%@ live", NN(self.line[@"name"]) ?: @"Fahrt"]; + self.map = [[MKMapView alloc] initWithFrame:self.view.bounds]; + self.map.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.map.delegate = self; + self.map.showsUserLocation = YES; + self.map.showsPointsOfInterest = NO; + [self.view addSubview:self.map]; + [self load]; +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + self.timer = [NSTimer scheduledTimerWithTimeInterval:15 target:self selector:@selector(load) userInfo:nil repeats:YES]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [self.timer invalidate]; + self.timer = nil; +} + +- (void)load { + NSString *path = [NSString stringWithFormat:@"/trips/%@?stopovers=true&polyline=%@&remarks=false", + [BVGAPI encode:self.tripId], self.drawn ? @"false" : @"true"]; + [BVGAPI getJSON:path completion:^(id json, NSError *error) { + NSDictionary *trip = [json isKindOfClass:[NSDictionary class]] ? NN(json[@"trip"]) : nil; + dispatch_async(dispatch_get_main_queue(), ^{ + if (!trip) { NSLog(@"TripMap-Fehler: %@", error); return; } + if (!self.drawn) [self drawRoute:trip]; + [self updateVehicle:trip]; + }); + }]; +} + +- (void)drawRoute:(NSDictionary *)trip { + // Linienverlauf (GeoJSON FeatureCollection mit Punkten) + NSDictionary *poly = NN(trip[@"polyline"]); + NSArray *features = [poly isKindOfClass:[NSDictionary class]] ? NN(poly[@"features"]) : nil; + NSMutableData *coords = [NSMutableData data]; + for (NSDictionary *f in features) { + NSArray *c = NN(NN(f[@"geometry"])[@"coordinates"]); + if (![c isKindOfClass:[NSArray class]] || c.count < 2) continue; + CLLocationCoordinate2D p = CLLocationCoordinate2DMake([c[1] doubleValue], [c[0] doubleValue]); + [coords appendBytes:&p length:sizeof(p)]; + } + + // Halte + NSMutableArray *stopAnns = [NSMutableArray array]; + id stopovers = NN(trip[@"stopovers"]); + if ([stopovers isKindOfClass:[NSArray class]]) { + for (NSDictionary *so in stopovers) { + NSDictionary *stop = NN(so[@"stop"]); + NSDictionary *loc = NN(stop[@"location"]); + if (![loc isKindOfClass:[NSDictionary class]]) continue; + CLLocationCoordinate2D p = CLLocationCoordinate2DMake([loc[@"latitude"] doubleValue], [loc[@"longitude"] doubleValue]); + + BVGAnnotation *a = [[BVGAnnotation alloc] init]; + a.kind = BVGAnnotationKindTripStop; + a.title = NN(stop[@"name"]) ?: @"?"; + NSDate *t = [BVGAPI dateFromISO:so[@"departure"]] ?: [BVGAPI dateFromISO:so[@"arrival"]]; + a.subtitle = t ? [BVGAPI hhmm:t] : nil; + a.coordinate = p; + [stopAnns addObject:a]; + + // ohne Polyline: Halte direkt verbinden + if (!features.count) [coords appendBytes:&p length:sizeof(p)]; + } + } + + NSUInteger n = coords.length / sizeof(CLLocationCoordinate2D); + if (n >= 2) { + MKPolyline *line = [MKPolyline polylineWithCoordinates:(CLLocationCoordinate2D *)coords.bytes count:n]; + [self.map addOverlay:line]; + [self.map setVisibleMapRect:line.boundingMapRect edgePadding:UIEdgeInsetsMake(40, 30, 40, 30) animated:NO]; + } + [self.map addAnnotations:stopAnns]; + self.drawn = YES; +} + +- (void)updateVehicle:(NSDictionary *)trip { + NSDictionary *loc = NN(trip[@"currentLocation"]); + if (![loc isKindOfClass:[NSDictionary class]]) return; + CLLocationCoordinate2D c = CLLocationCoordinate2DMake([loc[@"latitude"] doubleValue], [loc[@"longitude"] doubleValue]); + + if (!self.vehicle) { + self.vehicle = [[BVGAnnotation alloc] init]; + self.vehicle.kind = BVGAnnotationKindVehicle; + self.vehicle.line = self.line; + self.vehicle.title = [NSString stringWithFormat:@"%@ → %@", NN(self.line[@"name"]) ?: @"?", NN(trip[@"direction"]) ?: @""]; + self.vehicle.coordinate = c; + [self.map addAnnotation:self.vehicle]; + } else { + [UIView animateWithDuration:2.0 animations:^{ self.vehicle.coordinate = c; }]; + } +} + +#pragma mark - MKMapViewDelegate + +- (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id)overlay { + MKPolylineRenderer *r = [[MKPolylineRenderer alloc] initWithPolyline:(MKPolyline *)overlay]; + r.strokeColor = [[BVGAPI colorForLine:self.line] colorWithAlphaComponent:0.85]; + r.lineWidth = 5; + r.lineCap = kCGLineCapRound; + r.lineJoin = kCGLineJoinRound; + return r; +} + +- (MKAnnotationView *)mapView:(MKMapView *)mapView viewForAnnotation:(id)annotation { + if (![annotation isKindOfClass:[BVGAnnotation class]]) return nil; + BVGAnnotation *a = (BVGAnnotation *)annotation; + NSString *reuse = a.kind == BVGAnnotationKindVehicle ? @"veh" : @"halt"; + + MKAnnotationView *v = [mapView dequeueReusableAnnotationViewWithIdentifier:reuse]; + if (!v) { + v = [[MKAnnotationView alloc] initWithAnnotation:a reuseIdentifier:reuse]; + v.canShowCallout = YES; + } + v.annotation = a; + if (a.kind == BVGAnnotationKindVehicle) { + v.image = [BVGAPI badgeForLine:a.line]; + v.layer.zPosition = 10; + v.layer.shadowOpacity = 0.4; + v.layer.shadowRadius = 2; + v.layer.shadowOffset = CGSizeMake(0, 1); + } else { + v.image = [BVGAPI dotWithColor:[BVGAPI colorForLine:self.line] filled:NO]; + } + return v; +} + +@end diff --git a/BVGNearby/TripViewController.h b/BVGNearby/TripViewController.h new file mode 100644 index 0000000..d561d46 --- /dev/null +++ b/BVGNearby/TripViewController.h @@ -0,0 +1,7 @@ +#import "CardTableViewController.h" + +@interface TripViewController : CardTableViewController +- (instancetype)initWithTripId:(NSString *)tripId; +@property (nonatomic, strong) NSDictionary *line; // line-Objekt aus der Abfahrt +@property (nonatomic, copy) NSString *stopName; // eigene Haltestelle (wird hervorgehoben) +@end diff --git a/BVGNearby/TripViewController.m b/BVGNearby/TripViewController.m new file mode 100644 index 0000000..f4aef60 --- /dev/null +++ b/BVGNearby/TripViewController.m @@ -0,0 +1,251 @@ +#import "TripViewController.h" +#import "TripMapViewController.h" +#import "BVGAPI.h" + +#pragma mark - Timeline-Zelle + +@interface TimelineView : UIView +@property (nonatomic, strong) UIColor *color; +@property (nonatomic, assign) BOOL topLine, bottomLine, past, current; +@end + +@implementation TimelineView +- (void)drawRect:(CGRect)rect { + CGFloat cx = self.bounds.size.width / 2, cy = self.bounds.size.height / 2; + UIColor *passed = [BVGTheme textTertiary]; + // Linie oben (zum vorherigen Halt) und unten (zum naechsten) + if (self.topLine) { + [(self.past || self.current ? passed : self.color) setFill]; + UIRectFill(CGRectMake(cx - 2, 0, 4, cy)); + } + if (self.bottomLine) { + [(self.past ? passed : self.color) setFill]; + UIRectFill(CGRectMake(cx - 2, cy, 4, self.bounds.size.height - cy)); + } + CGFloat r = self.current ? 8 : 6; + UIBezierPath *dot = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(cx - r, cy - r, 2 * r, 2 * r)]; + if (self.current) { + [self.color setFill]; [dot fill]; + UIBezierPath *inner = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(cx - 3, cy - 3, 6, 6)]; + [[UIColor whiteColor] setFill]; [inner fill]; + } else { + [[UIColor whiteColor] setFill]; [dot fill]; + dot.lineWidth = 3; + [(self.past ? passed : self.color) setStroke]; [dot stroke]; + } +} +@end + +@interface TimelineCell : CardCell +@property (nonatomic, strong) TimelineView *timeline; +@property (nonatomic, strong) UILabel *nameLabel, *timeLabel, *noteLabel; +@end + +@implementation TimelineCell +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { + self = [super initWithStyle:UITableViewCellStyleDefault reuseIdentifier:reuseIdentifier]; + if (self) { + self.selectionStyle = UITableViewCellSelectionStyleNone; + _timeline = [[TimelineView alloc] init]; + _timeline.backgroundColor = [UIColor clearColor]; + _timeline.contentMode = UIViewContentModeRedraw; + [self.contentView addSubview:_timeline]; + _nameLabel = [[UILabel alloc] init]; + [self.contentView addSubview:_nameLabel]; + _noteLabel = [[UILabel alloc] init]; + _noteLabel.font = [BVGTheme font:12 weight:UIFontWeightMedium]; + [self.contentView addSubview:_noteLabel]; + _timeLabel = [[UILabel alloc] init]; + _timeLabel.textAlignment = NSTextAlignmentRight; + _timeLabel.font = [BVGTheme digits:15 weight:UIFontWeightMedium]; + [self.contentView addSubview:_timeLabel]; + } + return self; +} +- (void)layoutSubviews { + [super layoutSubviews]; + CGFloat w = self.contentView.bounds.size.width, h = self.contentView.bounds.size.height; + self.timeline.frame = CGRectMake(12, 0, 28, h); + BOOL note = self.noteLabel.text.length > 0; + self.nameLabel.frame = CGRectMake(50, note ? 7 : 0, w - 50 - 92, note ? 20 : h); + self.noteLabel.frame = CGRectMake(50, 27, w - 50 - 92, 15); + self.timeLabel.frame = CGRectMake(w - 92, 0, 80, h); +} +@end + +#pragma mark - Controller + +@interface TripViewController () +@property (nonatomic, copy) NSString *tripId; +@property (nonatomic, copy) NSString *direction; +@property (nonatomic, strong) NSArray *stopovers; +@property (nonatomic, assign) NSInteger currentIndex; +@property (nonatomic, assign) BOOL didScroll; +@property (nonatomic, strong) NSTimer *timer; +@end + +@implementation TripViewController + +- (instancetype)initWithTripId:(NSString *)tripId { + self = [super initWithStyle:UITableViewStyleGrouped]; + if (self) { + _tripId = [tripId copy]; + _stopovers = @[]; + _currentIndex = -1; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.navigationItem.title = NN(self.line[@"name"]) ?: @"Fahrt"; + self.tableView.rowHeight = 50; + [self buildHeaderWithDirection:nil]; + + self.refreshControl = [[UIRefreshControl alloc] init]; + [self.refreshControl addTarget:self action:@selector(load) forControlEvents:UIControlEventValueChanged]; + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Karte" + style:UIBarButtonItemStylePlain + target:self action:@selector(openMap)]; + [self load]; +} + +// Kopf: grosses Linien-Badge + Richtung +- (void)buildHeaderWithDirection:(NSString *)direction { + CGFloat w = self.tableView.bounds.size.width ?: [UIScreen mainScreen].bounds.size.width; + CGFloat x = [BVGTheme cardInset] + 4; + UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, w, 86)]; + + UIImageView *badge = [[UIImageView alloc] initWithImage:[BVGAPI badgeForLine:self.line]]; + badge.frame = CGRectMake(x, 10, badge.image.size.width, badge.image.size.height); + [header addSubview:badge]; + + UILabel *dir = [[UILabel alloc] initWithFrame:CGRectMake(x, 40, w - 2 * x, 34)]; + dir.font = [BVGTheme font:26 weight:UIFontWeightBold]; + dir.textColor = [BVGTheme textPrimary]; + dir.adjustsFontSizeToFitWidth = YES; + dir.minimumScaleFactor = 0.6; + dir.text = direction ? [@"→ " stringByAppendingString:direction] : @"Lade Fahrtverlauf …"; + dir.autoresizingMask = UIViewAutoresizingFlexibleWidth; + [header addSubview:dir]; + self.tableView.tableHeaderView = header; +} + +- (void)openMap { + TripMapViewController *map = [[TripMapViewController alloc] initWithTripId:self.tripId line:self.line]; + [self.navigationController pushViewController:map animated:YES]; +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + self.timer = [NSTimer scheduledTimerWithTimeInterval:30 target:self selector:@selector(load) userInfo:nil repeats:YES]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [self.timer invalidate]; + self.timer = nil; +} + +- (void)load { + NSString *path = [NSString stringWithFormat:@"/trips/%@?stopovers=true&remarks=false&polyline=false", [BVGAPI encode:self.tripId]]; + [BVGAPI getJSON:path completion:^(id json, NSError *error) { + NSDictionary *trip = [json isKindOfClass:[NSDictionary class]] ? NN(json[@"trip"]) : nil; + id rawStops = NN(trip[@"stopovers"]); + NSArray *stops = [rawStops isKindOfClass:[NSArray class]] ? rawStops : @[]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self.refreshControl endRefreshing]; + if (!trip) { + NSLog(@"Trip-Fehler: %@", error); + if (!self.stopovers.count) [self showEmptyIcon:@"📡" title:@"Nicht verfügbar" text:@"Der Fahrtverlauf konnte nicht geladen werden."]; + return; + } + [self showEmptyIcon:nil title:nil text:nil]; + NSString *dir = NN(trip[@"direction"]); + if (!(dir == self.direction || [dir isEqualToString:self.direction])) { + self.direction = dir; + [self buildHeaderWithDirection:dir]; + } + self.stopovers = stops; + self.currentIndex = [self findCurrentIndex]; + [self.tableView reloadData]; + + if (!self.didScroll && self.currentIndex >= 0 && self.currentIndex < (NSInteger)stops.count) { + self.didScroll = YES; + [self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:self.currentIndex inSection:0] + atScrollPosition:UITableViewScrollPositionMiddle animated:NO]; + } + }); + }]; +} + +- (NSDate *)timeOf:(NSDictionary *)so { + return [BVGAPI dateFromISO:so[@"departure"]] ?: [BVGAPI dateFromISO:so[@"arrival"]] + ?: [BVGAPI dateFromISO:so[@"plannedDeparture"]] ?: [BVGAPI dateFromISO:so[@"plannedArrival"]]; +} + +- (NSInteger)findCurrentIndex { + NSDate *now = [NSDate date]; + for (NSInteger i = 0; i < (NSInteger)self.stopovers.count; i++) { + NSDate *t = [self timeOf:self.stopovers[i]]; + if (t && [t compare:now] != NSOrderedAscending) return i; + } + return (NSInteger)self.stopovers.count - 1; +} + +#pragma mark - Table + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return self.stopovers.count; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { + NSDictionary *so = self.stopovers[indexPath.row]; + NSString *name = NN(NN(so[@"stop"])[@"name"]); + BOOL mine = self.stopName && [name isEqualToString:self.stopName]; + return (indexPath.row == self.currentIndex || mine || [NN(so[@"cancelled"]) boolValue]) ? 52 : 44; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + TimelineCell *cell = [tableView dequeueReusableCellWithIdentifier:@"stop"]; + if (!cell) cell = [[TimelineCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"stop"]; + + NSDictionary *so = self.stopovers[indexPath.row]; + NSString *name = NN(NN(so[@"stop"])[@"name"]) ?: @"?"; + BOOL past = indexPath.row < self.currentIndex; + BOOL current = indexPath.row == self.currentIndex; + BOOL mine = self.stopName && [name isEqualToString:self.stopName]; + BOOL cancelled = [NN(so[@"cancelled"]) boolValue]; + + cell.timeline.color = [BVGAPI colorForLine:self.line]; + cell.timeline.topLine = indexPath.row > 0; + cell.timeline.bottomLine = indexPath.row < (NSInteger)self.stopovers.count - 1; + cell.timeline.past = past; + cell.timeline.current = current; + [cell.timeline setNeedsDisplay]; + + cell.nameLabel.text = name; + cell.nameLabel.font = (current || mine) ? [BVGTheme font:16 weight:UIFontWeightSemibold] : [BVGTheme font:16 weight:UIFontWeightRegular]; + cell.nameLabel.textColor = past ? [BVGTheme textTertiary] : [BVGTheme textPrimary]; + + if (cancelled) { cell.noteLabel.text = @"Halt entfällt"; cell.noteLabel.textColor = [BVGTheme red]; } + else if (current) { cell.noteLabel.text = @"Nächster Halt"; cell.noteLabel.textColor = [BVGTheme green]; } + else if (mine) { cell.noteLabel.text = @"Deine Haltestelle"; cell.noteLabel.textColor = [BVGTheme link]; } + else { cell.noteLabel.text = nil; } + + NSDate *t = [self timeOf:so]; + NSInteger delay = [(NN(so[@"departureDelay"]) ?: NN(so[@"arrivalDelay"])) integerValue] / 60; + NSMutableAttributedString *time = [[NSMutableAttributedString alloc] initWithString:[BVGAPI hhmm:t] attributes:@{ + NSForegroundColorAttributeName: past ? [BVGTheme textTertiary] : [BVGTheme textPrimary] }]; + if (delay > 0) { + [time appendAttributedString:[[NSAttributedString alloc] initWithString:[NSString stringWithFormat:@" +%ld", (long)delay] + attributes:@{ NSForegroundColorAttributeName: [BVGTheme red] }]]; + } + cell.timeLabel.attributedText = time; + cell.cardColor = current ? [BVGTheme accentSoft] : nil; + [cell setNeedsLayout]; + return cell; +} + +@end diff --git a/BVGNearby/ViewController.h b/BVGNearby/ViewController.h index f60ca23..06a4fe7 100644 --- a/BVGNearby/ViewController.h +++ b/BVGNearby/ViewController.h @@ -1,14 +1,4 @@ -// -// ViewController.h -// BVGNearby -// -// Created by Jan Aaron Titus Kastl on 23.10.26. -// - -#import - -@interface ViewController : UIViewController - +#import "CardTableViewController.h" +@interface ViewController : CardTableViewController @end - diff --git a/BVGNearby/ViewController.m b/BVGNearby/ViewController.m index e52369e..db94a56 100644 --- a/BVGNearby/ViewController.m +++ b/BVGNearby/ViewController.m @@ -1,22 +1,550 @@ -// -// ViewController.m -// BVGNearby -// -// Created by Jan Aaron Titus Kastl on 23.10.26. -// - #import "ViewController.h" +#import "TripViewController.h" +#import "StopViewController.h" +#import "DepartureCell.h" +#import "BVGAPI.h" +#import "BVGSettings.h" +#import "SettingsViewController.h" +#import "DisruptionsViewController.h" +#import -@interface ViewController () +static NSString * const kCacheKey = @"nearbyCache"; +@interface ViewController () +@property (nonatomic, strong) CLLocationManager *locationManager; +@property (nonatomic, strong) NSArray *stops; // [NSMutableDictionary: id, name, distance, fav, departures] +@property (nonatomic, strong) NSTimer *timer; +@property (nonatomic, strong) NSTimer *tick; // Minuten lokal herunterzaehlen (auch offline) +@property (nonatomic, strong) NSDate *dataTime; // Stand der angezeigten Daten +@property (nonatomic, assign) BOOL offline; +@property (nonatomic, assign) NSInteger bannerCount; +@property (nonatomic, assign) BOOL loading; +@property (nonatomic, assign) BOOL waitingForLocation; @end @implementation ViewController +#pragma mark - Lifecycle + - (void)viewDidLoad { [super viewDidLoad]; - // Do any additional setup after loading the view. + self.largeTitle = @"Abfahrten"; + self.largeSubtitle = @"In deiner Nähe"; + self.stops = @[]; + self.bannerCount = -1; + self.tableView.rowHeight = [DepartureCell height]; + + self.refreshControl = [[UIRefreshControl alloc] init]; + [self.refreshControl addTarget:self action:@selector(refresh) forControlEvents:UIControlEventValueChanged]; + + self.locationManager = [[CLLocationManager alloc] init]; + self.locationManager.delegate = self; + self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters; + + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + [nc addObserver:self selector:@selector(refresh) name:UIApplicationDidBecomeActiveNotification object:nil]; + [nc addObserver:self selector:@selector(favoritesChanged) name:BVGFavoritesChangedNotification object:nil]; + [nc addObserver:self.tableView selector:@selector(reloadData) name:BVGAlarmsChangedNotification object:nil]; + [nc addObserver:self selector:@selector(settingsChanged) name:BVGSettingsChangedNotification object:nil]; + + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Ansage" + style:UIBarButtonItemStylePlain + target:self action:@selector(announceNearest)]; + self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"⚙︎" + style:UIBarButtonItemStylePlain + target:self action:@selector(openSettings)]; + [self.navigationItem.leftBarButtonItem setTitleTextAttributes:@{ NSFontAttributeName: [UIFont systemFontOfSize:22] } + forState:UIControlStateNormal]; + + // Sofort die letzten bekannten Daten zeigen, waehrend neu geladen wird + if (![self loadCache]) [self showEmptyIcon:@"📍" title:@"Einen Moment" text:@"Suche Haltestellen in deiner Nähe …"]; + + if ([CLLocationManager authorizationStatus] == kCLAuthorizationStatusNotDetermined) { + [self.locationManager requestWhenInUseAuthorization]; + } else { + [self refresh]; + } } +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [[NSNotificationCenter defaultCenter] removeObserver:self.tableView]; +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + [self.tableView reloadData]; + self.timer = [NSTimer scheduledTimerWithTimeInterval:30 target:self selector:@selector(refresh) userInfo:nil repeats:YES]; + self.tick = [NSTimer scheduledTimerWithTimeInterval:15 target:self selector:@selector(tickUpdate) userInfo:nil repeats:YES]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [self.timer invalidate]; + [self.tick invalidate]; + self.timer = nil; + self.tick = nil; +} + +- (void)tickUpdate { + if (self.tableView.editing || self.tableView.isDragging || self.loading) return; // Wischaktionen nicht stoeren + [self updateStatusHeader]; + [self.tableView reloadData]; +} + +- (void)openSettings { + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController: + [[SettingsViewController alloc] initWithStyle:UITableViewStyleGrouped]]; + [self presentViewController:nav animated:YES completion:nil]; +} + +- (void)settingsChanged { + [self.tableView reloadData]; + [self refresh]; +} + +#pragma mark - Cache & Offline-Hinweis + +- (void)saveCache { + NSDictionary *payload = @{ @"time": @([[NSDate date] timeIntervalSince1970]), @"stops": self.stops ?: @[] }; + NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; + if (data) [[NSUserDefaults standardUserDefaults] setObject:data forKey:kCacheKey]; +} + +- (BOOL)loadCache { + NSData *data = [[NSUserDefaults standardUserDefaults] objectForKey:kCacheKey]; + if (![data isKindOfClass:[NSData class]]) return NO; + NSDictionary *payload = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:nil]; + NSArray *stops = payload[@"stops"]; + NSDate *time = [NSDate dateWithTimeIntervalSince1970:[payload[@"time"] doubleValue]]; + if (![stops isKindOfClass:[NSArray class]] || !stops.count || [time timeIntervalSinceNow] < -6 * 3600) return NO; + self.stops = stops; + self.dataTime = time; + [self updateStatusHeader]; + [self updateDisruptionBanner]; + [self.tableView reloadData]; + return YES; +} + +// Untertitel unter dem grossen Titel: Stand / offline +- (void)updateStatusHeader { + NSTimeInterval age = self.dataTime ? -[self.dataTime timeIntervalSinceNow] : 0; + if (self.offline && self.dataTime) { + self.largeSubtitleColor = [BVGTheme red]; + self.largeSubtitle = [NSString stringWithFormat:@"Offline · Daten von %@", [BVGAPI hhmm:self.dataTime]]; + } else if (self.dataTime && age > 120) { + self.largeSubtitleColor = [BVGTheme orange]; + self.largeSubtitle = [NSString stringWithFormat:@"Stand %@ · wird aktualisiert …", [BVGAPI hhmm:self.dataTime]]; + } else if (self.dataTime) { + self.largeSubtitleColor = nil; + self.largeSubtitle = [NSString stringWithFormat:@"In deiner Nähe · Live %@", [BVGAPI hhmm:self.dataTime]]; + } else { + self.largeSubtitleColor = nil; + self.largeSubtitle = @"In deiner Nähe"; + } +} + +// gelbes Banner unter dem Titel, wenn es an den Haltestellen Stoerungen gibt +- (void)updateDisruptionBanner { + NSInteger count = [DisruptionsViewController countInCollection:[DisruptionsViewController collectFromStops:self.stops]]; + if (count == self.bannerCount) return; + self.bannerCount = count; + if (count == 0) { self.headerAccessory = nil; return; } + + CGFloat w = self.view.bounds.size.width; + UIView *v = [[UIView alloc] initWithFrame:CGRectMake(0, 0, w, 52)]; + UIButton *b = [UIButton buttonWithType:UIButtonTypeCustom]; + b.frame = CGRectMake([BVGTheme cardInset], 4, w - 2 * [BVGTheme cardInset], 44); + b.autoresizingMask = UIViewAutoresizingFlexibleWidth; + b.backgroundColor = [BVGTheme accentSoft]; + b.layer.cornerRadius = [BVGTheme cardRadius]; + b.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft; + b.contentEdgeInsets = UIEdgeInsetsMake(0, 14, 0, 14); + b.titleLabel.font = [BVGTheme font:15 weight:UIFontWeightSemibold]; + [b setTitleColor:[UIColor colorWithRed:0.62 green:0.36 blue:0 alpha:1] forState:UIControlStateNormal]; + [b setTitle:[NSString stringWithFormat:@"⚠︎ %ld %@ an deinen Haltestellen ›", (long)count, + count == 1 ? @"Hinweis" : @"Hinweise"] forState:UIControlStateNormal]; + [b addTarget:self action:@selector(openDisruptions) forControlEvents:UIControlEventTouchUpInside]; + [v addSubview:b]; + self.headerAccessory = v; +} + +- (void)openDisruptions { + DisruptionsViewController *vc = [[DisruptionsViewController alloc] + initWithStops:[DisruptionsViewController collectFromStops:self.stops]]; + [self.navigationController pushViewController:vc animated:YES]; +} + +- (void)showMessage:(NSString *)text { + if (!text) { [self showEmptyIcon:nil title:nil text:nil]; return; } + [self showEmptyIcon:@"🚏" title:nil text:text]; +} + +#pragma mark - Ansage & Schütteln + +- (void)announceStop:(NSDictionary *)stop { + if ([BVGAPI isSpeaking]) { [BVGAPI stopSpeaking]; return; } + [BVGAPI speak:[BVGAPI announcementForStop:stop[@"name"] departures:stop[@"departures"] max:3]]; +} + +- (void)announceNearest { + if (!self.stops.count) return; + // naechstgelegene Haltestelle = kleinste Entfernung, sonst erste + NSDictionary *best = self.stops.firstObject; + for (NSDictionary *s in self.stops) { + if (s[@"distance"] && (!best[@"distance"] || [s[@"distance"] doubleValue] < [best[@"distance"] doubleValue])) best = s; + } + [self announceStop:best]; +} + +- (void)announceFromHeader:(UILongPressGestureRecognizer *)press { + if (press.state != UIGestureRecognizerStateBegan) return; + NSInteger section = press.view.tag; + if (section < (NSInteger)self.stops.count) [self announceStop:self.stops[section]]; +} + +- (BOOL)canBecomeFirstResponder { + return YES; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self becomeFirstResponder]; +} + +- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event { + if (motion != UIEventSubtypeMotionShake) return; + [self.refreshControl beginRefreshing]; + [self refresh]; +} + +#pragma mark - Favoriten + +- (void)favoritesChanged { + for (NSMutableDictionary *e in self.stops) e[@"fav"] = @([BVGAPI isFavorite:e[@"id"]]); + NSMutableArray *favs = [NSMutableArray array]; + NSMutableArray *rest = [NSMutableArray array]; + for (NSMutableDictionary *e in self.stops) [([e[@"fav"] boolValue] ? favs : rest) addObject:e]; + [favs addObjectsFromArray:rest]; + self.stops = favs; + [self.tableView reloadData]; +} + +- (void)toggleFavorite:(UIButton *)sender { + if (sender.tag >= (NSInteger)self.stops.count) return; + NSDictionary *stop = self.stops[sender.tag]; + [BVGAPI setFavorite:![stop[@"fav"] boolValue] stopId:stop[@"id"] name:stop[@"name"]]; +} + +- (void)openStopFromHeader:(UITapGestureRecognizer *)tap { + NSInteger section = tap.view.tag; + if (section >= (NSInteger)self.stops.count) return; + [self openStop:self.stops[section]]; +} + +- (void)openStop:(NSDictionary *)stop { + StopViewController *vc = [[StopViewController alloc] initWithStopId:stop[@"id"] name:stop[@"name"]]; + [self.navigationController pushViewController:vc animated:YES]; +} + +#pragma mark - Laden + +- (void)refresh { + if (self.loading) return; + self.loading = YES; + + CLAuthorizationStatus st = [CLLocationManager authorizationStatus]; + if (st == kCLAuthorizationStatusAuthorizedWhenInUse || st == kCLAuthorizationStatusAuthorizedAlways) { + self.waitingForLocation = YES; + [self.locationManager requestLocation]; + } else { + [self loadNearby:nil]; + } +} + +- (void)locationManager:(CLLocationManager *)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status { + if (status != kCLAuthorizationStatusNotDetermined) [self refresh]; +} + +- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations { + if (!self.waitingForLocation) return; + self.waitingForLocation = NO; + [self loadNearby:locations.lastObject]; +} + +- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error { + NSLog(@"Location-Fehler: %@", error); + if (!self.waitingForLocation) return; + self.waitingForLocation = NO; + [self loadNearby:nil]; +} + +- (void)loadNearby:(CLLocation *)loc { + if (!loc) { + [self loadDeparturesWithNearby:@[] hadLocation:NO nearbyFailed:NO]; + return; + } + NSString *path = [NSString stringWithFormat: + @"/locations/nearby?latitude=%.6f&longitude=%.6f&results=6&distance=%ld&poi=false&addresses=false", + loc.coordinate.latitude, loc.coordinate.longitude, (long)[BVGSettings radius]]; + [BVGAPI getJSON:path completion:^(id json, NSError *error) { + BOOL failed = ![json isKindOfClass:[NSArray class]]; + if (failed) NSLog(@"Nearby-Fehler: %@", error); + dispatch_async(dispatch_get_main_queue(), ^{ + [self loadDeparturesWithNearby:(failed ? @[] : json) hadLocation:YES nearbyFailed:failed]; + }); + }]; +} + +- (void)loadDeparturesWithNearby:(NSArray *)nearby hadLocation:(BOOL)hadLocation nearbyFailed:(BOOL)nearbyFailed { + // alte Daten merken: bei Funkloch bleiben sie stehen statt zu verschwinden + NSMutableDictionary *oldById = [NSMutableDictionary dictionary]; + for (NSDictionary *s in self.stops) if (s[@"id"]) oldById[s[@"id"]] = s; + + NSMutableArray *list = [NSMutableArray array]; + NSMutableDictionary *byId = [NSMutableDictionary dictionary]; + + for (NSDictionary *f in [BVGAPI favorites]) { + NSMutableDictionary *e = [@{ @"id": f[@"id"], @"name": f[@"name"], @"fav": @YES } mutableCopy]; + if (oldById[f[@"id"]][@"distance"]) e[@"distance"] = oldById[f[@"id"]][@"distance"]; + [list addObject:e]; + byId[f[@"id"]] = e; + } + // Umgebung nicht ladbar -> bisherige Haltestellen weiterverwenden + NSArray *source = nearbyFailed ? self.stops : nearby; + for (NSDictionary *n in source) { + NSString *sid = NN(n[@"id"]); + if (!sid || [n[@"fav"] boolValue]) continue; + NSNumber *dist = NN(n[@"distance"]) ?: @0; + if (byId[sid]) { byId[sid][@"distance"] = dist; continue; } + NSMutableDictionary *e = [@{ @"id": sid, @"name": NN(n[@"name"]) ?: @"?", @"distance": dist, @"fav": @NO } mutableCopy]; + [list addObject:e]; + byId[sid] = e; + } + + if (!list.count) { + self.stops = @[]; + self.offline = nearbyFailed; + [self.tableView reloadData]; + if (nearbyFailed) { + [self showEmptyIcon:@"📡" title:@"Keine Verbindung" text:@"Zum Wiederholen nach unten ziehen oder das iPhone schütteln."]; + } else if (hadLocation) { + [self showEmptyIcon:@"🔍" title:@"Nichts in der Nähe" + text:[NSString stringWithFormat:@"Keine Haltestellen im Umkreis von %ld m. Den Radius kannst du in den Einstellungen vergrößern.", (long)[BVGSettings radius]]]; + } else { + [self showEmptyIcon:@"📍" title:@"Kein Standort" + text:@"Erlaube den Standortzugriff in den iOS-Einstellungen oder lege Favoriten über die Suche an."]; + } + [self finish]; + return; + } + + __block BOOL anyFailed = nearbyFailed; + NSInteger perStop = [BVGSettings departuresPerStop]; + dispatch_group_t group = dispatch_group_create(); + for (NSMutableDictionary *entry in list) { + dispatch_group_enter(group); + NSString *path = [NSString stringWithFormat:@"/stops/%@/departures?duration=45&results=%ld", + entry[@"id"], (long)(perStop + 6)]; + NSArray *old = oldById[entry[@"id"]][@"departures"]; + [BVGAPI getJSON:path completion:^(id json, NSError *error) { + NSArray *deps = nil; + if ([json isKindOfClass:[NSDictionary class]] && [json[@"departures"] isKindOfClass:[NSArray class]]) { + deps = json[@"departures"]; + } + @synchronized (entry) { + if (deps) entry[@"departures"] = deps; + else { + entry[@"departures"] = old ?: @[]; // Fehler: alte Abfahrten behalten + anyFailed = YES; + } + } + dispatch_group_leave(group); + }]; + } + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + self.stops = list; + self.offline = anyFailed; + if (!anyFailed) { + self.dataTime = [NSDate date]; + [self saveCache]; + } + [self showMessage:nil]; + [self updateStatusHeader]; + [self updateDisruptionBanner]; + [self.tableView reloadData]; + [self finish]; + }); +} + +- (void)finish { + dispatch_async(dispatch_get_main_queue(), ^{ + self.loading = NO; + [self.refreshControl endRefreshing]; + self.refreshControl.attributedTitle = [[NSAttributedString alloc] + initWithString:[NSString stringWithFormat:@"Stand %@", [BVGAPI hhmm:self.dataTime ?: [NSDate date]]]]; + [self updateStatusHeader]; + }); +} + +#pragma mark - Helfer + +- (NSArray *)departuresInSection:(NSInteger)section { + if (section >= (NSInteger)self.stops.count) return @[]; + NSArray *deps = self.stops[section][@"departures"] ?: @[]; + // gemerkter Richtungsfilter dieser Haltestelle + NSString *dir = [BVGSettings directionFilterForStop:self.stops[section][@"id"]]; + if (dir) { + NSMutableArray *f = [NSMutableArray array]; + for (NSDictionary *d in deps) if ([NN(d[@"direction"]) isEqualToString:dir]) [f addObject:d]; + deps = f; + } + NSUInteger max = (NSUInteger)[BVGSettings departuresPerStop]; + return deps.count > max ? [deps subarrayWithRange:NSMakeRange(0, max)] : deps; +} + +- (NSDictionary *)departureAt:(NSIndexPath *)ip { + NSArray *deps = [self departuresInSection:ip.section]; + return ip.row < (NSInteger)deps.count ? deps[ip.row] : nil; +} + +- (BOOL)isMoreRow:(NSIndexPath *)ip { + return ip.row == [self tableView:self.tableView numberOfRowsInSection:ip.section] - 1; +} + +#pragma mark - Table + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + return self.stops.count; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { + return 62; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { + NSDictionary *s = self.stops[section]; + CGFloat w = tableView.bounds.size.width; + CGFloat x = [BVGTheme cardInset] + 4; + + UIView *v = [[UIView alloc] initWithFrame:CGRectMake(0, 0, w, 62)]; + v.tag = section; + [v addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(openStopFromHeader:)]]; + [v addGestureRecognizer:[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(announceFromHeader:)]]; + v.isAccessibilityElement = YES; + v.accessibilityTraits = UIAccessibilityTraitHeader | UIAccessibilityTraitButton; + v.accessibilityLabel = s[@"name"]; + + UILabel *name = [[UILabel alloc] initWithFrame:CGRectMake(x, 16, w - x - 56, 25)]; + name.autoresizingMask = UIViewAutoresizingFlexibleWidth; + name.font = [BVGTheme font:21 weight:UIFontWeightBold]; + name.textColor = [BVGTheme textPrimary]; + name.text = s[@"name"]; + [v addSubview:name]; + + UILabel *meta = [[UILabel alloc] initWithFrame:CGRectMake(x, 41, w - x - 56, 16)]; + meta.autoresizingMask = UIViewAutoresizingFlexibleWidth; + meta.font = [BVGTheme font:13 weight:UIFontWeightMedium]; + meta.textColor = [BVGTheme textSecondary]; + NSNumber *dist = s[@"distance"]; + NSMutableArray *parts = [NSMutableArray array]; + if ([s[@"fav"] boolValue]) [parts addObject:@"★ Favorit"]; + NSString *dirFilter = [BVGSettings directionFilterForStop:s[@"id"]]; + if (dirFilter) [parts addObject:[@"→ " stringByAppendingString:dirFilter]]; + if (dist) [parts addObject:[NSString stringWithFormat:@"%@ m · %ld min zu Fuß", dist, (long)[self walkMinutesToStop:s]]]; + meta.text = [parts componentsJoinedByString:@" · "]; + [v addSubview:meta]; + + UIButton *star = [UIButton buttonWithType:UIButtonTypeCustom]; + star.frame = CGRectMake(w - [BVGTheme cardInset] - 44, 14, 44, 44); + star.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin; + star.titleLabel.font = [UIFont systemFontOfSize:24]; + [star setTitle:([s[@"fav"] boolValue] ? @"★" : @"☆") forState:UIControlStateNormal]; + [star setTitleColor:([s[@"fav"] boolValue] ? [BVGTheme orange] : [BVGTheme textTertiary]) forState:UIControlStateNormal]; + star.accessibilityLabel = [s[@"fav"] boolValue] ? @"Favorit entfernen" : @"Als Favorit merken"; + star.tag = section; + [star addTarget:self action:@selector(toggleFavorite:) forControlEvents:UIControlEventTouchUpInside]; + [v addSubview:star]; + return v; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return MAX([self departuresInSection:section].count, 1) + 1; // + "Alle Abfahrten" +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { + return [self isMoreRow:indexPath] ? 44 : [DepartureCell height]; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + if ([self isMoreRow:indexPath]) { + CardCell *cell = [tableView dequeueReusableCellWithIdentifier:@"more"]; + if (!cell) cell = [[CardCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"more"]; + cell.textLabel.text = @"Alle Abfahrten & Hinweise"; + cell.textLabel.font = [BVGTheme font:15 weight:UIFontWeightMedium]; + cell.textLabel.textColor = [BVGTheme link]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + return cell; + } + + DepartureCell *cell = [tableView dequeueReusableCellWithIdentifier:@"dep"]; + if (!cell) cell = [[DepartureCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"dep"]; + NSDictionary *d = [self departureAt:indexPath]; + if (d) { + [cell configureWithDeparture:d]; + // zu Fuss nicht mehr erreichbar? -> ausgrauen + NSDictionary *stop = self.stops[indexPath.section]; + NSDate *when = [BVGAPI whenOfDeparture:d]; + BOOL tooLate = stop[@"distance"] && when && + [when timeIntervalSinceNow] / 60.0 < [self walkMinutesToStop:stop]; + [cell setDimmed:tooLate]; + } else { + [cell configureEmpty:@"Keine Abfahrten in den nächsten 45 min"]; + } + return cell; +} + +// Luftlinie * 1,3 (Umwege) bei ca. 80 m/min Gehtempo, mindestens 1 min +- (NSInteger)walkMinutesToStop:(NSDictionary *)stop { + double dist = [stop[@"distance"] doubleValue]; + return MAX(1, (NSInteger)ceil(dist * 1.3 / 80.0)); +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + if ([self isMoreRow:indexPath]) { + [self openStop:self.stops[indexPath.section]]; + return; + } + + NSDictionary *d = [self departureAt:indexPath]; + NSString *tripId = NN(d[@"tripId"]); + if (!tripId) return; + + TripViewController *trip = [[TripViewController alloc] initWithTripId:tripId]; + trip.line = NN(d[@"line"]); + trip.stopName = self.stops[indexPath.section][@"name"]; + [self.navigationController pushViewController:trip animated:YES]; +} + +#pragma mark - Wischen → Wecker + +- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath { + if ([self isMoreRow:indexPath]) return NO; + NSDictionary *d = [self departureAt:indexPath]; + return d && NN(d[@"tripId"]) && ![NN(d[@"cancelled"]) boolValue]; +} + +- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle + forRowAtIndexPath:(NSIndexPath *)indexPath { +} + +- (NSArray *)tableView:(UITableView *)tableView editActionsForRowAtIndexPath:(NSIndexPath *)indexPath { + return [BVGAPI editActionsForDeparture:[self departureAt:indexPath] + stopName:self.stops[indexPath.section][@"name"] + from:self]; +} @end diff --git a/BVGNearby/add_files.rb b/BVGNearby/add_files.rb new file mode 100644 index 0000000..a42ff02 --- /dev/null +++ b/BVGNearby/add_files.rb @@ -0,0 +1,22 @@ +#!/usr/bin/env ruby +# Fuegt alle .h/.m-Dateien aus dem Ordner BVGNearby/, die noch nicht im Xcode-Projekt sind, +# automatisch hinzu (.m auch ins Target). Einmalig noetig: sudo gem install xcodeproj +# Aufruf im Projektordner (neben BVGNearby.xcodeproj): ruby BVGNearby/add_files.rb +require "xcodeproj" + +project = Xcodeproj::Project.open("BVGNearby.xcodeproj") +target = project.targets.find { |t| t.name == "BVGNearby" } || project.targets.first +group = project.main_group["BVGNearby"] +known = project.files.map { |f| File.basename(f.path.to_s) } + +added = [] +Dir.glob("BVGNearby/*.{h,m}").sort.each do |path| + name = File.basename(path) + next if known.include?(name) + ref = group.new_file(name) + target.add_file_references([ref]) if name.end_with?(".m") + added << name +end + +project.save +puts added.empty? ? "Alles schon im Projekt." : "Hinzugefügt: #{added.join(', ')}" diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..1625884 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")" + +xcodebuild -project BVGNearby.xcodeproj -scheme BVGNearby -configuration Debug \ + -destination 'generic/platform=iOS' -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO INFOPLIST_KEY_UIMainStoryboardFile= \ + -quiet build + +APP=build/Build/Products/Debug-iphoneos/BVGNearby.app +plutil -remove UIMainStoryboardFile "$APP/Info.plist" 2>/dev/null || true +ldid -S "$APP/BVGNearby" + +rm -rf build/Payload build/BVGNearby.ipa +mkdir build/Payload && cp -r "$APP" build/Payload/ +(cd build && zip -qr BVGNearby.ipa Payload) +ideviceinstaller install build/BVGNearby.ipa