BVGNearby v10 – iOS-9-Abfahrtsmonitor fürs iPhone 4S

This commit is contained in:
serverlele30 committed 2026-10-23 15:34:16 +02:00
1 parent 312b16cbd7
commit 5143238788
73 files changed
+5669 -268

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
build/
DerivedData/
xcuserdata/
*.xcuserstate
*.ipa
Payload/
.DS_Store
+142 -22
View File
@@ -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 = "<group>"; };
659F88D4308B5CF700D46649 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = "<group>"; };
659F88D6308B5CF700D46649 /* SceneDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SceneDelegate.h; sourceTree = "<group>"; };
659F88D7308B5CF700D46649 /* SceneDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SceneDelegate.m; sourceTree = "<group>"; };
659F88D9308B5CF700D46649 /* ViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ViewController.h; sourceTree = "<group>"; };
659F88DA308B5CF700D46649 /* ViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ViewController.m; sourceTree = "<group>"; };
659F88DD308B5CF700D46649 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
659F88DF308B5CFA00D46649 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
659F88E2308B5CFA00D46649 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
659F88E4308B5CFA00D46649 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
659F88E5308B5CFA00D46649 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
65AABC6A308B75CF0050A7AC /* TripViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TripViewController.h; sourceTree = "<group>"; };
65AABC6B308B75F30050A7AC /* TripViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TripViewController.m; sourceTree = "<group>"; };
65AABC6D308B7A0D0050A7AC /* DepartureCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DepartureCell.m; sourceTree = "<group>"; };
65AABC6E308B7A0D0050A7AC /* SearchViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SearchViewController.m; sourceTree = "<group>"; };
65AABC6F308B7A0D0050A7AC /* StopViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = StopViewController.m; sourceTree = "<group>"; };
65AABC70308B7A0D0050A7AC /* AlarmsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AlarmsViewController.m; sourceTree = "<group>"; };
65AABC71308B7A0D0050A7AC /* StopViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = StopViewController.h; sourceTree = "<group>"; };
65AABC72308B7A0E0050A7AC /* SearchViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SearchViewController.h; sourceTree = "<group>"; };
65AABC73308B7A0E0050A7AC /* DepartureCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DepartureCell.h; sourceTree = "<group>"; };
65AABC74308B7A0E0050A7AC /* BVGAPI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BVGAPI.h; sourceTree = "<group>"; };
65AABC75308B7A0E0050A7AC /* AlarmsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AlarmsViewController.h; sourceTree = "<group>"; };
65AABC76308B7A0E0050A7AC /* BVGAPI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BVGAPI.m; sourceTree = "<group>"; };
65AABC7C308B7D8A0050A7AC /* BVGAnnotation.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BVGAnnotation.m; sourceTree = "<group>"; };
65AABC7D308B7D8A0050A7AC /* TripMapViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TripMapViewController.m; sourceTree = "<group>"; };
65AABC7E308B7D8A0050A7AC /* TripMapViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TripMapViewController.h; sourceTree = "<group>"; };
65AABC7F308B7D8B0050A7AC /* JourneyDetailViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = JourneyDetailViewController.m; sourceTree = "<group>"; };
65AABC80308B7D8B0050A7AC /* JourneyViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = JourneyViewController.m; sourceTree = "<group>"; };
65AABC81308B7D8B0050A7AC /* JourneyViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = JourneyViewController.h; sourceTree = "<group>"; };
65AABC82308B7D8B0050A7AC /* BVGAnnotation.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BVGAnnotation.h; sourceTree = "<group>"; };
65AABC83308B7D8B0050A7AC /* JourneyDetailViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = JourneyDetailViewController.h; sourceTree = "<group>"; };
65AABC84308B7D8B0050A7AC /* MapViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MapViewController.h; sourceTree = "<group>"; };
65AABC85308B7D8B0050A7AC /* LIESMICH.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LIESMICH.txt; sourceTree = "<group>"; };
65AABC86308B7D8C0050A7AC /* MapViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MapViewController.m; sourceTree = "<group>"; };
65AABC8D308B81040050A7AC /* GuidanceManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GuidanceManager.h; sourceTree = "<group>"; };
65AABC8E308B81040050A7AC /* GuidanceViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GuidanceViewController.m; sourceTree = "<group>"; };
65AABC8F308B81050050A7AC /* GuidanceViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GuidanceViewController.h; sourceTree = "<group>"; };
65AABC90308B81050050A7AC /* GuidanceManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GuidanceManager.m; sourceTree = "<group>"; };
65AABC93308B83BE0050A7AC /* BVGSettings.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BVGSettings.h; sourceTree = "<group>"; };
65AABC94308B83BE0050A7AC /* SettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsViewController.m; sourceTree = "<group>"; };
65AABC95308B83BE0050A7AC /* BVGSettings.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BVGSettings.m; sourceTree = "<group>"; };
65AABC97308B83BE0050A7AC /* SettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsViewController.h; sourceTree = "<group>"; };
65AABC9B308B87630050A7AC /* AppIcon.appiconset */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.appiconset; sourceTree = "<group>"; };
65AABC9D308B87E00050A7AC /* CardCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CardCell.h; sourceTree = "<group>"; };
65AABC9E308B87E00050A7AC /* CardCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CardCell.m; sourceTree = "<group>"; };
65AABC9F308B87E00050A7AC /* ChipBar.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ChipBar.h; sourceTree = "<group>"; };
65AABCA0308B87E00050A7AC /* BVGTheme.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BVGTheme.h; sourceTree = "<group>"; };
65AABCA1308B87E00050A7AC /* ChipBar.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ChipBar.m; sourceTree = "<group>"; };
65AABCA2308B87E00050A7AC /* CardTableViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CardTableViewController.h; sourceTree = "<group>"; };
65AABCA3308B87E00050A7AC /* BVGTheme.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BVGTheme.m; sourceTree = "<group>"; };
65AABCA4308B87E00050A7AC /* CardTableViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CardTableViewController.m; sourceTree = "<group>"; };
65AABCA9308B91530050A7AC /* add_files.rb */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.ruby; path = add_files.rb; sourceTree = "<group>"; };
65AABCAA308B91530050A7AC /* DisruptionsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DisruptionsViewController.h; sourceTree = "<group>"; };
65AABCAB308B91540050A7AC /* DisruptionsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DisruptionsViewController.m; sourceTree = "<group>"; };
/* 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 = "<group>";
@@ -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 = "<group>";
};
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;
+4
View File
@@ -0,0 +1,4 @@
#import "CardTableViewController.h"
@interface AlarmsViewController : CardTableViewController
@end
+97
View File
@@ -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
+1 -10
View File
@@ -1,14 +1,5 @@
//
// AppDelegate.h
// BVGNearby
//
// Created by Jan Aaron Titus Kastl on 23.10.26.
//
#import <UIKit/UIKit.h>
@interface AppDelegate : UIResponder <UIApplicationDelegate>
@property (strong, nonatomic) UIWindow *window;
@end
+162 -23
View File
@@ -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 <AudioToolbox/AudioToolbox.h>
#import <CoreSpotlight/CoreSpotlight.h>
@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<UISceneSession *> *)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
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -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"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+57
View File
@@ -0,0 +1,57 @@
#import <UIKit/UIKit.h>
// 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
+554
View File
@@ -0,0 +1,554 @@
#import "BVGAPI.h"
#import "BVGSettings.h"
#import <AVFoundation/AVFoundation.h>
#import <CoreSpotlight/CoreSpotlight.h>
#import <MobileCoreServices/MobileCoreServices.h>
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
+16
View File
@@ -0,0 +1,16 @@
#import <MapKit/MapKit.h>
typedef NS_ENUM(NSInteger, BVGAnnotationKind) {
BVGAnnotationKindStop,
BVGAnnotationKindVehicle,
BVGAnnotationKindTripStop
};
@interface BVGAnnotation : NSObject <MKAnnotation>
@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
+7
View File
@@ -0,0 +1,7 @@
#import "BVGAnnotation.h"
@implementation BVGAnnotation
@synthesize coordinate = _coordinate;
@synthesize title = _title;
@synthesize subtitle = _subtitle;
@end
+23
View File
@@ -0,0 +1,23 @@
#import <Foundation/Foundation.h>
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
+61
View File
@@ -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
+31
View File
@@ -0,0 +1,31 @@
#import <UIKit/UIKit.h>
// 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
+72
View File
@@ -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
-24
View File
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="ViewController" customModuleProvider="" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+10
View File
@@ -0,0 +1,10 @@
#import <UIKit/UIKit.h>
// 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
+110
View File
@@ -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
+21
View File
@@ -0,0 +1,21 @@
#import <UIKit/UIKit.h>
#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
+215
View File
@@ -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<UITableViewDataSource>)self tableView:self.tableView titleForHeaderInSection:section];
}
- (NSString *)footerTitleFor:(NSInteger)section {
if (![self respondsToSelector:@selector(tableView:titleForFooterInSection:)]) return nil;
return [(id<UITableViewDataSource>)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
+9
View File
@@ -0,0 +1,9 @@
#import <UIKit/UIKit.h>
// 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
+68
View File
@@ -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
+8
View File
@@ -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
+195
View File
@@ -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
+9
View File
@@ -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
+112
View File
@@ -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:@"<br */?>" withString:@"\n"
options:NSRegularExpressionSearch | NSCaseInsensitiveSearch range:NSMakeRange(0, s.length)];
t = [t stringByReplacingOccurrencesOfString:@"<[^>]+>" withString:@"" options:NSRegularExpressionSearch range:NSMakeRange(0, t.length)];
return [[t stringByReplacingOccurrencesOfString:@"&nbsp;" 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
+41
View File
@@ -0,0 +1,41 @@
#import <Foundation/Foundation.h>
#import <CoreLocation/CoreLocation.h>
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
+503
View File
@@ -0,0 +1,503 @@
#import "GuidanceManager.h"
#import "BVGAPI.h"
#import "BVGSettings.h"
#import <UIKit/UIKit.h>
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 () <CLLocationManagerDelegate>
@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<CLLocation *> *)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
+4
View File
@@ -0,0 +1,4 @@
#import "CardTableViewController.h"
@interface GuidanceViewController : CardTableViewController
@end
+305
View File
@@ -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
+2 -19
View File
@@ -2,24 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Zeigt Abfahrten an Haltestellen in deiner Nähe.</string>
</dict>
</plist>
+5
View File
@@ -0,0 +1,5 @@
#import "CardTableViewController.h"
@interface JourneyDetailViewController : CardTableViewController
- (instancetype)initWithJourney:(NSDictionary *)journey;
@end
+185
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
#import "CardTableViewController.h"
@interface JourneyViewController : CardTableViewController
@end
+493
View File
@@ -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 <CoreLocation/CoreLocation.h>
#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 () <CLLocationManagerDelegate>
@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<CLLocation *> *)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
+20
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
#import <UIKit/UIKit.h>
@interface MapViewController : UIViewController
@end
+301
View File
@@ -0,0 +1,301 @@
#import "MapViewController.h"
#import "StopViewController.h"
#import "TripViewController.h"
#import "BVGAnnotation.h"
#import "BVGAPI.h"
#import "BVGTheme.h"
#import <MapKit/MapKit.h>
static const CLLocationDegrees kMaxRadarSpan = 0.045; // weiter rausgezoomt -> keine Fahrzeuge (Last auf dem 4S)
@interface MapViewController () <MKMapViewDelegate>
@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<MKAnnotation>)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
-15
View File
@@ -1,15 +0,0 @@
//
// SceneDelegate.h
// BVGNearby
//
// Created by Jan Aaron Titus Kastl on 23.10.26.
//
#import <UIKit/UIKit.h>
@interface SceneDelegate : UIResponder <UIWindowSceneDelegate>
@property (strong, nonatomic) UIWindow * window;
@end
-57
View File
@@ -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
+8
View File
@@ -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
+222
View File
@@ -0,0 +1,222 @@
#import "SearchViewController.h"
#import "StopViewController.h"
#import "BVGAPI.h"
@interface SearchViewController () <UISearchBarDelegate>
@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
+4
View File
@@ -0,0 +1,4 @@
#import "CardTableViewController.h"
@interface SettingsViewController : CardTableViewController
@end
+167
View File
@@ -0,0 +1,167 @@
#import "SettingsViewController.h"
#import "BVGSettings.h"
#import "BVGAPI.h"
#import <CoreSpotlight/CoreSpotlight.h>
#import <objc/runtime.h>
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
+5
View File
@@ -0,0 +1,5 @@
#import "CardTableViewController.h"
@interface StopViewController : CardTableViewController
- (instancetype)initWithStopId:(NSString *)stopId name:(NSString *)name;
@end
+303
View File
@@ -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:@"<br */?>" withString:@"\n"
options:NSRegularExpressionSearch | NSCaseInsensitiveSearch
range:NSMakeRange(0, s.length)];
t = [t stringByReplacingOccurrencesOfString:@"<[^>]+>" withString:@""
options:NSRegularExpressionSearch range:NSMakeRange(0, t.length)];
return [t stringByReplacingOccurrencesOfString:@"&nbsp;" 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
+5
View File
@@ -0,0 +1,5 @@
#import <UIKit/UIKit.h>
@interface TripMapViewController : UIViewController
- (instancetype)initWithTripId:(NSString *)tripId line:(NSDictionary *)line;
@end
+158
View File
@@ -0,0 +1,158 @@
#import "TripMapViewController.h"
#import "BVGAnnotation.h"
#import "BVGAPI.h"
#import <MapKit/MapKit.h>
@interface TripMapViewController () <MKMapViewDelegate>
@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<MKOverlay>)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<MKAnnotation>)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
+7
View File
@@ -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
+251
View File
@@ -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
+2 -12
View File
@@ -1,14 +1,4 @@
//
// ViewController.h
// BVGNearby
//
// Created by Jan Aaron Titus Kastl on 23.10.26.
//
#import <UIKit/UIKit.h>
@interface ViewController : UIViewController
#import "CardTableViewController.h"
@interface ViewController : CardTableViewController
@end
+537 -9
View File
@@ -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 <CoreLocation/CoreLocation.h>
@interface ViewController ()
static NSString * const kCacheKey = @"nearbyCache";
@interface ViewController () <CLLocationManagerDelegate>
@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<CLLocation *> *)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
+22
View File
@@ -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(', ')}"
Executable
+17
View File
@@ -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