BVGNearby v10 – iOS-9-Abfahrtsmonitor fürs iPhone 4S
No files matched your search
@@ -0,0 +1,7 @@
|
||||
build/
|
||||
DerivedData/
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
*.ipa
|
||||
Payload/
|
||||
.DS_Store
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#import "CardTableViewController.h"
|
||||
|
||||
@interface AlarmsViewController : CardTableViewController
|
||||
@end
|
||||
@@ -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,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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
#import "BVGAnnotation.h"
|
||||
|
||||
@implementation BVGAnnotation
|
||||
@synthesize coordinate = _coordinate;
|
||||
@synthesize title = _title;
|
||||
@synthesize subtitle = _subtitle;
|
||||
@end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:@" " 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
#import "CardTableViewController.h"
|
||||
|
||||
@interface GuidanceViewController : CardTableViewController
|
||||
@end
|
||||
@@ -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,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>
|
||||
@@ -0,0 +1,5 @@
|
||||
#import "CardTableViewController.h"
|
||||
|
||||
@interface JourneyDetailViewController : CardTableViewController
|
||||
- (instancetype)initWithJourney:(NSDictionary *)journey;
|
||||
@end
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
#import "CardTableViewController.h"
|
||||
|
||||
@interface JourneyViewController : CardTableViewController
|
||||
@end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface MapViewController : UIViewController
|
||||
@end
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
#import "CardTableViewController.h"
|
||||
|
||||
@interface SettingsViewController : CardTableViewController
|
||||
@end
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
#import "CardTableViewController.h"
|
||||
|
||||
@interface StopViewController : CardTableViewController
|
||||
- (instancetype)initWithStopId:(NSString *)stopId name:(NSString *)name;
|
||||
@end
|
||||
@@ -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:@" " 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
|
||||
@@ -0,0 +1,5 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface TripMapViewController : UIViewController
|
||||
- (instancetype)initWithTripId:(NSString *)tripId line:(NSDictionary *)line;
|
||||
@end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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(', ')}"
|
||||
@@ -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
|
||||