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 0000000..801d39d Binary files /dev/null and b/BVGNearby/AppIcon.appiconset/icon-1024.png differ diff --git a/BVGNearby/AppIcon.appiconset/icon-20@2x.png b/BVGNearby/AppIcon.appiconset/icon-20@2x.png new file mode 100644 index 0000000..eaa3f18 Binary files /dev/null and b/BVGNearby/AppIcon.appiconset/icon-20@2x.png differ diff --git a/BVGNearby/AppIcon.appiconset/icon-20@3x.png b/BVGNearby/AppIcon.appiconset/icon-20@3x.png new file mode 100644 index 0000000..0d6ba14 Binary files /dev/null and b/BVGNearby/AppIcon.appiconset/icon-20@3x.png differ diff --git a/BVGNearby/AppIcon.appiconset/icon-29@2x.png b/BVGNearby/AppIcon.appiconset/icon-29@2x.png new file mode 100644 index 0000000..dcfc5d0 Binary files /dev/null and b/BVGNearby/AppIcon.appiconset/icon-29@2x.png differ diff --git a/BVGNearby/AppIcon.appiconset/icon-29@3x.png b/BVGNearby/AppIcon.appiconset/icon-29@3x.png new file mode 100644 index 0000000..7c7bfef Binary files /dev/null and b/BVGNearby/AppIcon.appiconset/icon-29@3x.png differ diff --git a/BVGNearby/AppIcon.appiconset/icon-40@2x.png b/BVGNearby/AppIcon.appiconset/icon-40@2x.png new file mode 100644 index 0000000..5703dce Binary files /dev/null and b/BVGNearby/AppIcon.appiconset/icon-40@2x.png differ diff --git a/BVGNearby/AppIcon.appiconset/icon-40@3x.png b/BVGNearby/AppIcon.appiconset/icon-40@3x.png new file mode 100644 index 0000000..cadb06c Binary files /dev/null and b/BVGNearby/AppIcon.appiconset/icon-40@3x.png differ diff --git a/BVGNearby/AppIcon.appiconset/icon-60@2x.png b/BVGNearby/AppIcon.appiconset/icon-60@2x.png new file mode 100644 index 0000000..cadb06c Binary files /dev/null and b/BVGNearby/AppIcon.appiconset/icon-60@2x.png differ diff --git a/BVGNearby/AppIcon.appiconset/icon-60@3x.png b/BVGNearby/AppIcon.appiconset/icon-60@3x.png new file mode 100644 index 0000000..9ffdab4 Binary files /dev/null and b/BVGNearby/AppIcon.appiconset/icon-60@3x.png differ 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 0000000..801d39d Binary files /dev/null and b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ 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 0000000..eaa3f18 Binary files /dev/null and b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png differ 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 0000000..0d6ba14 Binary files /dev/null and b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png differ diff --git a/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png new file mode 100644 index 0000000..dcfc5d0 Binary files /dev/null and b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png differ diff --git a/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png new file mode 100644 index 0000000..7c7bfef Binary files /dev/null and b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png differ diff --git a/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png new file mode 100644 index 0000000..5703dce Binary files /dev/null and b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png differ 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 0000000..cadb06c Binary files /dev/null and b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png differ 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 0000000..cadb06c Binary files /dev/null and b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png differ 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 0000000..9ffdab4 Binary files /dev/null and b/BVGNearby/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png differ 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