v15: Widget, Mitteilungs-Knoepfe, Akku, neues Design

This commit is contained in:
serverlele30 committed 2026-10-23 16:34:02 +02:00
1 parent 98a94e765e
commit 425fa3cbd3
30 files changed
+1651 -655

No files matched your search

+2 -2
View File
@@ -63,14 +63,14 @@
// grosse Weckzeit links, darunter die Fahrt
NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:[BVGAPI hhmm:n.fireDate]
attributes:@{ NSFontAttributeName: [BVGTheme digits:26 weight:UIFontWeightSemibold],
attributes:@{ NSFontAttributeName: [BVGTheme digits:22 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] }]];
NSForegroundColorAttributeName: [BVGTheme warningText] }]];
cell.textLabel.attributedText = title;
NSMutableString *sub = [NSMutableString string];
+66 -15
View File
@@ -2,8 +2,7 @@
#import "ViewController.h"
#import "MapViewController.h"
#import "JourneyViewController.h"
#import "SearchViewController.h"
#import "AlarmsViewController.h"
#import "SettingsViewController.h"
#import "StopViewController.h"
#import "GuidanceManager.h"
#import "GuidanceViewController.h"
@@ -48,18 +47,35 @@ static void BVGUncaughtException(NSException *e) {
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
NSSetUncaughtExceptionHandler(&BVGUncaughtException);
// Knoepfe direkt in der Mitteilung (Sperrbildschirm: nach links wischen, Banner: herunterziehen)
UIMutableUserNotificationAction *accept = [[UIMutableUserNotificationAction alloc] init];
accept.identifier = @"accept";
accept.title = @"Übernehmen";
accept.activationMode = UIUserNotificationActivationModeBackground;
accept.authenticationRequired = NO;
UIMutableUserNotificationAction *keep = [[UIMutableUserNotificationAction alloc] init];
keep.identifier = @"keep";
keep.title = @"Behalten";
keep.activationMode = UIUserNotificationActivationModeBackground;
keep.authenticationRequired = NO;
UIMutableUserNotificationCategory *altCategory = [[UIMutableUserNotificationCategory alloc] init];
altCategory.identifier = GuidanceAlternativeCategory;
[altCategory setActions:@[accept, keep] forContext:UIUserNotificationActionContextDefault];
[altCategory setActions:@[accept, keep] forContext:UIUserNotificationActionContextMinimal];
[application registerUserNotificationSettings:
[UIUserNotificationSettings settingsForTypes:(UIUserNotificationTypeAlert | UIUserNotificationTypeSound | UIUserNotificationTypeBadge)
categories:nil]];
categories:[NSSet setWithObject:altCategory]]];
[BVGAPI syncWidget]; // Favoriten fuers Widget bereitstellen
[BVGTheme applyAppearance]; // modernes Design (Farben, Leisten, Schriften)
// 4 Tabs: Suche steckt in "Abfahrten", Wecker & Einstellungen in "Mehr"
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"],
@[[[ViewController alloc] initWithStyle:UITableViewStylePlain], @"Abfahrten", @"nearby"],
@[[[JourneyViewController alloc] initWithStyle:UITableViewStyleGrouped], @"Verbindung", @"route"],
@[[[MapViewController alloc] init], @"Karte", @"map"],
@[[[SettingsViewController alloc] initWithStyle:UITableViewStyleGrouped], @"Mehr", @"more"],
];
NSMutableArray *navs = [NSMutableArray array];
NSInteger tag = 0;
@@ -71,6 +87,7 @@ static void BVGUncaughtException(NSException *e) {
self.tabs = [[UITabBarController alloc] init];
self.tabs.viewControllers = navs;
[BVGTheme applyTabBarIndicator:self.tabs.tabBar count:navs.count];
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.window.backgroundColor = [BVGTheme background];
@@ -99,16 +116,16 @@ static void BVGUncaughtException(NSException *e) {
}
- (void)updateGuidanceBadge {
UIViewController *routeTab = self.tabs.viewControllers[2];
UIViewController *routeTab = self.tabs.viewControllers[1];
GuidanceManager *g = [GuidanceManager shared];
if (!g.active) routeTab.tabBarItem.badgeValue = nil;
else routeTab.tabBarItem.badgeValue = (g.status == GuidanceStatusBroken || g.alternative) ? @"!" : @"▶";
else routeTab.tabBarItem.badgeValue = (g.status == GuidanceStatusBroken || g.alternative) ? @"!" : @""; // leer = kleiner Punkt
}
- (void)openGuidance {
if (![GuidanceManager shared].active) return;
[self.tabs dismissViewControllerAnimated:NO completion:nil];
self.tabs.selectedIndex = 2;
self.tabs.selectedIndex = 1;
UINavigationController *nav = (UINavigationController *)self.tabs.selectedViewController;
if ([nav.topViewController isKindOfClass:[GuidanceViewController class]]) return;
[nav popToRootViewControllerAnimated:NO];
@@ -119,7 +136,7 @@ static void BVGUncaughtException(NSException *e) {
for (NSString *icon in @[@"🚏 ", @"🔔 ", @"⚠ ", @"→"]) {
text = [text stringByReplacingOccurrencesOfString:icon withString:([icon isEqualToString:@"→"] ? @"Richtung" : @"")];
}
return [text stringByReplacingOccurrencesOfString:@"Antippen zum Entscheiden." withString:@""];
return [text stringByReplacingOccurrencesOfString:@"Wischen oder antippen zum Entscheiden." withString:@""];
}
- (void)updateAlarmBadge {
@@ -136,22 +153,56 @@ static void BVGUncaughtException(NSException *e) {
#pragma mark - Spotlight: Haltestelle aus der iOS-Suche oeffnen
- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity
restorationHandler:(void (^)(NSArray *))restorationHandler {
restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
if (![userActivity.activityType isEqualToString:CSSearchableItemActionType]) return NO;
NSString *stopId = [BVGAPI stopIdFromSpotlightIdentifier:userActivity.userInfo[CSSearchableItemActivityIdentifier]];
if (!stopId) return NO;
[self openStop:stopId name:nil];
return YES;
}
- (void)openStop:(NSString *)stopId name:(NSString *)name {
[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"];
name:name ?: [BVGAPI nameForStop:stopId] ?: @"Haltestelle"];
[nav pushViewController:vc animated:NO];
}
#pragma mark - Widget: bvgnearby://stop?id=...&name=...
- (BOOL)handleURL:(NSURL *)url {
if (![url.scheme isEqualToString:@"bvgnearby"]) return NO;
NSMutableDictionary *q = [NSMutableDictionary dictionary];
for (NSString *pair in [url.query componentsSeparatedByString:@"&"]) {
NSArray *kv = [pair componentsSeparatedByString:@"="];
if (kv.count == 2) q[kv[0]] = [kv[1] stringByRemovingPercentEncoding] ?: kv[1];
}
if ([url.host isEqualToString:@"stop"] && q[@"id"]) [self openStop:q[@"id"] name:q[@"name"]];
else self.tabs.selectedIndex = 0; // bvgnearby:// -> Abfahrten
return YES;
}
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<NSString *, id> *)options {
return [self handleURL:url];
}
#pragma mark - Knoepfe in der Alternativ-Mitteilung
- (void)application:(UIApplication *)application handleActionWithIdentifier:(NSString *)identifier
forLocalNotification:(UILocalNotification *)notification completionHandler:(void (^)(void))completionHandler {
GuidanceManager *g = [GuidanceManager shared];
if ([identifier isEqualToString:@"accept"]) [g acceptAlternative];
else if ([identifier isEqualToString:@"keep"]) [g dismissAlternative];
// kurz Zeit lassen, damit neue Erinnerungen geplant und gespeichert werden
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[[NSUserDefaults standardUserDefaults] synchronize];
if (completionHandler) completionHandler();
});
}
#pragma mark - Wecker
- (void)application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification {
+7 -1
View File
@@ -26,8 +26,13 @@ extern NSString * const BVGAlarmsChangedNotification;
+ (UIImage *)badgeForLine:(NSDictionary *)line;
+ (UIImage *)badgeStripForLines:(NSArray *)lines;
+ (UIImage *)dotWithColor:(UIColor *)color filled:(BOOL)filled;
+ (UIImage *)tabIconNamed:(NSString *)name;
+ (UIImage *)tabIconNamed:(NSString *)name; // nearby, search, map, route, alarm, more
+ (UIImage *)smallIconNamed:(NSString *)name size:(CGFloat)size; // Tab-Icon verkleinert
+ (UIImage *)stopSignWithSize:(CGFloat)diameter; // gelber Kreis mit gruenem H
+ (NSString *)spokenLineName:(NSDictionary *)line;
+ (NSString *)shortName:(NSString *)name; // "U Amrumer Str. (Berlin)" -> "U Amrumer Str."
+ (NSString *)platform:(NSString *)platform; // "2 (U7)" -> "2"
+ (UIBarButtonItem *)barButtonWithIcon:(NSString *)icon target:(id)target action:(SEL)action; // speaker, locate
// Favoriten & zuletzt angesehen
+ (NSArray *)favorites;
@@ -36,6 +41,7 @@ extern NSString * const BVGAlarmsChangedNotification;
+ (NSArray *)recentStops;
+ (void)addRecentStop:(NSString *)stopId name:(NSString *)name;
+ (NSString *)nameForStop:(NSString *)stopId;
+ (void)syncWidget; // Favoriten fuer das Mitteilungszentrale-Widget bereitstellen (App Group)
// Spotlight (iOS 9 Suche / Siri-Vorschlaege)
+ (void)indexStopInSpotlight:(NSString *)stopId name:(NSString *)name;
+102 -4
View File
@@ -10,6 +10,7 @@ static NSString *gLastSource = nil;
static NSString * const kFavoritesKey = @"favorites";
static NSString * const kRecentsKey = @"recentStops";
static NSString * const kSpotlightPrefix = @"stop:";
static NSString * const kAppGroup = @"group.com.serverlele.BVGNearby"; // gemeinsam mit dem Widget
NSString * const BVGFavoritesChangedNotification = @"BVGFavoritesChanged";
NSString * const BVGAlarmsChangedNotification = @"BVGAlarmsChanged";
@@ -192,13 +193,15 @@ NSString * const BVGAlarmsChangedNotification = @"BVGAlarmsChanged";
UIImage *cached = [cache objectForKey:key];
if (cached) return cached;
NSDictionary *attrs = @{ NSFontAttributeName: [UIFont systemFontOfSize:14 weight:UIFontWeightBold],
NSDictionary *attrs = @{ NSFontAttributeName: [UIFont systemFontOfSize:13 weight:UIFontWeightBold],
NSForegroundColorAttributeName: fg ?: [UIColor whiteColor] };
CGSize textSize = [name sizeWithAttributes:attrs];
CGSize size = CGSizeMake(MAX(42, ceil(textSize.width) + 16), 24);
CGSize size = CGSizeMake(MAX(38, ceil(textSize.width) + 12), 22);
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
CGFloat radius = [product isEqualToString:@"suburban"] ? size.height / 2 : 7;
// S-Bahn rund, U-Bahn fast eckig, Rest leicht abgerundet - wie auf den Anzeigen
CGFloat radius = [product isEqualToString:@"suburban"] ? size.height / 2
: ([product isEqualToString:@"subway"] ? 3 : 5);
[[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)
@@ -221,7 +224,7 @@ NSString * const BVGAlarmsChangedNotification = @"BVGAlarmsChanged";
}
if (!imgs.count) return nil;
UIGraphicsBeginImageContextWithOptions(CGSizeMake(width - 4, 24), NO, 0);
UIGraphicsBeginImageContextWithOptions(CGSizeMake(width - 4, 22), NO, 0);
CGFloat x = 0;
for (UIImage *b in imgs) {
[b drawAtPoint:CGPointMake(x, 0)];
@@ -232,6 +235,24 @@ NSString * const BVGAlarmsChangedNotification = @"BVGAlarmsChanged";
return img;
}
+ (NSString *)shortName:(NSString *)name {
if (![name isKindOfClass:[NSString class]] || !name.length) return name;
return [name stringByReplacingOccurrencesOfString:@"\\s*\\((Berlin|Bln)\\)" withString:@""
options:NSRegularExpressionSearch range:NSMakeRange(0, name.length)];
}
+ (NSString *)platform:(NSString *)platform {
if (![platform isKindOfClass:[NSString class]] || !platform.length) return nil;
NSString *p = [platform stringByReplacingOccurrencesOfString:@"\\s*\\(.*\\)" withString:@""
options:NSRegularExpressionSearch range:NSMakeRange(0, platform.length)];
return p.length ? p : platform;
}
+ (UIBarButtonItem *)barButtonWithIcon:(NSString *)icon target:(id)target action:(SEL)action {
return [[UIBarButtonItem alloc] initWithImage:[self smallIconNamed:icon size:22]
style:UIBarButtonItemStylePlain target:target action:action];
}
+ (NSString *)spokenLineName:(NSDictionary *)line {
NSString *name = NN(line[@"name"]) ?: @"";
NSString *product = NN(line[@"product"]) ?: @"";
@@ -253,6 +274,41 @@ NSString * const BVGAlarmsChangedNotification = @"BVGAlarmsChanged";
return img;
}
+ (UIImage *)smallIconNamed:(NSString *)name size:(CGFloat)size {
UIImage *big = [self tabIconNamed:name];
UIGraphicsBeginImageContextWithOptions(CGSizeMake(size, size), NO, 0);
[big drawInRect:CGRectMake(0, 0, size, size)];
UIImage *img = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return [img imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
}
// Haltestellenschild: gelber Kreis mit gruenem "H" (wie an jeder deutschen Haltestelle)
+ (UIImage *)stopSignWithSize:(CGFloat)d {
static NSCache *cache;
static dispatch_once_t once;
dispatch_once(&once, ^{ cache = [[NSCache alloc] init]; });
NSNumber *key = @(d);
UIImage *img = [cache objectForKey:key];
if (img) return img;
UIGraphicsBeginImageContextWithOptions(CGSizeMake(d, d), NO, 0);
UIColor *green = [UIColor colorWithRed:0 green:0.46 blue:0.25 alpha:1];
[green setFill];
[[UIBezierPath bezierPathWithOvalInRect:CGRectMake(0, 0, d, d)] fill];
[[UIColor colorWithRed:0.94 green:0.84 blue:0.13 alpha:1] setFill];
CGFloat ring = MAX(1.5, d * 0.07);
[[UIBezierPath bezierPathWithOvalInRect:CGRectMake(ring, ring, d - 2 * ring, d - 2 * ring)] fill];
NSDictionary *attrs = @{ NSFontAttributeName: [UIFont systemFontOfSize:d * 0.6 weight:UIFontWeightHeavy],
NSForegroundColorAttributeName: green };
CGSize t = [@"H" sizeWithAttributes:attrs];
[@"H" drawAtPoint:CGPointMake((d - t.width) / 2, (d - t.height) / 2) withAttributes:attrs];
img = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
[cache setObject:img forKey:key];
return img;
}
+ (UIImage *)tabIconNamed:(NSString *)name {
CGSize size = CGSizeMake(25, 25);
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
@@ -304,6 +360,40 @@ NSString * const BVGAlarmsChangedNotification = @"BVGAlarmsChanged";
CGFloat dash[] = {3, 3};
[path setLineDash:dash count:2 phase:0];
[path stroke];
} else if ([name isEqualToString:@"speaker"]) {
// Lautsprecher mit zwei Schallboegen
UIBezierPath *body = [UIBezierPath bezierPath];
[body moveToPoint:CGPointMake(3, 9)];
[body addLineToPoint:CGPointMake(7, 9)];
[body addLineToPoint:CGPointMake(12, 4)];
[body addLineToPoint:CGPointMake(12, 21)];
[body addLineToPoint:CGPointMake(7, 16)];
[body addLineToPoint:CGPointMake(3, 16)];
[body closePath];
[body fill];
for (int i = 0; i < 2; i++) {
CGFloat r = 5 + i * 4.5;
UIBezierPath *wave = [UIBezierPath bezierPathWithArcCenter:CGPointMake(12, 12.5) radius:r
startAngle:-M_PI / 3.5 endAngle:M_PI / 3.5 clockwise:YES];
wave.lineWidth = 2;
wave.lineCapStyle = kCGLineCapRound;
[wave stroke];
}
} else if ([name isEqualToString:@"locate"]) {
// Ortungspfeil (wie in Apple Karten)
UIBezierPath *arrow = [UIBezierPath bezierPath];
[arrow moveToPoint:CGPointMake(22, 3)];
[arrow addLineToPoint:CGPointMake(3, 11.5)];
[arrow addLineToPoint:CGPointMake(12, 13)];
[arrow addLineToPoint:CGPointMake(13.5, 22)];
[arrow closePath];
arrow.lineWidth = 2;
arrow.lineJoinStyle = kCGLineJoinRound;
[arrow stroke];
} else if ([name isEqualToString:@"more"]) {
for (int i = 0; i < 3; i++) {
[[UIBezierPath bezierPathWithOvalInRect:CGRectMake(3 + i * 7.5, 10, 4.5, 4.5)] fill];
}
} else if ([name isEqualToString:@"alarm"]) {
UIBezierPath *face = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(3, 4, 19, 19)];
face.lineWidth = 2;
@@ -342,9 +432,17 @@ NSString * const BVGAlarmsChangedNotification = @"BVGAlarmsChanged";
[[NSUserDefaults standardUserDefaults] setObject:list forKey:kFavoritesKey];
[[NSUserDefaults standardUserDefaults] synchronize];
if (fav) [self indexStopInSpotlight:stopId name:name];
[self syncWidget];
[[NSNotificationCenter defaultCenter] postNotificationName:BVGFavoritesChangedNotification object:nil];
}
+ (void)syncWidget {
NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:kAppGroup];
[shared setObject:[self favorites] forKey:@"favorites"];
[shared setBool:[[NSUserDefaults standardUserDefaults] boolForKey:@"set.ownServer"] forKey:@"ownServer"];
[shared synchronize];
}
#pragma mark - Zuletzt angesehen
+ (NSArray *)recentStops {
+20 -11
View File
@@ -1,31 +1,40 @@
#import <UIKit/UIKit.h>
// Zentrales Design: Farben, Schriften, Systemdarstellung.
// Orientiert an aktuellem iOS-Design (inset grouped, grosse Titel), umgesetzt mit iOS-9-Mitteln.
// Zentrales Design im Stil der BVG-App: gelber Kopfbereich, kraeftige schwarze Schrift,
// weisse Karten auf hellgrauem Grund. Nur iOS-9-Mittel.
@interface BVGTheme : NSObject
+ (void)applyAppearance;
+ (void)applyTabBarIndicator:(UITabBar *)bar count:(NSUInteger)count; // gelber Balken ueber dem aktiven Tab
// Farben
+ (UIColor *)background; // hellgrauer Hintergrund (#F2F2F7)
+ (UIColor *)card; // Kartenflaeche (weiss)
+ (UIColor *)cardPressed; // Karte beim Antippen
+ (UIColor *)background; // hellgrauer Hintergrund
+ (UIColor *)card; // Kartenflaeche (weiss)
+ (UIColor *)cardPressed; // Karte beim Antippen
+ (UIColor *)separator;
+ (UIColor *)textPrimary;
+ (UIColor *)textPrimary; // schwarz
+ (UIColor *)textSecondary;
+ (UIColor *)textTertiary;
+ (UIColor *)accent; // BVG-Gelb
+ (UIColor *)accentSoft; // helles Gelb fuer Hervorhebungen
+ (UIColor *)link; // iOS-Blau
+ (UIColor *)accent; // BVG-Gelb
+ (UIColor *)accentSoft; // helles Gelb fuer Hervorhebungen
+ (UIColor *)header; // Kopfbereich (= Gelb)
+ (UIColor *)headerText; // Schrift auf Gelb
+ (UIColor *)headerSecondary; // Nebentext auf Gelb (dunkel, gut lesbar)
+ (UIColor *)warningText; // dunkles Orange, lesbar auf Gelb und Weiss
+ (UIColor *)searchField; // Suchfeld-Hintergrund
+ (UIColor *)link;
+ (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
+ (UIFont *)digits:(CGFloat)size weight:(CGFloat)weight; // gleich breite Ziffern -> kein Springen
+ (CGFloat)cardInset; // Abstand Karte <-> Bildschirmrand
+ (CGFloat)cardInset; // Abstand Karte <-> Bildschirmrand
+ (CGFloat)cardRadius;
+ (UIImage *)imageWithColor:(UIColor *)color;
+ (UIImage *)roundedImageWithColor:(UIColor *)color radius:(CGFloat)radius; // dehnbar, z.B. Suchfeld
@end
+50 -22
View File
@@ -6,19 +6,24 @@
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]; }
+ (UIColor *)background { return [self rgb:242 :242 :245]; }
+ (UIColor *)card { return [UIColor whiteColor]; }
+ (UIColor *)cardPressed { return [self rgb:228 :228 :228]; }
+ (UIColor *)separator { return [self rgb:226 :226 :226]; }
+ (UIColor *)textPrimary { return [self rgb:28 :28 :30]; }
+ (UIColor *)textSecondary { return [self rgb:118 :118 :124]; }
+ (UIColor *)textTertiary { return [self rgb:165 :165 :165]; }
+ (UIColor *)accent { return [self rgb:240 :215 :34]; }
+ (UIColor *)accentSoft { return [self rgb:255 :245 :190]; }
+ (UIColor *)header { return [self accent]; }
+ (UIColor *)headerText { return [self rgb:0 :0 :0]; }
+ (UIColor *)headerSecondary { return [self rgb:60 :54 :20]; }
+ (UIColor *)warningText { return [self rgb:156 :72 :0]; }
+ (UIColor *)searchField { return [UIColor whiteColor]; }
+ (UIColor *)link { return [self rgb:0 :94 :184]; }
+ (UIColor *)green { return [self rgb:0 :140 :60]; }
+ (UIColor *)red { return [self rgb:210 :20 :30]; }
+ (UIColor *)orange { return [self rgb:235 :120 :0]; }
+ (UIFont *)font:(CGFloat)size weight:(CGFloat)weight {
return [UIFont systemFontOfSize:size weight:weight];
@@ -28,8 +33,8 @@
return [UIFont monospacedDigitSystemFontOfSize:size weight:weight];
}
+ (CGFloat)cardInset { return 14; }
+ (CGFloat)cardRadius { return 12; }
+ (CGFloat)cardInset { return 10; }
+ (CGFloat)cardRadius { return 10; }
+ (UIImage *)imageWithColor:(UIColor *)color {
UIGraphicsBeginImageContextWithOptions(CGSizeMake(1, 1), YES, 0);
@@ -40,20 +45,30 @@
return img;
}
+ (UIImage *)roundedImageWithColor:(UIColor *)color radius:(CGFloat)radius {
CGFloat s = radius * 2 + 2;
UIGraphicsBeginImageContextWithOptions(CGSizeMake(s, s), NO, 0);
[color setFill];
[[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, s, s) cornerRadius:radius] fill];
UIImage *img = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return [img resizableImageWithCapInsets:UIEdgeInsetsMake(radius, radius, radius, radius)];
}
+ (void)applyAppearance {
// Navigationsleiste: flach, gleiche Farbe wie der Hintergrund, keine Trennlinie
// Navigationsleiste: gelb, schwarze fette Schrift, ohne Trennlinie -> geht nahtlos in den Kopfbereich ueber
UINavigationBar *nav = [UINavigationBar appearance];
[nav setBackgroundImage:[self imageWithColor:[self background]] forBarMetrics:UIBarMetricsDefault];
[nav setBackgroundImage:[self imageWithColor:[self header]] forBarMetrics:UIBarMetricsDefault];
nav.shadowImage = [[UIImage alloc] init];
nav.translucent = NO;
nav.barTintColor = [self background];
nav.tintColor = [self textPrimary];
nav.titleTextAttributes = @{ NSForegroundColorAttributeName: [self textPrimary],
nav.barTintColor = [self header];
nav.tintColor = [self headerText];
nav.titleTextAttributes = @{ NSForegroundColorAttributeName: [self headerText],
NSFontAttributeName: [self font:17 weight:UIFontWeightSemibold] };
[[UIBarButtonItem appearance] setTitleTextAttributes:@{ NSFontAttributeName: [self font:17 weight:UIFontWeightRegular] }
[[UIBarButtonItem appearance] setTitleTextAttributes:@{ NSFontAttributeName: [self font:16 weight:UIFontWeightRegular] }
forState:UIControlStateNormal];
// Tab-Leiste: weiss, Auswahl dunkel
// Tab-Leiste: weiss, aktiver Tab schwarz mit gelbem Balken
UITabBar *tab = [UITabBar appearance];
tab.barTintColor = [UIColor whiteColor];
tab.tintColor = [self textPrimary];
@@ -69,4 +84,17 @@
[UISegmentedControl appearance].tintColor = [self textPrimary];
}
+ (void)applyTabBarIndicator:(UITabBar *)bar count:(NSUInteger)count {
if (!count) return;
CGFloat w = floor([UIScreen mainScreen].bounds.size.width / count);
UIGraphicsBeginImageContextWithOptions(CGSizeMake(w, 49), NO, 0);
[[self accent] setFill];
[[UIBezierPath bezierPathWithRoundedRect:CGRectMake(w * 0.2, 0, w * 0.6, 3)
byRoundingCorners:UIRectCornerBottomLeft | UIRectCornerBottomRight
cornerRadii:CGSizeMake(2, 2)] fill];
UIImage *img = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
bar.selectionIndicatorImage = img;
}
@end
+9
View File
@@ -8,3 +8,12 @@
@property (nonatomic, assign) BOOL lastInSection;
- (void)applyCardStyle;
@end
// Mehrzeilige Zelle: Symbol links, Titel + Detail beliebig lang.
// Die Hoehe wird selbst berechnet (iOS 9 kann das mit Standardzellen nicht zuverlaessig).
@interface InfoCell : CardCell
@property (nonatomic, strong, readonly) UIImageView *iconView;
@property (nonatomic, strong, readonly) UILabel *titleLabel;
@property (nonatomic, strong, readonly) UILabel *detailLabel;
- (CGFloat)fittingHeightForTableWidth:(CGFloat)tableWidth;
@end
+99 -2
View File
@@ -52,7 +52,7 @@
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.font = [BVGTheme font:13 weight:UIFontWeightRegular];
self.detailTextLabel.textColor = [BVGTheme textSecondary];
self.firstInSection = YES;
self.lastInSection = YES;
@@ -102,9 +102,106 @@
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.font = [BVGTheme font:13 weight:UIFontWeightRegular];
self.detailTextLabel.textColor = [BVGTheme textSecondary];
self.contentView.alpha = 1;
}
@end
#pragma mark - Mehrzeilige Zelle mit selbst berechneter Hoehe
@implementation InfoCell
static const CGFloat kPadV = 10, kPadH = 12, kGap = 10, kLineGap = 3;
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:UITableViewCellStyleDefault reuseIdentifier:reuseIdentifier];
if (self) {
_iconView = [[UIImageView alloc] init];
_iconView.contentMode = UIViewContentModeLeft;
[self.contentView addSubview:_iconView];
_titleLabel = [[UILabel alloc] init];
_titleLabel.numberOfLines = 0;
_titleLabel.font = [BVGTheme font:15 weight:UIFontWeightSemibold];
_titleLabel.textColor = [BVGTheme textPrimary];
[self.contentView addSubview:_titleLabel];
_detailLabel = [[UILabel alloc] init];
_detailLabel.numberOfLines = 0;
_detailLabel.font = [BVGTheme font:13 weight:UIFontWeightRegular];
_detailLabel.textColor = [BVGTheme textSecondary];
[self.contentView addSubview:_detailLabel];
}
return self;
}
- (CGFloat)iconWidth {
UIImage *img = self.iconView.image;
return img ? MIN(MAX(img.size.width, 24), 64) : 0;
}
- (CGFloat)textX {
CGFloat iw = [self iconWidth];
return kPadH + (iw ? iw + kGap : 0);
}
+ (CGFloat)heightOfLabel:(UILabel *)l width:(CGFloat)w {
if (!l.text.length && !l.attributedText.length) return 0;
if (w <= 0) return 0;
return ceil([l sizeThatFits:CGSizeMake(w, CGFLOAT_MAX)].height);
}
// Breite fuer den Text bei gegebener Tabellenbreite (Karten-Einrueckung + Pfeil rechts beruecksichtigt)
- (CGFloat)textWidthForContentWidth:(CGFloat)cw {
return cw - [self textX] - kPadH;
}
- (CGFloat)heightForTextWidth:(CGFloat)tw {
CGFloat th = [InfoCell heightOfLabel:self.titleLabel width:tw];
CGFloat dh = [InfoCell heightOfLabel:self.detailLabel width:tw];
CGFloat h = kPadV + th + (th && dh ? kLineGap : 0) + dh + kPadV;
CGFloat iconH = self.iconView.image ? self.iconView.image.size.height + 2 * kPadV : 0;
return ceil(MAX(MAX(h, iconH), 44));
}
- (CGFloat)fittingHeightForTableWidth:(CGFloat)tableWidth {
CGFloat cw = tableWidth - 2 * [BVGTheme cardInset];
if (self.accessoryType != UITableViewCellAccessoryNone || self.accessoryView) cw -= 40; // Pfeil/Schalter (lieber etwas zu viel Platz als abgeschnitten)
return [self heightForTextWidth:[self textWidthForContentWidth:cw]];
}
- (void)layoutSubviews {
[super layoutSubviews];
CGFloat w = self.contentView.bounds.size.width;
CGFloat h = self.contentView.bounds.size.height;
CGFloat tw = [self textWidthForContentWidth:w];
CGFloat th = [InfoCell heightOfLabel:self.titleLabel width:tw];
CGFloat dh = [InfoCell heightOfLabel:self.detailLabel width:tw];
CGFloat total = th + (th && dh ? kLineGap : 0) + dh;
CGFloat y = MAX(kPadV, floor((h - total) / 2)); // vertikal mittig
CGFloat iw = [self iconWidth];
if (iw) {
CGFloat ih = self.iconView.image.size.height;
self.iconView.frame = CGRectMake(kPadH, floor((h - ih) / 2), iw, ih);
}
self.titleLabel.frame = CGRectMake([self textX], y, tw, th);
self.detailLabel.frame = CGRectMake([self textX], y + th + (th && dh ? kLineGap : 0), tw, dh);
}
- (void)prepareForReuse {
[super prepareForReuse];
self.iconView.image = nil;
self.titleLabel.attributedText = nil;
self.titleLabel.text = nil;
self.detailLabel.text = nil;
self.titleLabel.font = [BVGTheme font:15 weight:UIFontWeightSemibold];
self.titleLabel.textColor = [BVGTheme textPrimary];
self.titleLabel.textAlignment = NSTextAlignmentLeft;
self.detailLabel.font = [BVGTheme font:13 weight:UIFontWeightRegular];
self.detailLabel.textColor = [BVGTheme textSecondary];
}
@end
+46 -62
View File
@@ -1,7 +1,7 @@
#import "CardTableViewController.h"
// Ruhiges Layout: Gelb nur in der Navigationsleiste, Inhalt auf hellgrauem Grund.
@interface CardTableViewController ()
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UILabel *subtitleLabel;
@end
@@ -18,25 +18,26 @@
self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
self.tableView.sectionHeaderHeight = 0;
self.tableView.sectionFooterHeight = 0;
self.tableView.contentInset = UIEdgeInsetsMake(0, 0, 16, 0);
self.tableView.contentInset = UIEdgeInsetsMake(0, 0, 12, 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)
[self updateLargeHeader];
// "Zurueck" statt "Back" (bzw. statt langer Titel wie "16:01 – 18:42")
self.navigationItem.backBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Zurück" style:UIBarButtonItemStylePlain
target:nil action:nil];
}
#pragma mark - Grosser Titel
#pragma mark - Titel & Kopf
- (void)setLargeTitle:(NSString *)largeTitle {
_largeTitle = [largeTitle copy];
if (self.isViewLoaded) [self updateLargeHeader];
self.navigationItem.title = largeTitle; // Titel steht nur in der gelben Leiste
}
- (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) {
if (self.subtitleLabel && largeSubtitle.length) { // nur Text tauschen -> kein Springen
self.subtitleLabel.text = largeSubtitle;
self.subtitleLabel.textColor = self.largeSubtitleColor ?: [BVGTheme textSecondary];
} else {
@@ -50,61 +51,41 @@
}
- (void)setHeaderAccessory:(UIView *)headerAccessory {
if (_headerAccessory != headerAccessory) [_headerAccessory removeFromSuperview];
_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;
CGFloat x = [BVGTheme cardInset] + 6;
UIView *header = [[UIView alloc] initWithFrame:CGRectZero];
CGFloat y = 2;
CGFloat y = 8;
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;
y += f.size.height + 4;
}
header.frame = CGRectMake(0, 0, w, y + 4);
if (self.largeSubtitle.length) {
self.subtitleLabel = [[UILabel alloc] initWithFrame:CGRectMake(x, y, w - 2 * x, 16)];
self.subtitleLabel.font = [BVGTheme font:12 weight:UIFontWeightMedium];
self.subtitleLabel.textColor = self.largeSubtitleColor ?: [BVGTheme textSecondary];
self.subtitleLabel.text = self.largeSubtitle;
self.subtitleLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[header addSubview:self.subtitleLabel];
y += 18;
} else {
self.subtitleLabel = nil;
}
header.frame = CGRectMake(0, 0, w, MAX(y, 6));
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 {
@@ -117,19 +98,21 @@
NSMutableAttributedString *s = [[NSMutableAttributedString alloc] init];
NSMutableParagraphStyle *ps = [[NSMutableParagraphStyle alloc] init];
ps.alignment = NSTextAlignmentCenter;
ps.paragraphSpacing = 6;
ps.paragraphSpacing = 4;
if (icon) [s appendAttributedString:[[NSAttributedString alloc] initWithString:[icon stringByAppendingString:@"\n"]
attributes:@{ NSFontAttributeName: [UIFont systemFontOfSize:46], NSParagraphStyleAttributeName: ps }]];
attributes:@{ NSFontAttributeName: [UIFont systemFontOfSize:34], NSParagraphStyleAttributeName: ps }]];
if (title) [s appendAttributedString:[[NSAttributedString alloc] initWithString:[title stringByAppendingString:@"\n"]
attributes:@{ NSFontAttributeName: [BVGTheme font:19 weight:UIFontWeightSemibold],
attributes:@{ NSFontAttributeName: [BVGTheme font:16 weight:UIFontWeightSemibold],
NSForegroundColorAttributeName: [BVGTheme textPrimary], NSParagraphStyleAttributeName: ps }]];
if (text) [s appendAttributedString:[[NSAttributedString alloc] initWithString:text
attributes:@{ NSFontAttributeName: [BVGTheme font:15 weight:UIFontWeightRegular],
attributes:@{ NSFontAttributeName: [BVGTheme font:13 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);
CGFloat top = self.tableView.tableHeaderView.frame.size.height;
CGFloat free = v.bounds.size.height - top;
l.frame = CGRectMake(32, top + MAX(24, free * 0.35 - size.height / 2), w, size.height);
l.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[v addSubview:l];
self.tableView.backgroundView = v;
@@ -139,9 +122,10 @@
+ (UILabel *)sectionLabelWithText:(NSString *)text {
UILabel *l = [[UILabel alloc] init];
l.text = text;
l.font = [BVGTheme font:15 weight:UIFontWeightSemibold];
l.text = [text uppercaseString];
l.font = [BVGTheme font:12 weight:UIFontWeightSemibold];
l.textColor = [BVGTheme textSecondary];
l.accessibilityTraits = UIAccessibilityTraitHeader;
return l;
}
@@ -156,44 +140,44 @@
}
- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section {
if ([self headerTitleFor:section].length) return 42;
return section == 0 ? 8 : 20;
if ([self headerTitleFor:section].length) return 34;
return section == 0 ? 4 : 12;
}
- (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)];
UIView *v = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.bounds.size.width, title.length ? 34 : 4)];
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.frame = CGRectMake([BVGTheme cardInset] + 6, 14, v.bounds.size.width - 2 * [BVGTheme cardInset] - 12, 16);
l.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[v addSubview:l];
return v;
}
- (CGFloat)footerTextHeight:(NSString *)text {
CGFloat w = self.tableView.bounds.size.width - 2 * ([BVGTheme cardInset] + 4);
CGFloat w = self.tableView.bounds.size.width - 2 * ([BVGTheme cardInset] + 6);
CGRect r = [text boundingRectWithSize:CGSizeMake(w, CGFLOAT_MAX)
options:NSStringDrawingUsesLineFragmentOrigin
attributes:@{ NSFontAttributeName: [BVGTheme font:13 weight:UIFontWeightRegular] }
attributes:@{ NSFontAttributeName: [BVGTheme font:12 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;
return text.length ? [self footerTextHeight:text] + 12 : 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)];
UIView *v = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.bounds.size.width, h + 12)];
UILabel *l = [[UILabel alloc] initWithFrame:CGRectMake([BVGTheme cardInset] + 6, 6,
v.bounds.size.width - 2 * ([BVGTheme cardInset] + 6), h)];
l.numberOfLines = 0;
l.font = [BVGTheme font:13 weight:UIFontWeightRegular];
l.font = [BVGTheme font:12 weight:UIFontWeightRegular];
l.textColor = [BVGTheme textSecondary];
l.text = text;
l.autoresizingMask = UIViewAutoresizingFlexibleWidth;
+4 -4
View File
@@ -21,10 +21,10 @@
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.titleLabel.font = [BVGTheme font:14 weight:UIFontWeightMedium];
CGFloat w = ceil([titles[i] sizeWithAttributes:@{ NSFontAttributeName: b.titleLabel.font }].width) + 24;
b.frame = CGRectMake(x, 7, w, 30);
b.layer.cornerRadius = 15;
b.tag = i;
[b addTarget:self action:@selector(tapped:) forControlEvents:UIControlEventTouchUpInside];
[_scroll addSubview:b];
+40 -30
View File
@@ -2,6 +2,7 @@
#import "BVGAPI.h"
#import "BVGTheme.h"
// Abfahrt im Stil der BVG-Anzeigen: Linie | Richtung (fett) + Infozeile | grosse Minutenzahl
@interface DepartureCell ()
@property (nonatomic, strong) UIImageView *badgeView;
@property (nonatomic, strong) UILabel *directionLabel;
@@ -13,7 +14,7 @@
@implementation DepartureCell
+ (CGFloat)height { return 64; }
+ (CGFloat)height { return 56; }
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:UITableViewCellStyleDefault reuseIdentifier:reuseIdentifier];
@@ -23,12 +24,14 @@
[self.contentView addSubview:_badgeView];
_directionLabel = [[UILabel alloc] init];
_directionLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold];
_directionLabel.font = [BVGTheme font:15 weight:UIFontWeightSemibold];
_directionLabel.textColor = [BVGTheme textPrimary];
_directionLabel.adjustsFontSizeToFitWidth = YES;
_directionLabel.minimumScaleFactor = 0.85;
[self.contentView addSubview:_directionLabel];
_infoLabel = [[UILabel alloc] init];
_infoLabel.font = [BVGTheme font:13 weight:UIFontWeightRegular];
_infoLabel.font = [BVGTheme font:12 weight:UIFontWeightRegular];
[self.contentView addSubview:_infoLabel];
_minutesLabel = [[UILabel alloc] init];
@@ -36,13 +39,16 @@
[self.contentView addSubview:_minutesLabel];
_subLabel = [[UILabel alloc] init];
_subLabel.font = [BVGTheme digits:12 weight:UIFontWeightMedium];
_subLabel.font = [BVGTheme digits:11 weight:UIFontWeightMedium];
_subLabel.adjustsFontSizeToFitWidth = YES;
_subLabel.minimumScaleFactor = 0.8;
_subLabel.textAlignment = NSTextAlignmentRight;
[self.contentView addSubview:_subLabel];
_emptyLabel = [[UILabel alloc] init];
_emptyLabel.font = [BVGTheme font:15 weight:UIFontWeightRegular];
_emptyLabel.font = [BVGTheme font:14 weight:UIFontWeightRegular];
_emptyLabel.textColor = [BVGTheme textSecondary];
_emptyLabel.numberOfLines = 2;
_emptyLabel.hidden = YES;
[self.contentView addSubview:_emptyLabel];
}
@@ -53,19 +59,21 @@
[super layoutSubviews];
CGFloat w = self.contentView.bounds.size.width;
CGFloat h = self.contentView.bounds.size.height;
CGFloat right = 76;
CGFloat textX = 80;
CGFloat right = 70;
CGFloat badgeW = MAX(46, MIN(self.badgeView.image.size.width, 60));
CGFloat textX = 12 + badgeW + 10;
CGFloat textW = w - textX - right - 14;
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.badgeView.frame = CGRectMake(12, (h - 22) / 2, badgeW, 22);
self.directionLabel.frame = CGRectMake(textX, 10, textW, 19);
self.infoLabel.frame = CGRectMake(textX, 30, textW, 16);
self.minutesLabel.frame = CGRectMake(w - right - 10, 8, right, 24);
self.subLabel.frame = CGRectMake(w - right - 10, 31, right, 15);
self.emptyLabel.frame = CGRectMake(16, 0, w - 32, h);
}
- (void)setDimmed:(BOOL)dimmed {
self.contentView.alpha = dimmed ? 0.38 : 1.0;
self.contentView.alpha = dimmed ? 0.35 : 1.0;
}
- (void)setAllHidden:(BOOL)hidden {
@@ -85,36 +93,36 @@
[self setAllHidden:NO];
NSDictionary *line = NN(d[@"line"]);
NSString *tripId = NN(d[@"tripId"]);
NSString *dir = NN(d[@"direction"]) ?: @"";
NSString *dir = [BVGAPI shortName: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;
[self setNeedsLayout];
// Richtung
if (cancelled) {
self.directionLabel.attributedText = [[NSAttributedString alloc] initWithString:dir attributes:@{
NSStrikethroughStyleAttributeName: @(NSUnderlineStyleSingle),
NSFontAttributeName: [BVGTheme font:16 weight:UIFontWeightSemibold],
NSFontAttributeName: [BVGTheme font:15 weight:UIFontWeightSemibold],
NSForegroundColorAttributeName: [BVGTheme textTertiary] }];
} else {
self.directionLabel.attributedText = nil;
self.directionLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold];
self.directionLabel.font = [BVGTheme font:15 weight:UIFontWeightSemibold];
self.directionLabel.textColor = [BVGTheme textPrimary];
self.directionLabel.text = dir;
}
// Infozeile: Wecker, Gleis/Gleiswechsel, Stoerung
// Infozeile: Ausfall, Stoerung, Gleis(-wechsel), Wecker
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"]);
NSString *platform = [BVGAPI platform:NN(d[@"platform"])];
NSString *planned = [BVGAPI platform:NN(d[@"plannedPlatform"])];
if (platform && planned && ![platform isEqualToString:planned]) {
[info addObject:[NSString stringWithFormat:@"Gleis %@ statt %@", platform, planned]];
infoColor = [BVGTheme orange];
infoColor = [BVGTheme warningText];
} else if (platform ?: planned) {
[info addObject:[NSString stringWithFormat:@"Gleis %@", platform ?: planned]];
}
@@ -123,25 +131,27 @@
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];
[info insertObject:@"⚠︎ Störung" atIndex:0];
infoColor = [BVGTheme warningText];
break;
}
}
}
if ([BVGAPI hasAlarmForTrip:tripId]) [info addObject:@"⏰ Wecker"];
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:@" · "];
if (!info.count && when) [info addObject:[NSString stringWithFormat:@"ab %@", [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.font = [BVGTheme font:14 weight:UIFontWeightSemibold];
self.minutesLabel.textColor = [BVGTheme red];
self.minutesLabel.text = @"Ausfall";
self.subLabel.attributedText = [[NSAttributedString alloc] initWithString:[BVGAPI hhmm:when] attributes:@{
NSStrikethroughStyleAttributeName: @(NSUnderlineStyleSingle),
NSFontAttributeName: [BVGTheme digits:11 weight:UIFontWeightMedium],
NSForegroundColorAttributeName: [BVGTheme textTertiary] }];
self.accessibilityLabel = [NSString stringWithFormat:@"%@ Richtung %@ fällt aus", [BVGAPI spokenLineName:line], dir];
return;
@@ -154,21 +164,21 @@
if (mins <= 0) {
self.minutesLabel.attributedText = nil;
self.minutesLabel.font = [BVGTheme font:18 weight:UIFontWeightBold];
self.minutesLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold];
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.font = [BVGTheme digits:17 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],
attributes:@{ NSFontAttributeName: [BVGTheme digits:20 weight:UIFontWeightSemibold],
NSForegroundColorAttributeName: numberColor }];
[s appendAttributedString:[[NSAttributedString alloc] initWithString:@" min" attributes:@{
NSFontAttributeName: [BVGTheme font:13 weight:UIFontWeightMedium],
NSFontAttributeName: [BVGTheme font:12 weight:UIFontWeightRegular],
NSForegroundColorAttributeName: [BVGTheme textSecondary] }]];
self.minutesLabel.attributedText = s;
}
+16 -12
View File
@@ -64,8 +64,6 @@
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."];
}
@@ -74,31 +72,37 @@
}
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
return self.stops[section][@"name"];
return [BVGAPI shortName: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];
- (InfoCell *)cellAt:(NSIndexPath *)indexPath {
InfoCell *cell = [[InfoCell alloc] initWithStyle:UITableViewCellStyleDefault 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;
cell.titleLabel.text = [@"⚠︎ " stringByAppendingString:item[@"summary"]];
cell.titleLabel.textColor = [BVGTheme warningText];
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;
if (body.length > 140) body = [[body substringToIndex:137] stringByAppendingString:@" …"];
NSString *lineText = lines.count ? [NSString stringWithFormat:@"Betrifft %@ · ", [lines componentsJoinedByString:@", "]] : @"";
cell.detailLabel.text = [lineText stringByAppendingString:body];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
return cell;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return [[self cellAt:indexPath] fittingHeightForTableWidth:tableView.bounds.size.width];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
return [self cellAt:indexPath];
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tableView deselectRowAtIndexPath:indexPath animated:YES];
NSDictionary *item = self.stops[indexPath.section][@"items"][indexPath.row];
+1
View File
@@ -9,6 +9,7 @@ typedef NS_ENUM(NSInteger, GuidanceStatus) {
};
extern NSString * const GuidanceChangedNotification;
extern NSString * const GuidanceAlternativeCategory; // Mitteilungs-Kategorie mit Knoepfen
// Begleitet eine Verbindung: Ein-/Aussteige-Erinnerungen, regelmaessige Echtzeit-Pruefung,
// Alternativsuche bei Problemen. Die Entscheidung trifft immer der Nutzer.
+47 -1
View File
@@ -6,6 +6,8 @@
NSString * const GuidanceChangedNotification = @"GuidanceChanged";
static NSString * const kSavedJourneyKey = @"guidanceJourney";
static NSString * const kSavedAltKey = @"guidanceAlternative"; // fuer "Uebernehmen" direkt aus der Mitteilung
NSString * const GuidanceAlternativeCategory = @"ALTERNATIVE";
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
@@ -25,6 +27,7 @@ static const CLLocationDistance kExitRadius = 400; // Backup: Aussteigen per G
@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, strong) CLLocationManager *locationManager;
@property (nonatomic, strong) CLLocation *lastLocation;
@property (nonatomic, assign) BOOL preciseLocation;
@end
@implementation GuidanceManager
@@ -107,6 +110,14 @@ static const CLLocationDistance kExitRadius = 400; // Backup: Aussteigen per G
[self changed];
}
// Alternative wird mitgespeichert: so funktioniert "Uebernehmen" auch, wenn iOS die App zwischendurch beendet hat
- (void)setAlternative:(NSDictionary *)alternative {
_alternative = alternative;
NSData *data = alternative ? [NSJSONSerialization dataWithJSONObject:alternative options:0 error:nil] : nil;
if (data) [[NSUserDefaults standardUserDefaults] setObject:data forKey:kSavedAltKey];
else [[NSUserDefaults standardUserDefaults] removeObjectForKey:kSavedAltKey];
}
- (void)save {
NSData *data = [NSJSONSerialization dataWithJSONObject:self.journey options:0 error:nil];
if (data) [[NSUserDefaults standardUserDefaults] setObject:data forKey:kSavedJourneyKey];
@@ -125,6 +136,11 @@ static const CLLocationDistance kExitRadius = 400; // Backup: Aussteigen per G
self.journey = j;
self.active = YES;
self.exitAlertedLegs = [NSMutableSet set];
NSData *altData = [[NSUserDefaults standardUserDefaults] objectForKey:kSavedAltKey];
if ([altData isKindOfClass:[NSData class]]) {
NSDictionary *alt = [NSJSONSerialization JSONObjectWithData:altData options:0 error:nil];
if ([alt isKindOfClass:[NSDictionary class]]) _alternative = alt;
}
[self startMonitoring];
[self checkNow];
}
@@ -151,13 +167,40 @@ static const CLLocationDistance kExitRadius = 400; // Backup: Aussteigen per G
if ([CLLocationManager authorizationStatus] == kCLAuthorizationStatusNotDetermined) {
[self.locationManager requestWhenInUseAuthorization];
}
self.preciseLocation = YES; // erzwingt die erste Einstellung
[self updateLocationAccuracy];
[self.locationManager startUpdatingLocation];
}
// Akku sparen: genaues GPS nur, wenn es gebraucht wird (zu Fuss, kurz vor dem Einsteigen oder Aussteigen).
// Sonst grobe Ortung ueber Funkzellen - die App laeuft trotzdem im Hintergrund weiter und prueft jede Minute.
- (BOOL)needsPreciseLocation {
NSArray *legs = [self legs];
NSInteger idx = [self currentLegIndex];
if (idx >= (NSInteger)legs.count) return NO;
NSDictionary *leg = legs[idx];
if ([GuidanceManager isWalk:leg]) return YES;
NSDate *dep = [GuidanceManager legDeparture:leg];
NSDate *arr = [GuidanceManager legArrival:leg];
if (dep && [dep timeIntervalSinceNow] < 5 * 60 && [dep timeIntervalSinceNow] > -60) return YES; // Einsteigen
if (arr && [arr timeIntervalSinceNow] < 6 * 60) return YES; // Aussteigen
return NO;
}
- (void)updateLocationAccuracy {
if (!self.locationManager) return;
BOOL precise = [self needsPreciseLocation];
if (precise == self.preciseLocation && self.locationManager.distanceFilter > 0) return;
self.preciseLocation = precise;
self.locationManager.desiredAccuracy = precise ? kCLLocationAccuracyHundredMeters : kCLLocationAccuracyThreeKilometers;
self.locationManager.distanceFilter = precise ? 30 : 1000;
}
#pragma mark - Pruefen
- (void)checkNow {
if (!self.active || self.checking) return;
[self updateLocationAccuracy];
self.checking = YES;
[self changed];
@@ -331,7 +374,7 @@ static const CLLocationDistance kExitRadius = 400; // Backup: Aussteigen per G
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.",
NSString *text = [NSString stringWithFormat:@"⚠ %@ Alternative: %@, Ankunft %@%@. Wischen oder antippen zum Entscheiden.",
problem, [lines componentsJoinedByString:@" → "], [BVGAPI hhmm:bestArr],
diff > 0 ? [NSString stringWithFormat:@" (+%ld min)", (long)diff] : @""];
[self notifyNow:text kind:@"alternative"];
@@ -364,6 +407,7 @@ static const CLLocationDistance kExitRadius = 400; // Backup: Aussteigen per G
}
- (NSString *)platformText:(NSString *)platform {
platform = [BVGAPI platform:platform];
return platform ? [NSString stringWithFormat:@", Gleis %@", platform] : @"";
}
@@ -408,6 +452,7 @@ static const CLLocationDistance kExitRadius = 400; // Backup: Aussteigen per G
n.alertBody = text;
n.soundName = UILocalNotificationDefaultSoundName; // bei Stumm: nur Vibration (System)
n.userInfo = @{ @"kind": kind };
if ([kind isEqualToString:@"alternative"]) n.category = GuidanceAlternativeCategory; // Knoepfe Uebernehmen/Behalten
[[UIApplication sharedApplication] presentLocalNotificationNow:n];
}
@@ -470,6 +515,7 @@ static const CLLocationDistance kExitRadius = 400; // Backup: Aussteigen per G
- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray<CLLocation *> *)locations {
self.lastLocation = locations.lastObject;
if (!self.active) return;
[self updateLocationAccuracy];
// Hintergrund: falls der Timer nicht feuert, haelt der Standort die Pruefung am Laufen
if (!self.lastCheck || [self.lastCheck timeIntervalSinceNow] < -kCheckInterval) [self checkNow];
+80 -70
View File
@@ -15,13 +15,10 @@ typedef NS_ENUM(NSInteger, GSection) { GSectionStatus, GSectionAlternative, GSec
- (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)];
UIBarButtonItem *check = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemRefresh
target:self action:@selector(check)];
UIBarButtonItem *speak = [BVGAPI barButtonWithIcon:@"speaker" target:self action:@selector(speak)];
speak.accessibilityLabel = @"Anweisung ansagen";
self.navigationItem.rightBarButtonItems = @[check, speak];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(reload)
@@ -140,47 +137,54 @@ typedef NS_ENUM(NSInteger, GSection) { GSectionStatus, GSectionAlternative, GSec
return 0;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
CardCell *cell = [[CardCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:nil];
+ (NSString *)name:(NSDictionary *)place {
return [BVGAPI shortName:NN(NN(place)[@"name"])] ?: @"";
}
// "18:05 (+2)" bzw. "18:05"
+ (NSString *)time:(NSDate *)t delay:(id)delaySeconds {
NSInteger d = [NN(delaySeconds) integerValue] / 60;
return d > 0 ? [NSString stringWithFormat:@"%@ (+%ld)", [BVGAPI hhmm:t], (long)d] : [BVGAPI hhmm:t];
}
- (InfoCell *)cellAt:(NSIndexPath *)indexPath {
InfoCell *cell = [[InfoCell alloc] initWithStyle:UITableViewCellStyleDefault 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];
cell.titleLabel.text = [BVGAPI shortName:[g currentInstruction]];
cell.titleLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold];
cell.titleLabel.textColor = [UIColor whiteColor];
cell.cardColor = [BVGTheme textPrimary]; // aktuelle Anweisung hervorgehoben
} else if (indexPath.row == 1) {
switch (g.status) {
case GuidanceStatusOK:
cell.textLabel.text = @"✓ Alle Anschlüsse erreichbar";
cell.textLabel.textColor = [BVGTheme green];
cell.titleLabel.text = @"✓ Alle Anschlüsse erreichbar";
cell.titleLabel.textColor = [BVGTheme green];
break;
case GuidanceStatusAtRisk:
cell.textLabel.text = [@"⚠ " stringByAppendingString:g.problemText ?: @"Umstieg knapp"];
cell.textLabel.textColor = [BVGTheme orange];
cell.titleLabel.text = [@"⚠︎ " stringByAppendingString:g.problemText ?: @"Umstieg knapp"];
cell.titleLabel.textColor = [BVGTheme warningText];
break;
case GuidanceStatusBroken:
cell.textLabel.text = [@"✕ " stringByAppendingString:g.problemText ?: @"Route nicht mehr möglich"];
cell.textLabel.textColor = [BVGTheme red];
cell.titleLabel.text = [@"✕ " stringByAppendingString:g.problemText ?: @"Route nicht mehr möglich"];
cell.titleLabel.textColor = [BVGTheme red];
break;
case GuidanceStatusDone:
cell.textLabel.text = @"🎉 Ziel erreicht";
cell.titleLabel.text = @"Ziel erreicht";
break;
}
cell.textLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold];
cell.titleLabel.font = [BVGTheme font:14 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];
cell.titleLabel.text = [NSString stringWithFormat:@"Ankunft %@",
[GuidanceViewController time:[GuidanceManager legArrival:last] delay:last[@"arrivalDelay"]]];
cell.titleLabel.font = [BVGTheme digits:14 weight:UIFontWeightSemibold];
cell.detailLabel.text = [GuidanceViewController name:last[@"destination"]];
}
return cell;
}
@@ -190,25 +194,20 @@ typedef NS_ENUM(NSInteger, GSection) { GSectionStatus, GSectionAlternative, GSec
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];
cell.iconView.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;
cell.titleLabel.text = [NSString stringWithFormat:@"%@ – %@", [BVGAPI hhmm:dep], [BVGAPI hhmm:arr]];
cell.titleLabel.font = [BVGTheme digits:15 weight:UIFontWeightSemibold];
cell.detailLabel.text = diff > 0 ? [NSString stringWithFormat:@"%ld min später als geplant", (long)diff]
: @"Ankunft wie geplant oder früher";
} else {
cell.textLabel.text = @"Aktuelle Route behalten";
cell.textLabel.textColor = [BVGTheme textSecondary];
cell.textLabel.textAlignment = NSTextAlignmentCenter;
BOOL accept = indexPath.row == 1;
cell.titleLabel.text = accept ? @"Alternative übernehmen" : @"Aktuelle Route behalten";
cell.titleLabel.textColor = accept ? [BVGTheme green] : [BVGTheme textSecondary];
cell.titleLabel.textAlignment = NSTextAlignmentCenter;
cell.selectionStyle = UITableViewCellSelectionStyleDefault;
}
return cell;
@@ -220,46 +219,49 @@ typedef NS_ENUM(NSInteger, GSection) { GSectionStatus, GSectionAlternative, GSec
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"]) ?: @"";
NSString *origin = [GuidanceViewController name:leg[@"origin"]];
NSString *dest = [GuidanceViewController name:leg[@"destination"]];
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];
if (mins <= 1 && ([origin isEqualToString:dest] || !NN(leg[@"distance"]) || [NN(leg[@"distance"]) integerValue] < 80)) {
cell.titleLabel.text = @"Umsteigen";
cell.detailLabel.text = [@"am Bahnhof " stringByAppendingString:dest];
} else {
cell.titleLabel.text = [NSString stringWithFormat:@"🚶 %ld min zu Fuß", (long)MAX(mins, 1)];
cell.detailLabel.text = [@"bis " stringByAppendingString:dest];
}
cell.titleLabel.font = [BVGTheme font:14 weight:UIFontWeightMedium];
} 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.iconView.image = [BVGAPI badgeForLine:leg[@"line"]];
cell.titleLabel.text = [@"→ " stringByAppendingString:[BVGAPI shortName:NN(leg[@"direction"])] ?: @""];
cell.titleLabel.font = [BVGTheme font:15 weight:(indexPath.row == current ? UIFontWeightSemibold : UIFontWeightRegular)];
NSString *dp = [BVGAPI platform:NN(leg[@"departurePlatform"])], *ap = [BVGAPI platform:NN(leg[@"arrivalPlatform"])];
NSMutableString *d = [NSMutableString string];
if ([NN(leg[@"cancelled"]) boolValue]) [d appendString:@"FÄLLT AUS\n"];
[d appendFormat:@"ab %@ %@%@", [GuidanceViewController time:dep delay:leg[@"departureDelay"]], origin,
dp ? [NSString stringWithFormat:@" · Gl. %@", dp] : @""];
[d appendFormat:@"\nan %@ %@%@", [GuidanceViewController time:arr delay:leg[@"arrivalDelay"]], dest,
ap ? [NSString stringWithFormat:@" · Gl. %@", ap] : @""];
cell.detailLabel.text = d;
cell.detailLabel.font = [BVGTheme digits:13 weight:UIFontWeightRegular];
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];
cell.titleLabel.textColor = done ? [BVGTheme textTertiary] : [BVGTheme textPrimary];
cell.detailLabel.textColor = [NN(leg[@"cancelled"]) boolValue] ? [BVGTheme red] : [BVGTheme textSecondary];
if (indexPath.row == current) cell.cardColor = [BVGTheme accentSoft];
if (done) cell.contentView.alpha = 0.5;
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;
BOOL share = indexPath.row == 0;
cell.titleLabel.text = share ? @"Ankunftszeit teilen" : @"Navigation beenden";
cell.titleLabel.font = [BVGTheme font:15 weight:UIFontWeightRegular];
cell.titleLabel.textColor = share ? [BVGTheme link] : [BVGTheme red];
cell.titleLabel.textAlignment = NSTextAlignmentCenter;
cell.selectionStyle = UITableViewCellSelectionStyleDefault;
return cell;
}
@@ -267,6 +269,14 @@ typedef NS_ENUM(NSInteger, GSection) { GSectionStatus, GSectionAlternative, GSec
return cell;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return [[self cellAt:indexPath] fittingHeightForTableWidth:tableView.bounds.size.width];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
return [self cellAt:indexPath];
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tableView deselectRowAtIndexPath:indexPath animated:YES];
GuidanceManager *g = [GuidanceManager shared];
+13 -13
View File
@@ -46,14 +46,14 @@
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.frame = CGRectMake([BVGTheme cardInset], 16, w - 2 * [BVGTheme cardInset], 46);
start.autoresizingMask = UIViewAutoresizingFlexibleWidth;
start.backgroundColor = [BVGTheme accent];
start.layer.cornerRadius = 14;
start.titleLabel.font = [BVGTheme font:18 weight:UIFontWeightSemibold];
start.backgroundColor = [BVGTheme textPrimary];
start.layer.cornerRadius = [BVGTheme cardRadius];
start.titleLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold];
[start setTitle:@"Navigation starten" forState:UIControlStateNormal];
[start setTitleColor:[BVGTheme textPrimary] forState:UIControlStateNormal];
[start setTitleColor:[BVGTheme textSecondary] forState:UIControlStateHighlighted];
[start setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
[start setTitleColor:[BVGTheme textTertiary] forState:UIControlStateHighlighted];
[start addTarget:self action:@selector(startGuidance) forControlEvents:UIControlEventTouchUpInside];
[footer addSubview:start];
self.tableView.tableFooterView = footer;
@@ -128,7 +128,7 @@
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.font = [BVGTheme font:14 weight:UIFontWeightMedium];
cell.textLabel.textColor = [BVGTheme textSecondary];
cell.cardColor = [BVGTheme background];
return cell;
@@ -136,8 +136,8 @@
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.textLabel.text = [@"→ " stringByAppendingString:[BVGAPI shortName:NN(leg[@"direction"])] ?: @""];
cell.textLabel.font = [BVGTheme font:15 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];
@@ -152,13 +152,13 @@
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"]);
NSString *platform = [BVGAPI platform:NN(leg[isDep ? @"departurePlatform" : @"arrivalPlatform"])];
NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:[BVGAPI hhmm:t]
attributes:@{ NSFontAttributeName: [BVGTheme digits:17 weight:UIFontWeightSemibold],
attributes:@{ NSFontAttributeName: [BVGTheme digits:15 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],
[title appendAttributedString:[[NSAttributedString alloc] initWithString:[@" " stringByAppendingString:[BVGAPI shortName:NN(place[@"name"])] ?: @"?"]
attributes:@{ NSFontAttributeName: [BVGTheme font:15 weight:UIFontWeightRegular],
NSForegroundColorAttributeName: [BVGTheme textPrimary] }]];
cell.textLabel.attributedText = title;
+269 -210
View File
@@ -23,12 +23,14 @@
self = [super initWithStyle:UITableViewCellStyleDefault reuseIdentifier:reuseIdentifier];
if (self) {
_timeLabel = [[UILabel alloc] init];
_timeLabel.font = [BVGTheme digits:20 weight:UIFontWeightSemibold];
_timeLabel.font = [BVGTheme digits:17 weight:UIFontWeightSemibold];
_timeLabel.textColor = [BVGTheme textPrimary];
_timeLabel.adjustsFontSizeToFitWidth = YES;
_timeLabel.minimumScaleFactor = 0.8;
[self.contentView addSubview:_timeLabel];
_durationLabel = [[UILabel alloc] init];
_durationLabel.font = [BVGTheme digits:15 weight:UIFontWeightSemibold];
_durationLabel.font = [BVGTheme digits:14 weight:UIFontWeightMedium];
_durationLabel.textColor = [BVGTheme textSecondary];
_durationLabel.textAlignment = NSTextAlignmentRight;
[self.contentView addSubview:_durationLabel];
@@ -38,7 +40,7 @@
[self.contentView addSubview:_linesView];
_infoLabel = [[UILabel alloc] init];
_infoLabel.font = [BVGTheme font:13 weight:UIFontWeightRegular];
_infoLabel.font = [BVGTheme font:12 weight:UIFontWeightRegular];
_infoLabel.textColor = [BVGTheme textSecondary];
[self.contentView addSubview:_infoLabel];
@@ -50,18 +52,25 @@
- (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);
self.timeLabel.frame = CGRectMake(14, 9, w - 110, 21);
self.durationLabel.frame = CGRectMake(w - 92, 10, 84, 19);
self.linesView.frame = CGRectMake(14, 34, w - 22, 22);
self.infoLabel.frame = CGRectMake(14, 60, w - 22, 15);
}
@end
#pragma mark - Routenplaner
typedef NS_ENUM(NSInteger, JourneySection) {
JourneySectionDate, // Datumsauswahl (nur bei "Abfahrt um" / "Ankunft um")
JourneySectionSearch, // Suchen-Knopf + Statuszeile
JourneySectionQuick, // Schnellziele (wenn noch keine Ergebnisse)
JourneySectionResult, // je eine Karte pro Verbindung
};
@interface JourneyViewController () <CLLocationManagerDelegate>
@property (nonatomic, strong) NSDictionary *from; // nil = Mein Standort, sonst {id, name}
@property (nonatomic, strong) NSDictionary *from; // nil = Mein Standort, sonst {id, name} oder {name, latitude, longitude}
@property (nonatomic, strong) NSDictionary *to;
@property (nonatomic, strong) ChipBar *timeControl; // Jetzt / Abfahrt um / Ankunft um
@property (nonatomic, strong) UIDatePicker *datePicker;
@@ -79,13 +88,11 @@
- (void)viewDidLoad {
[super viewDidLoad];
self.largeTitle = @"Route";
self.largeSubtitle = [NSString stringWithFormat:@"Umstiege mit mind. %ld min Puffer", (long)[BVGSettings transferMinutes]];
self.largeTitle = @"Verbindung";
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];
@@ -94,102 +101,108 @@
self.datePicker.minuteInterval = 1;
self.datePicker.date = [NSDate dateWithTimeIntervalSinceNow:15 * 60];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateNaviButton)
name:GuidanceChangedNotification object:nil];
[self buildHeader];
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
[nc addObserver:self selector:@selector(updateNaviButton) name:GuidanceChangedNotification object:nil];
[nc addObserver:self selector:@selector(settingsChanged) name:BVGSettingsChangedNotification 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)settingsChanged {
[self.tableView reloadData];
}
#pragma mark - Kopfbereich: Von / Nach + Zeit
- (UIButton *)endpointButton:(BOOL)isFrom frame:(CGRect)frame {
UIButton *b = [UIButton buttonWithType:UIButtonTypeCustom];
b.frame = frame;
b.autoresizingMask = UIViewAutoresizingFlexibleWidth;
b.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft;
b.contentEdgeInsets = UIEdgeInsetsMake(0, 12, 0, 8);
[b setBackgroundImage:[BVGTheme imageWithColor:[BVGTheme cardPressed]] forState:UIControlStateHighlighted];
b.titleLabel.lineBreakMode = NSLineBreakByTruncatingTail;
NSString *label = isFrom ? @"Von " : @"Nach ";
NSString *value;
BOOL placeholder = NO;
if (isFrom) value = self.from ? [BVGAPI shortName:self.from[@"name"]] : @"Mein Standort";
else if (self.to) value = [BVGAPI shortName:self.to[@"name"]];
else { value = @"Ziel eingeben"; placeholder = YES; }
NSMutableAttributedString *t = [[NSMutableAttributedString alloc] initWithString:label attributes:@{
NSFontAttributeName: [BVGTheme font:13 weight:UIFontWeightRegular],
NSForegroundColorAttributeName: [BVGTheme textSecondary] }];
[t appendAttributedString:[[NSAttributedString alloc] initWithString:value ?: @"" attributes:@{
NSFontAttributeName: [BVGTheme font:15 weight:(placeholder ? UIFontWeightRegular : UIFontWeightSemibold)],
NSForegroundColorAttributeName: placeholder ? [BVGTheme textTertiary] : [BVGTheme textPrimary] }]];
[b setAttributedTitle:t forState:UIControlStateNormal];
b.accessibilityLabel = [NSString stringWithFormat:@"%@ %@", isFrom ? @"Start" : @"Ziel", value];
b.tag = isFrom ? 0 : 1;
[b addTarget:self action:@selector(endpointTapped:) forControlEvents:UIControlEventTouchUpInside];
return b;
}
- (void)buildHeader {
CGFloat w = self.view.bounds.size.width;
CGFloat inset = [BVGTheme cardInset];
CGFloat cardW = w - 2 * inset;
CGFloat rowH = 42;
UIView *v = [[UIView alloc] initWithFrame:CGRectMake(0, 0, w, 2 * rowH + 46)];
// weisse Karte mit zwei Zeilen und Tausch-Knopf rechts
UIView *card = [[UIView alloc] initWithFrame:CGRectMake(inset, 0, cardW, 2 * rowH)];
card.autoresizingMask = UIViewAutoresizingFlexibleWidth;
card.backgroundColor = [UIColor whiteColor];
card.layer.cornerRadius = [BVGTheme cardRadius];
card.clipsToBounds = YES;
[v addSubview:card];
[card addSubview:[self endpointButton:YES frame:CGRectMake(0, 0, cardW - 46, rowH)]];
[card addSubview:[self endpointButton:NO frame:CGRectMake(0, rowH, cardW - 46, rowH)]];
UIView *line = [[UIView alloc] initWithFrame:CGRectMake(12, rowH, cardW - 58, 1.0 / [UIScreen mainScreen].scale)];
line.backgroundColor = [BVGTheme separator];
line.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[card addSubview:line];
UIButton *swap = [UIButton buttonWithType:UIButtonTypeCustom];
swap.frame = CGRectMake(cardW - 42, rowH - 16, 32, 32);
swap.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin;
[swap setBackgroundImage:[BVGTheme roundedImageWithColor:[BVGTheme background] radius:16] forState:UIControlStateNormal];
[swap setBackgroundImage:[BVGTheme roundedImageWithColor:[BVGTheme cardPressed] radius:16] forState:UIControlStateHighlighted];
swap.titleLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold];
[swap setTitle:@"⇅" forState:UIControlStateNormal];
[swap setTitleColor:[BVGTheme textPrimary] forState:UIControlStateNormal];
swap.accessibilityLabel = @"Start und Ziel tauschen";
[swap addTarget:self action:@selector(swap) forControlEvents:UIControlEventTouchUpInside];
[card addSubview:swap];
// Zeit-Chips darunter
[self.timeControl removeFromSuperview];
self.timeControl.frame = CGRectMake(0, 2 * rowH + 2, w, 44);
self.timeControl.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[v addSubview:self.timeControl];
self.headerAccessory = v;
}
- (void)endpointTapped:(UIButton *)b {
[self chooseEndpoint:(b.tag == 0)];
}
- (void)updateNaviButton {
self.navigationItem.rightBarButtonItem = [GuidanceManager shared].active
? [[UIBarButtonItem alloc] initWithTitle:@"Navigation" style:UIBarButtonItemStyleDone target:self action:@selector(openGuidance)]
? [[UIBarButtonItem alloc] initWithTitle:@"Navigation ›" style:UIBarButtonItemStyleDone target:self action:@selector(openGuidance)]
: nil;
}
@@ -202,62 +215,81 @@
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];
[self.tableView reloadData];
}
- (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
self.to = oldFrom; // nil (Mein Standort) als Ziel ist nicht erlaubt
if (!self.to) {
self.to = self.from;
self.from = nil;
}
self.journeys = @[];
self.errorText = nil;
[self buildHeader];
[self.tableView reloadData];
}
#pragma mark - Auswahl
#pragma mark - Auswahl von Start/Ziel: direkt die Suche mit Schnellauswahl
- (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];
SearchViewController *search = [[SearchViewController alloc] initWithStyle:UITableViewStylePlain];
search.allowAddresses = YES;
search.offerSavedPlaces = YES;
search.offerCurrentLocation = isFrom;
search.pickTitle = isFrom ? @"Start" : @"Ziel";
search.pickHandler = ^(NSDictionary *place) {
if (!isFrom && !place) return; // "Mein Standort" nur als Start
[weakSelf setEndpoint:place isFrom:isFrom];
};
[self.navigationController pushViewController:search animated:YES];
}
- (void)setEndpoint:(NSDictionary *)stop isFrom:(BOOL)isFrom {
if (isFrom) self.from = stop; else self.to = stop;
- (void)setEndpoint:(NSDictionary *)place isFrom:(BOOL)isFrom {
if (isFrom) self.from = place; else self.to = place;
self.journeys = @[];
self.errorText = nil;
[self buildHeader];
[self.tableView reloadData];
if (self.to) [self startSearch]; // Ziel steht -> gleich suchen (wie in der BVG-App)
}
#pragma mark - Schnellziele
- (NSString *)quickKey:(NSInteger)idx {
return idx == 0 ? @"home" : @"work";
}
- (void)quickPlaceTapped:(NSInteger)idx {
NSDictionary *place = [BVGSettings placeForKey:[self quickKey:idx]];
if (!place) { [self editQuickPlace:idx]; return; }
self.from = nil; // ab meinem Standort
self.timeControl.selectedIndex = 0;
[self setEndpoint:place isFrom:NO];
}
- (void)editQuickPlace:(NSInteger)idx {
NSString *key = [self quickKey:idx];
__weak JourneyViewController *weakSelf = self;
SearchViewController *search = [[SearchViewController alloc] initWithStyle:UITableViewStylePlain];
search.allowAddresses = YES;
search.pickTitle = idx == 0 ? @"Zuhause festlegen" : @"Arbeit festlegen";
search.pickHandler = ^(NSDictionary *place) {
if (!place) return;
[BVGSettings setPlace:place forKey:key];
[weakSelf.tableView reloadData];
};
[self.navigationController pushViewController:search animated:YES];
}
- (void)quickLongPress:(UILongPressGestureRecognizer *)g {
if (g.state != UIGestureRecognizerStateBegan) return;
NSIndexPath *ip = [self.tableView indexPathForRowAtPoint:[g locationInView:self.tableView]];
if (ip && [self kindOfSection:ip.section] == JourneySectionQuick) [self editQuickPlace:ip.row];
}
#pragma mark - Suche
@@ -298,7 +330,7 @@
- (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.errorText = @"Standort nicht verfügbar. Tippe auf „Von“ und wähle eine Haltestelle.";
[self.tableView reloadData];
}
@@ -321,7 +353,7 @@
NSLog(@"Journey-Fehler: %@ %@", error, json);
self.errorText = @"Keine Verbindung gefunden oder Server nicht erreichbar.";
}
// Sicherheitsnetz: nur Verbindungen mit mind. 5 min pro Umstieg zeigen
// Sicherheitsnetz: nur Verbindungen mit mind. X min pro Umstieg zeigen
NSMutableArray *ok = [NSMutableArray array];
for (NSDictionary *j in list ?: @[]) {
id legs = NN(j[@"legs"]);
@@ -336,20 +368,12 @@
#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];
NSDate *dep = [GuidanceManager legDeparture:legs.firstObject];
NSDate *arr = [GuidanceManager legArrival:legs.lastObject];
NSInteger mins = (dep && arr) ? (NSInteger)round([arr timeIntervalSinceDate:dep] / 60.0) : 0;
NSMutableArray *lines = [NSMutableArray array];
@@ -363,130 +387,165 @@
[lines addObject:leg[@"line"]];
if (firstDelay < 0) {
firstDelay = [NN(leg[@"departureDelay"]) integerValue] / 60;
firstStop = NN(NN(leg[@"origin"])[@"name"]);
firstTransitDep = [JourneyViewController legDeparture:leg];
firstStop = [BVGAPI shortName:NN(NN(leg[@"origin"])[@"name"])];
firstTransitDep = [GuidanceManager legDeparture:leg];
}
}
cell.timeLabel.text = [NSString stringWithFormat:@"%@ → %@", [BVGAPI hhmm:dep], [BVGAPI hhmm:arr]];
cell.durationLabel.text = [NSString stringWithFormat:@"%ld min", (long)mins];
cell.timeLabel.text = [NSString stringWithFormat:@"%@ – %@", [BVGAPI hhmm:dep], [BVGAPI hhmm:arr]];
cell.durationLabel.text = mins >= 60 ? [NSString stringWithFormat:@"%ld:%02ld h", (long)(mins / 60), (long)(mins % 60)]
: [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 (transfers == 0) [info addObject:@"direkt"];
else [info addObject:[NSString stringWithFormat:@"%ld× umsteigen", (long)transfers]];
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"];
UIColor *color = [BVGTheme textSecondary];
if (firstDelay > 0) { [info insertObject:[NSString stringWithFormat:@"+%ld min", (long)firstDelay] atIndex:0]; color = [BVGTheme red]; }
if (cancelled) { [info insertObject:@"⚠︎ Ausfall" atIndex:0]; color = [BVGTheme red]; }
cell.infoLabel.text = [info componentsJoinedByString:@" · "];
cell.infoLabel.textColor = (cancelled || firstDelay > 0) ? [BVGTheme red] : [BVGTheme textSecondary];
cell.infoLabel.textColor = color;
cell.accessibilityLabel = [NSString stringWithFormat:@"%@ bis %@, %ld Minuten, %@",
[BVGAPI hhmm:dep], [BVGAPI hhmm:arr], (long)mins, cell.infoLabel.text];
}
#pragma mark - Table
// Abschnitte: 0 Von/Nach · 1 Zeit · 2 Suchen-Knopf · 3+ je eine Karte pro Verbindung
- (NSArray *)sectionKinds {
NSMutableArray *k = [NSMutableArray array];
if (self.timeControl.selectedIndex > 0) [k addObject:@(JourneySectionDate)];
[k addObject:@(JourneySectionSearch)];
if (!self.journeys.count && !self.searching) [k addObject:@(JourneySectionQuick)];
for (NSUInteger i = 0; i < self.journeys.count; i++) [k addObject:@(JourneySectionResult)];
return k;
}
- (JourneySection)kindOfSection:(NSInteger)section {
NSArray *k = [self sectionKinds];
return section < (NSInteger)k.count ? [k[section] integerValue] : JourneySectionResult;
}
- (NSDictionary *)journeyForSection:(NSInteger)section {
NSInteger first = [[self sectionKinds] indexOfObject:@(JourneySectionResult)];
NSInteger i = section - first;
return (first != NSNotFound && i >= 0 && i < (NSInteger)self.journeys.count) ? self.journeys[i] : nil;
}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return 3 + self.journeys.count;
return [self sectionKinds].count;
}
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
if (section == 1) return @"Wann?";
if (section == 3) return @"Verbindungen";
JourneySection kind = [self kindOfSection:section];
if (kind == JourneySectionQuick) return @"Schnellziele";
if (kind == JourneySectionResult && section > 0 && [self kindOfSection:section - 1] != JourneySectionResult) {
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;
JourneySection kind = [self kindOfSection:section];
if (kind == JourneySectionSearch) {
if (self.searching) return @"Suche Verbindungen …";
if (self.errorText) return self.errorText;
return [NSString stringWithFormat:@"Umstiege immer mit mind. %ld min Puffer.", (long)[BVGSettings transferMinutes]];
}
if (kind == JourneySectionQuick) return @"Gedrückt halten zum Ändern.";
return nil;
}
- (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;
}
return [self kindOfSection:section] == JourneySectionQuick ? 2 : 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;
switch ([self kindOfSection:indexPath.section]) {
case JourneySectionDate: return 180;
case JourneySectionSearch: return 44;
case JourneySectionQuick: return 50;
case JourneySectionResult: return 84;
}
return 52;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath.section >= 3) {
JourneySection kind = [self kindOfSection:indexPath.section];
if (kind == JourneySectionResult) {
JourneyCell *cell = [tableView dequeueReusableCellWithIdentifier:@"journey"];
if (!cell) cell = [[JourneyCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"journey"];
[self configureCell:cell journey:self.journeys[indexPath.section - 3]];
[self configureCell:cell journey:[self journeyForSection:indexPath.section]];
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) {
if (kind == JourneySectionDate) {
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 {
} else if (kind == JourneySectionSearch) {
// dunkler Hauptknopf
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];
cell.textLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold];
cell.textLabel.textColor = [UIColor whiteColor];
cell.cardColor = [BVGTheme textPrimary];
cell.accessibilityTraits = UIAccessibilityTraitButton;
} else {
NSArray *defs = @[@[@"home", @"⌂", @"Nach Hause"], @[@"work", @"✱", @"Zur Arbeit"]];
NSArray *d = defs[indexPath.row];
NSDictionary *place = [BVGSettings placeForKey:d[0]];
cell.imageView.image = [JourneyViewController roundIcon:d[1]];
cell.textLabel.text = d[2];
cell.textLabel.font = [BVGTheme font:15 weight:UIFontWeightSemibold];
cell.detailTextLabel.font = [BVGTheme font:12 weight:UIFontWeightRegular];
cell.detailTextLabel.text = place ? place[@"name"] : @"Antippen zum Festlegen";
cell.detailTextLabel.textColor = place ? [BVGTheme textSecondary] : [BVGTheme link];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
if (!cell.gestureRecognizers.count) {
[cell addGestureRecognizer:[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(quickLongPress:)]];
}
}
return cell;
}
+ (UIImage *)roundIcon:(NSString *)text {
CGSize size = CGSizeMake(28, 28);
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
[[BVGTheme accentSoft] setFill];
[[UIBezierPath bezierPathWithOvalInRect:CGRectMake(0, 0, 28, 28)] fill];
NSDictionary *attrs = @{ NSFontAttributeName: [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold],
NSForegroundColorAttributeName: [BVGTheme textPrimary] };
CGSize t = [text sizeWithAttributes:attrs];
[text drawAtPoint:CGPointMake((28 - t.width) / 2, (28 - t.height) / 2) withAttributes:attrs];
UIImage *img = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return img;
}
- (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];
switch ([self kindOfSection:indexPath.section]) {
case JourneySectionSearch: [self startSearch]; break;
case JourneySectionQuick: [self quickPlaceTapped:indexPath.row]; break;
case JourneySectionResult: {
NSDictionary *j = [self journeyForSection:indexPath.section];
if (!j) break;
JourneyDetailViewController *vc = [[JourneyDetailViewController alloc] initWithJourney:j];
[self.navigationController pushViewController:vc animated:YES];
break;
}
default: break;
}
}
+21 -16
View File
@@ -1,20 +1,25 @@
BVGNearby v8 – Schnellziele, Richtungsfilter, Hinweise, Teilen
===============================================================
BVGNearby v15 – Widget, Mitteilungs-Knoepfe, Akku, Feinschliff
==============================================================
1) Alle Dateien aus dem ZIP in den Ordner BVGNearby/ kopieren (ersetzen).
Installation (im Projektordner, neben BVGNearby.xcodeproj):
unzip -o BVGNearby-v15.zip # ersetzt BVGNearby/, legt Widget/ an, ersetzt deploy.sh
chmod +x deploy.sh
./deploy.sh # macht den Methoden-Check jetzt selbst
2) Neue Dateien ins Xcode-Projekt aufnehmen – automatisch:
sudo gem install xcodeproj # einmalig
ruby BVGNearby/add_files.rb # im Ordner neben BVGNearby.xcodeproj
Kein Xcode-Target noetig: deploy.sh baut das Widget direkt mit clang,
legt es als PlugIns/BVGWidget.appex in die App und signiert beides mit ldid.
Macht das Widget Aerger: NO_WIDGET=1 ./deploy.sh
Oder von Hand: File → Add Files … → DisruptionsViewController.h/.m
(Target BVGNearby anhaken, "Copy items" aus).
Widget aktivieren: Mitteilungszentrale runterziehen -> "Heute" -> ganz unten
"Bearbeiten" -> "BVG Abfahrten" hinzufuegen.
Zeigt die ersten 2 Favoriten (je 3 Abfahrten), ohne Favoriten die naechsten
Haltestellen per Standort. Antippen oeffnet die Haltestelle in der App.
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
Neu in v15:
- Widget fuer die Mitteilungszentrale
- Alternativ-Mitteilung: "Uebernehmen" / "Behalten" direkt auf dem Sperrbildschirm
(nach links wischen) bzw. im Banner (herunterziehen)
- Navigation spart Akku: genaues GPS nur zu Fuss und kurz vor Ein-/Aussteigen
- Lautsprecher-Symbol statt "Ansage", Ortungspfeil in der Karte
- Gleis "2 (U7)" -> "2"; dezenteres Badge am Verbindung-Tab
- Karte: kleinere Haltestellen und schlankere Statusleiste
+9 -10
View File
@@ -22,7 +22,7 @@ static const CLLocationDegrees kMaxRadarSpan = 0.045; // weiter rausgezoomt ->
- (void)viewDidLoad {
[super viewDidLoad];
self.navigationItem.title = @"Live-Karte";
self.navigationItem.title = @"Karte";
self.vehicles = [NSMutableDictionary dictionary];
self.stops = [NSMutableDictionary dictionary];
@@ -39,21 +39,20 @@ static const CLLocationDegrees kMaxRadarSpan = 0.045; // weiter rausgezoomt ->
// schwebende Status-Pille mit Schatten
self.statusLabel = [[UILabel alloc] init];
self.statusLabel.font = [BVGTheme font:13 weight:UIFontWeightSemibold];
self.statusLabel.font = [BVGTheme font:12 weight:UIFontWeightMedium];
self.statusLabel.textAlignment = NSTextAlignmentCenter;
self.statusLabel.textColor = [BVGTheme textPrimary];
self.statusLabel.backgroundColor = [UIColor whiteColor];
self.statusLabel.layer.cornerRadius = 17;
self.statusLabel.layer.cornerRadius = 14;
self.statusLabel.layer.masksToBounds = NO;
self.statusLabel.layer.shadowColor = [UIColor blackColor].CGColor;
self.statusLabel.layer.shadowOpacity = 0.18;
self.statusLabel.layer.shadowOpacity = 0.12;
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.leftBarButtonItem = [BVGAPI barButtonWithIcon:@"locate" target:self action:@selector(centerOnUser)];
self.navigationItem.leftBarButtonItem.accessibilityLabel = @"Mein Standort";
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemRefresh
target:self action:@selector(reloadAll)];
}
@@ -62,7 +61,7 @@ static const CLLocationDegrees kMaxRadarSpan = 0.045; // weiter rausgezoomt ->
[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);
self.statusLabel.frame = CGRectMake(w / 2 - 110, bottom - 42, 220, 28);
}
- (void)viewDidAppear:(BOOL)animated {
@@ -273,12 +272,12 @@ static const CLLocationDegrees kMaxRadarSpan = 0.045; // weiter rausgezoomt ->
if (a.kind == BVGAnnotationKindVehicle) {
v.image = [BVGAPI badgeForLine:a.line];
v.layer.shadowColor = [UIColor blackColor].CGColor;
v.layer.shadowOpacity = 0.35;
v.layer.shadowOpacity = 0.25;
v.layer.shadowRadius = 2;
v.layer.shadowOffset = CGSizeMake(0, 1);
v.layer.zPosition = 10;
} else {
v.image = [MapViewController stopIcon];
v.image = [BVGAPI stopSignWithSize:18];
v.layer.zPosition = 0;
}
return v;
+6 -1
View File
@@ -1,8 +1,13 @@
#import "CardTableViewController.h"
@interface SearchViewController : CardTableViewController
// Gesetzt = Auswahlmodus (z.B. fuer den Routenplaner): Tippen liefert die Haltestelle zurueck
// Gesetzt = Auswahlmodus (z.B. fuer den Routenplaner): Tippen liefert den Ort zurueck.
// stop == nil bedeutet "Mein Standort" (nur wenn offerCurrentLocation gesetzt ist).
@property (nonatomic, copy) void (^pickHandler)(NSDictionary *stop);
// im Auswahlmodus auch Adressen anbieten (z.B. fuer "Zuhause")
@property (nonatomic, assign) BOOL allowAddresses;
// im Auswahlmodus "Mein Standort" sowie Zuhause/Arbeit als Schnellauswahl zeigen
@property (nonatomic, assign) BOOL offerCurrentLocation;
@property (nonatomic, assign) BOOL offerSavedPlaces;
@property (nonatomic, copy) NSString *pickTitle; // z.B. "Start" / "Ziel"
@end
+79 -31
View File
@@ -1,12 +1,14 @@
#import "SearchViewController.h"
#import "StopViewController.h"
#import "BVGAPI.h"
#import "BVGSettings.h"
@interface SearchViewController () <UISearchBarDelegate>
@property (nonatomic, strong) UISearchBar *searchBar;
@property (nonatomic, strong) NSArray *results;
@property (nonatomic, copy) NSString *lastQuery;
@property (nonatomic, assign) BOOL searching;
@property (nonatomic, assign) BOOL focusedOnce;
@end
@implementation SearchViewController
@@ -14,18 +16,23 @@
- (void)viewDidLoad {
[super viewDidLoad];
self.results = @[];
self.tableView.rowHeight = 58;
self.tableView.rowHeight = 52;
self.searchBar = [[UISearchBar alloc] initWithFrame:CGRectMake(0, 0, self.view.bounds.size.width, 44)];
self.searchBar.placeholder = @"Haltestelle, z. B. Alexanderplatz";
// Suchfeld: weisses, abgerundetes Feld auf hellgrauem Grund
self.searchBar = [[UISearchBar alloc] initWithFrame:CGRectMake(0, 0, self.view.bounds.size.width, 40)];
self.searchBar.placeholder = self.allowAddresses ? @"Haltestelle oder Adresse" : @"Haltestelle, z. B. Alexanderplatz";
self.searchBar.delegate = self;
self.searchBar.autocorrectionType = UITextAutocorrectionTypeNo;
self.searchBar.searchBarStyle = UISearchBarStyleMinimal;
self.searchBar.backgroundImage = [BVGTheme imageWithColor:[BVGTheme background]];
[self.searchBar setSearchFieldBackgroundImage:[BVGTheme roundedImageWithColor:[BVGTheme searchField] radius:9]
forState:UIControlStateNormal];
self.searchBar.searchTextPositionAdjustment = UIOffsetMake(6, 0);
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";
if (self.pickHandler) self.navigationItem.title = self.pickTitle ?: @"Ort wählen";
else self.navigationItem.title = @"Haltestelle suchen";
self.tableView.keyboardDismissMode = UIScrollViewKeyboardDismissModeOnDrag;
[[NSNotificationCenter defaultCenter] addObserver:self.tableView selector:@selector(reloadData)
@@ -39,7 +46,14 @@
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self.tableView reloadData]; // "Zuletzt" aktualisieren
if (self.pickHandler) [self.searchBar becomeFirstResponder];
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
if (!self.focusedOnce) { // nur beim ersten Oeffnen direkt lostippen
self.focusedOnce = YES;
[self.searchBar becomeFirstResponder];
}
}
- (BOOL)isSearching {
@@ -103,23 +117,23 @@
return list.count ? [list componentsJoinedByString:@" · "] : @"Haltestelle";
}
// runde Haltestellen-Marke links in der Liste
+ (UIImage *)iconWithText:(NSString *)text fav:(BOOL)fav {
// runde Marke links in der Liste (Adresse, Standort, Zuhause ...)
+ (UIImage *)iconWithText:(NSString *)text color:(UIColor *)bg {
static NSCache *cache;
static dispatch_once_t once;
dispatch_once(&once, ^{ cache = [[NSCache alloc] init]; });
NSString *key = [text stringByAppendingString:fav ? @"1" : @"0"];
NSString *key = [NSString stringWithFormat:@"%@|%@", text, bg];
UIImage *img = [cache objectForKey:key];
if (img) return img;
CGSize size = CGSizeMake(32, 32);
CGSize size = CGSizeMake(28, 28);
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 };
[bg setFill];
[[UIBezierPath bezierPathWithOvalInRect:CGRectMake(0, 0, 28, 28)] fill];
NSDictionary *attrs = @{ NSFontAttributeName: [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold],
NSForegroundColorAttributeName: [BVGTheme textPrimary] };
CGSize t = [text sizeWithAttributes:attrs];
[text drawAtPoint:CGPointMake((32 - t.width) / 2, (32 - t.height) / 2) withAttributes:attrs];
[text drawAtPoint:CGPointMake((28 - t.width) / 2, (28 - t.height) / 2) withAttributes:attrs];
img = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
[cache setObject:img forKey:key];
@@ -147,9 +161,23 @@
#pragma mark - Daten
// Zeilen: normale Orte, oder Schnellauswahl {kind: current|saved, title, place}
- (NSArray *)sections {
if ([self isSearching]) return @[ @{ @"title": self.results.count ? @"Treffer" : @"", @"rows": self.results } ];
NSMutableArray *s = [NSMutableArray array];
if (self.pickHandler && (self.offerCurrentLocation || self.offerSavedPlaces)) {
NSMutableArray *quick = [NSMutableArray array];
if (self.offerCurrentLocation) [quick addObject:@{ @"kind": @"current", @"title": @"Mein Standort" }];
if (self.offerSavedPlaces) {
for (NSArray *pl in @[@[@"home", @"Zuhause"], @[@"work", @"Arbeit"]]) {
NSDictionary *place = [BVGSettings placeForKey:pl[0]];
if (place) [quick addObject:@{ @"kind": @"saved", @"key": pl[0], @"title": pl[1], @"place": place }];
}
}
if (quick.count) [s addObject:@{ @"title": @"", @"rows": quick }];
}
NSArray *favs = [BVGAPI favorites];
NSArray *recents = [BVGAPI recentStops];
if (favs.count) [s addObject:@{ @"title": @"Favoriten", @"rows": favs }];
@@ -170,8 +198,8 @@
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."];
[self showEmptyIcon:nil title:@"Wohin soll's gehen?"
text:@"Tippe mindestens zwei Buchstaben. Mit ☆ gemerkte Haltestellen erscheinen hier und auf der Startseite."];
}
} else {
[self showEmptyIcon:nil title:nil text:nil];
@@ -191,31 +219,51 @@
- (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];
NSDictionary *row = [self stopAt:indexPath];
cell.textLabel.font = [BVGTheme font:15 weight:UIFontWeightSemibold];
cell.detailTextLabel.font = [BVGTheme font:12 weight:UIFontWeightRegular];
cell.accessoryType = self.pickHandler ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator;
NSString *kind = row[@"kind"];
if ([kind isEqualToString:@"current"]) {
cell.imageView.image = [SearchViewController iconWithText:@"◉" color:[BVGTheme accentSoft]];
cell.textLabel.text = row[@"title"];
cell.detailTextLabel.text = @"per GPS";
return cell;
}
if ([kind isEqualToString:@"saved"]) {
BOOL home = [row[@"key"] isEqualToString:@"home"];
cell.imageView.image = [SearchViewController iconWithText:(home ? @"⌂" : @"✱") color:[BVGTheme accentSoft]];
cell.textLabel.text = row[@"title"];
cell.detailTextLabel.text = row[@"place"][@"name"];
return cell;
}
BOOL isAddress = ![SearchViewController isStop:row];
if (isAddress) cell.imageView.image = [SearchViewController iconWithText:@"⌂" color:[BVGTheme background]];
else cell.imageView.image = [BVGAPI stopSignWithSize:28];
cell.textLabel.text = NN(row[@"name"]) ?: NN(row[@"address"]);
NSString *detail = isAddress ? @"Adresse" : [SearchViewController productsText:row];
if (!isAddress && [BVGAPI isFavorite:row[@"id"]]) detail = [@"★ " stringByAppendingString:detail];
cell.detailTextLabel.text = detail;
return cell;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tableView deselectRowAtIndexPath:indexPath animated:YES];
[self.searchBar resignFirstResponder];
NSDictionary *stop = [self stopAt:indexPath];
NSDictionary *row = [self stopAt:indexPath];
if (self.pickHandler) {
self.pickHandler([SearchViewController placeFromLocation:stop]);
NSString *kind = row[@"kind"];
if ([kind isEqualToString:@"current"]) self.pickHandler(nil);
else if ([kind isEqualToString:@"saved"]) self.pickHandler(row[@"place"]);
else self.pickHandler([SearchViewController placeFromLocation:row]);
[self.navigationController popViewControllerAnimated:YES];
return;
}
if (![SearchViewController isStop:stop]) return; // Adressen haben keine Abfahrten
StopViewController *vc = [[StopViewController alloc] initWithStopId:stop[@"id"] name:stop[@"name"]];
if (![SearchViewController isStop:row]) return; // Adressen haben keine Abfahrten
StopViewController *vc = [[StopViewController alloc] initWithStopId:row[@"id"] name:row[@"name"]];
[self.navigationController pushViewController:vc animated:YES];
}
+50 -11
View File
@@ -1,6 +1,8 @@
#import "SettingsViewController.h"
#import "BVGSettings.h"
#import "BVGAPI.h"
#import "AlarmsViewController.h"
#import "SearchViewController.h"
#import <CoreSpotlight/CoreSpotlight.h>
#import <objc/runtime.h>
@@ -14,10 +16,12 @@ static char kSwitchKey; // Zuordnung UISwitch -> Einstellungs-Schluessel
- (void)viewDidLoad {
[super viewDidLoad];
self.largeTitle = @"Einstellungen";
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone
target:self action:@selector(close)];
self.largeTitle = @"Mehr";
[[NSNotificationCenter defaultCenter] addObserver:self.tableView selector:@selector(reloadData)
name:BVGAlarmsChangedNotification object:nil];
self.sections = @[
@{ @"title": @"",
@"rows": @[ @{ @"title": @"Wecker", @"open": @"alarms" } ] },
@{ @"title": @"Abfahrten",
@"rows": @[
@{ @"title": @"Suchradius", @"key": @"set.radius",
@@ -32,7 +36,7 @@ static char kSwitchKey; // Zuordnung UISwitch -> Einstellungs-Schluessel
@{ @"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.",
@"footer": @"Werden als Schnellziele in „Verbindung“ angezeigt.",
@"rows": @[ @{ @"title": @"🏠 Zuhause", @"place": @"home" },
@{ @"title": @"💼 Arbeit", @"place": @"work" } ] },
@{ @"title": @"Ansagen",
@@ -48,8 +52,13 @@ static char kSwitchKey; // Zuordnung UISwitch -> Einstellungs-Schluessel
];
}
- (void)close {
[self dismissViewControllerAnimated:YES completion:nil];
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self.tableView];
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self.tableView reloadData]; // Wecker-Anzahl, Datenquelle
}
#pragma mark - Table
@@ -75,9 +84,15 @@ static char kSwitchKey; // Zuordnung UISwitch -> Einstellungs-Schluessel
CardCell *cell = [[CardCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:nil];
cell.textLabel.text = row[@"title"];
cell.selectionStyle = UITableViewCellSelectionStyleNone;
cell.detailTextLabel.font = [BVGTheme font:16 weight:UIFontWeightRegular];
cell.detailTextLabel.font = [BVGTheme font:15 weight:UIFontWeightRegular];
if (row[@"values"]) {
if (row[@"open"]) {
NSUInteger n = [BVGAPI userAlarms].count;
cell.textLabel.font = [BVGTheme font:16 weight:UIFontWeightRegular];
cell.detailTextLabel.text = n ? [NSString stringWithFormat:@"%lu aktiv", (unsigned long)n] : @"keine";
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
cell.selectionStyle = UITableViewCellSelectionStyleDefault;
} else 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)];
@@ -86,8 +101,10 @@ static char kSwitchKey; // Zuordnung UISwitch -> Einstellungs-Schluessel
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;
cell.detailTextLabel.text = place ? place[@"name"] : @"festlegen";
cell.detailTextLabel.textColor = place ? [BVGTheme textSecondary] : [BVGTheme link];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
cell.selectionStyle = UITableViewCellSelectionStyleDefault;
} else if (row[@"switch"]) {
UISwitch *sw = [[UISwitch alloc] init];
sw.on = [[NSUserDefaults standardUserDefaults] boolForKey:row[@"switch"]];
@@ -130,17 +147,39 @@ static char kSwitchKey; // Zuordnung UISwitch -> Einstellungs-Schluessel
- (void)switchChanged:(UISwitch *)sw {
NSString *key = objc_getAssociatedObject(sw, &kSwitchKey);
[BVGSettings setBool:sw.on forKey:key];
[BVGAPI syncWidget];
if ([key isEqualToString:@"set.speech"] && !sw.on) [BVGAPI stopSpeaking];
}
- (void)pickPlaceForKey:(NSString *)key {
__weak SettingsViewController *weakSelf = self;
SearchViewController *search = [[SearchViewController alloc] initWithStyle:UITableViewStylePlain];
search.allowAddresses = YES;
search.pickTitle = [key isEqualToString:@"home"] ? @"Zuhause festlegen" : @"Arbeit festlegen";
search.pickHandler = ^(NSDictionary *place) {
if (!place) return;
[BVGSettings setPlace:place forKey:key];
[weakSelf.tableView reloadData];
};
[self.navigationController pushViewController:search animated:YES];
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tableView deselectRowAtIndexPath:indexPath animated:YES];
NSDictionary *row = self.sections[indexPath.section][@"rows"][indexPath.row];
if (row[@"open"]) {
[self.navigationController pushViewController:[[AlarmsViewController alloc] initWithStyle:UITableViewStylePlain]
animated:YES];
return;
}
if (row[@"values"]) { [self chooseValueForRow:row]; return; }
if (row[@"place"]) {
if (![BVGSettings placeForKey:row[@"place"]]) return;
if (![BVGSettings placeForKey:row[@"place"]]) { [self pickPlaceForKey:row[@"place"]]; return; }
UIAlertController *a = [UIAlertController alertControllerWithTitle:row[@"title"] message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
[a addAction:[UIAlertAction actionWithTitle:@"Ändern …" style:UIAlertActionStyleDefault handler:^(UIAlertAction *x) {
[self pickPlaceForKey:row[@"place"]];
}]];
[a addAction:[UIAlertAction actionWithTitle:@"Ort entfernen" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *x) {
[BVGSettings setPlace:nil forKey:row[@"place"]];
[self.tableView reloadData];
+11 -12
View File
@@ -41,13 +41,13 @@
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)];
UIView *acc = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.view.bounds.size.width, 76)];
[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.frame = CGRectMake([BVGTheme cardInset] + 6, 44, acc.bounds.size.width - 2 * [BVGTheme cardInset] - 12, 28);
self.directionButton.autoresizingMask = UIViewAutoresizingFlexibleWidth;
self.directionButton.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft;
self.directionButton.titleLabel.font = [BVGTheme font:15 weight:UIFontWeightMedium];
self.directionButton.titleLabel.font = [BVGTheme font:14 weight:UIFontWeightMedium];
self.directionButton.titleLabel.lineBreakMode = NSLineBreakByTruncatingTail;
[self.directionButton addTarget:self action:@selector(chooseDirection) forControlEvents:UIControlEventTouchUpInside];
[acc addSubview:self.directionButton];
@@ -88,12 +88,11 @@
UIBarButtonItem *star = [[UIBarButtonItem alloc] initWithTitle:(fav ? @"★" : @"☆")
style:UIBarButtonItemStylePlain
target:self action:@selector(toggleStar)];
[star setTitleTextAttributes:@{ NSFontAttributeName: [UIFont systemFontOfSize:24] } forState:UIControlStateNormal];
[star setTitleTextAttributes:@{ NSFontAttributeName: [UIFont systemFontOfSize:20] } forState:UIControlStateNormal];
star.tintColor = fav ? [BVGTheme orange] : [BVGTheme textSecondary];
UIBarButtonItem *speaker = [[UIBarButtonItem alloc] initWithTitle:@"Ansage"
style:UIBarButtonItemStylePlain
target:self action:@selector(announce)];
star.tintColor = fav ? [BVGTheme textPrimary] : [BVGTheme headerSecondary];
UIBarButtonItem *speaker = [BVGAPI barButtonWithIcon:@"speaker" target:self action:@selector(announce)];
speaker.accessibilityLabel = @"Abfahrten ansagen";
self.navigationItem.rightBarButtonItems = @[star, speaker];
}
@@ -154,9 +153,9 @@
- (void)updateDirectionButton {
NSString *dir = [BVGSettings directionFilterForStop:self.stopId];
[self.directionButton setTitle:(dir ? [NSString stringWithFormat:@"Nur Richtung %@ ✕", dir] : @"Alle Richtungen ▾")
[self.directionButton setTitle:(dir ? [NSString stringWithFormat:@"→ nur %@ ✕", dir] : @"Alle Richtungen ▾")
forState:UIControlStateNormal];
[self.directionButton setTitleColor:(dir ? [BVGTheme orange] : [BVGTheme link]) forState:UIControlStateNormal];
[self.directionButton setTitleColor:(dir ? [BVGTheme warningText] : [BVGTheme link]) forState:UIControlStateNormal];
}
- (void)chooseDirection {
@@ -244,8 +243,8 @@
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.font = [BVGTheme font:14 weight:UIFontWeightMedium];
cell.textLabel.textColor = [BVGTheme warningText];
cell.textLabel.numberOfLines = 2;
cell.cardColor = [BVGTheme accentSoft];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
+3 -3
View File
@@ -57,7 +57,7 @@
[self.contentView addSubview:_noteLabel];
_timeLabel = [[UILabel alloc] init];
_timeLabel.textAlignment = NSTextAlignmentRight;
_timeLabel.font = [BVGTheme digits:15 weight:UIFontWeightMedium];
_timeLabel.font = [BVGTheme digits:14 weight:UIFontWeightMedium];
[self.contentView addSubview:_timeLabel];
}
return self;
@@ -121,7 +121,7 @@
[header addSubview:badge];
UILabel *dir = [[UILabel alloc] initWithFrame:CGRectMake(x, 40, w - 2 * x, 34)];
dir.font = [BVGTheme font:26 weight:UIFontWeightBold];
dir.font = [BVGTheme font:22 weight:UIFontWeightBold];
dir.textColor = [BVGTheme textPrimary];
dir.adjustsFontSizeToFitWidth = YES;
dir.minimumScaleFactor = 0.6;
@@ -226,7 +226,7 @@
[cell.timeline setNeedsDisplay];
cell.nameLabel.text = name;
cell.nameLabel.font = (current || mine) ? [BVGTheme font:16 weight:UIFontWeightSemibold] : [BVGTheme font:16 weight:UIFontWeightRegular];
cell.nameLabel.font = (current || mine) ? [BVGTheme font:15 weight:UIFontWeightSemibold] : [BVGTheme font:15 weight:UIFontWeightRegular];
cell.nameLabel.textColor = past ? [BVGTheme textTertiary] : [BVGTheme textPrimary];
if (cancelled) { cell.noteLabel.text = @"Halt entfällt"; cell.noteLabel.textColor = [BVGTheme red]; }
+168 -111
View File
@@ -1,15 +1,64 @@
#import "ViewController.h"
#import "TripViewController.h"
#import "StopViewController.h"
#import "SearchViewController.h"
#import "DepartureCell.h"
#import "BVGAPI.h"
#import "BVGSettings.h"
#import "SettingsViewController.h"
#import "DisruptionsViewController.h"
#import <CoreLocation/CoreLocation.h>
static NSString * const kCacheKey = @"nearbyCache";
#pragma mark - Kopfzeile einer Haltestellen-Karte
@interface StopHeaderCell : CardCell
@property (nonatomic, strong) UIImageView *signView;
@property (nonatomic, strong) UILabel *nameLabel;
@property (nonatomic, strong) UILabel *metaLabel;
@property (nonatomic, strong) UIButton *starButton;
@end
@implementation StopHeaderCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:UITableViewCellStyleDefault reuseIdentifier:reuseIdentifier];
if (self) {
_signView = [[UIImageView alloc] initWithImage:[BVGAPI stopSignWithSize:24]];
[self.contentView addSubview:_signView];
_nameLabel = [[UILabel alloc] init];
_nameLabel.font = [BVGTheme font:16 weight:UIFontWeightSemibold];
_nameLabel.textColor = [BVGTheme textPrimary];
_nameLabel.adjustsFontSizeToFitWidth = YES;
_nameLabel.minimumScaleFactor = 0.85;
[self.contentView addSubview:_nameLabel];
_metaLabel = [[UILabel alloc] init];
_metaLabel.font = [BVGTheme font:12 weight:UIFontWeightRegular];
_metaLabel.textColor = [BVGTheme textSecondary];
[self.contentView addSubview:_metaLabel];
_starButton = [UIButton buttonWithType:UIButtonTypeCustom];
_starButton.titleLabel.font = [UIFont systemFontOfSize:20];
[self.contentView addSubview:_starButton];
}
return self;
}
- (void)layoutSubviews {
[super layoutSubviews];
CGFloat w = self.contentView.bounds.size.width;
self.signView.frame = CGRectMake(12, 12, 24, 24);
self.nameLabel.frame = CGRectMake(46, 7, w - 46 - 46, 20);
self.metaLabel.frame = CGRectMake(46, 27, w - 46 - 46, 15);
self.starButton.frame = CGRectMake(w - 44, 2, 44, 44);
}
@end
#pragma mark - Abfahrten in der Naehe
@interface ViewController () <CLLocationManagerDelegate>
@property (nonatomic, strong) CLLocationManager *locationManager;
@property (nonatomic, strong) NSArray *stops; // [NSMutableDictionary: id, name, distance, fav, departures]
@@ -31,12 +80,15 @@ static NSString * const kCacheKey = @"nearbyCache";
self.largeTitle = @"Abfahrten";
self.largeSubtitle = @"In deiner Nähe";
self.stops = @[];
self.bannerCount = -1;
self.tableView.rowHeight = [DepartureCell height];
self.bannerCount = 0;
[self buildHeaderAccessory];
self.refreshControl = [[UIRefreshControl alloc] init];
[self.refreshControl addTarget:self action:@selector(refresh) forControlEvents:UIControlEventValueChanged];
UILongPressGestureRecognizer *press = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPress:)];
[self.tableView addGestureRecognizer:press];
self.locationManager = [[CLLocationManager alloc] init];
self.locationManager.delegate = self;
self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters;
@@ -47,14 +99,8 @@ static NSString * const kCacheKey = @"nearbyCache";
[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];
self.navigationItem.rightBarButtonItem = [BVGAPI barButtonWithIcon:@"speaker" target:self action:@selector(announceNearest)];
self.navigationItem.rightBarButtonItem.accessibilityLabel = @"Abfahrten ansagen";
// 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 …"];
@@ -92,17 +138,60 @@ static NSString * const kCacheKey = @"nearbyCache";
[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 - Kopfbereich: Suchfeld + Stoerungs-Hinweis
- (void)buildHeaderAccessory {
CGFloat w = self.view.bounds.size.width;
CGFloat inset = [BVGTheme cardInset];
CGFloat h = 36 + (self.bannerCount > 0 ? 42 : 0);
UIView *v = [[UIView alloc] initWithFrame:CGRectMake(0, 0, w, h)];
// weisses "Suchfeld" - oeffnet die Haltestellensuche
UIButton *search = [UIButton buttonWithType:UIButtonTypeCustom];
search.frame = CGRectMake(inset, 0, w - 2 * inset, 36);
search.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[search setBackgroundImage:[BVGTheme roundedImageWithColor:[BVGTheme searchField] radius:9] forState:UIControlStateNormal];
[search setBackgroundImage:[BVGTheme roundedImageWithColor:[BVGTheme cardPressed] radius:9] forState:UIControlStateHighlighted];
search.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft;
search.contentEdgeInsets = UIEdgeInsetsMake(0, 10, 0, 10);
search.titleEdgeInsets = UIEdgeInsetsMake(0, 6, 0, -6);
[search setImage:[BVGAPI smallIconNamed:@"search" size:15] forState:UIControlStateNormal];
search.tintColor = [BVGTheme textSecondary];
search.titleLabel.font = [BVGTheme font:15 weight:UIFontWeightRegular];
[search setTitle:@"Haltestelle suchen" forState:UIControlStateNormal];
[search setTitleColor:[BVGTheme textSecondary] forState:UIControlStateNormal];
search.accessibilityLabel = @"Haltestelle suchen";
[search addTarget:self action:@selector(openSearch) forControlEvents:UIControlEventTouchUpInside];
[v addSubview:search];
// schwarzer Hinweis-Balken bei Stoerungen an den angezeigten Haltestellen
if (self.bannerCount > 0) {
UIButton *b = [UIButton buttonWithType:UIButtonTypeCustom];
b.frame = CGRectMake(inset, 42, w - 2 * inset, 34);
b.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[b setBackgroundImage:[BVGTheme roundedImageWithColor:[BVGTheme accentSoft] radius:9] forState:UIControlStateNormal];
b.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft;
b.contentEdgeInsets = UIEdgeInsetsMake(0, 12, 0, 12);
b.titleLabel.font = [BVGTheme font:13 weight:UIFontWeightSemibold];
[b setTitleColor:[BVGTheme warningText] forState:UIControlStateNormal];
[b setTitle:[NSString stringWithFormat:@"⚠︎ %ld %@ an deinen Haltestellen ›", (long)self.bannerCount,
self.bannerCount == 1 ? @"Hinweis" : @"Hinweise"] forState:UIControlStateNormal];
[b addTarget:self action:@selector(openDisruptions) forControlEvents:UIControlEventTouchUpInside];
[v addSubview:b];
}
self.headerAccessory = v;
}
- (void)openSearch {
SearchViewController *search = [[SearchViewController alloc] initWithStyle:UITableViewStylePlain];
[self.navigationController pushViewController:search animated:YES];
}
#pragma mark - Cache & Offline-Hinweis
- (void)saveCache {
@@ -131,42 +220,24 @@ static NSString * const kCacheKey = @"nearbyCache";
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]];
self.largeSubtitle = [NSString stringWithFormat:@"Offline · Daten von %@ Uhr", [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]];
self.largeSubtitleColor = [BVGTheme warningText];
self.largeSubtitle = [NSString stringWithFormat:@"Stand %@ Uhr · 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]];
self.largeSubtitle = [NSString stringWithFormat:@"In deiner Nähe · live, %@ Uhr", [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;
[self buildHeaderAccessory];
}
- (void)openDisruptions {
@@ -197,10 +268,11 @@ static NSString * const kCacheKey = @"nearbyCache";
[self announceStop:best];
}
- (void)announceFromHeader:(UILongPressGestureRecognizer *)press {
// Haltestellen-Kopf gedrueckt halten -> Ansage fuer diese Haltestelle
- (void)longPress:(UILongPressGestureRecognizer *)press {
if (press.state != UIGestureRecognizerStateBegan) return;
NSInteger section = press.view.tag;
if (section < (NSInteger)self.stops.count) [self announceStop:self.stops[section]];
NSIndexPath *ip = [self.tableView indexPathForRowAtPoint:[press locationInView:self.tableView]];
if (ip && ip.row == 0 && ip.section < (NSInteger)self.stops.count) [self announceStop:self.stops[ip.section]];
}
- (BOOL)canBecomeFirstResponder {
@@ -236,12 +308,6 @@ static NSString * const kCacheKey = @"nearbyCache";
[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];
@@ -330,10 +396,10 @@ static NSString * const kCacheKey = @"nearbyCache";
[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]]];
text:[NSString stringWithFormat:@"Keine Haltestellen im Umkreis von %ld m. Den Radius kannst du unter „Mehr“ 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."];
text:@"Erlaube den Standortzugriff in den iOS-Einstellungen oder suche oben eine Haltestelle und merke sie dir mit ☆."];
}
[self finish];
return;
@@ -382,8 +448,6 @@ static NSString * const kCacheKey = @"nearbyCache";
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];
});
}
@@ -404,15 +468,27 @@ static NSString * const kCacheKey = @"nearbyCache";
return deps.count > max ? [deps subarrayWithRange:NSMakeRange(0, max)] : deps;
}
// Zeilen pro Karte: 0 = Haltestelle · 1..n = Abfahrten (oder Leer-Hinweis) · letzte = "Alle Abfahrten"
- (NSDictionary *)departureAt:(NSIndexPath *)ip {
NSArray *deps = [self departuresInSection:ip.section];
return ip.row < (NSInteger)deps.count ? deps[ip.row] : nil;
NSInteger i = ip.row - 1;
return (i >= 0 && i < (NSInteger)deps.count) ? deps[i] : nil;
}
- (BOOL)isHeaderRow:(NSIndexPath *)ip {
return ip.row == 0;
}
- (BOOL)isMoreRow:(NSIndexPath *)ip {
return ip.row == [self tableView:self.tableView numberOfRowsInSection:ip.section] - 1;
}
// 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));
}
#pragma mark - Table
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
@@ -420,69 +496,56 @@ static NSString * const kCacheKey = @"nearbyCache";
}
- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section {
return 62;
return section == 0 ? 4 : 12;
}
- (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;
return [[UIView alloc] initWithFrame:CGRectZero];
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return MAX([self departuresInSection:section].count, 1) + 1; // + "Alle Abfahrten"
return 1 + MAX([self departuresInSection:section].count, 1) + 1;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return [self isMoreRow:indexPath] ? 44 : [DepartureCell height];
if ([self isHeaderRow:indexPath]) return 48;
if ([self isMoreRow:indexPath]) return 40;
return [DepartureCell height];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *stop = self.stops[indexPath.section];
if ([self isHeaderRow:indexPath]) {
StopHeaderCell *cell = [tableView dequeueReusableCellWithIdentifier:@"stop"];
if (!cell) {
cell = [[StopHeaderCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"stop"];
[cell.starButton addTarget:self action:@selector(toggleFavorite:) forControlEvents:UIControlEventTouchUpInside];
}
BOOL fav = [stop[@"fav"] boolValue];
cell.nameLabel.text = [BVGAPI shortName:stop[@"name"]];
NSMutableArray *parts = [NSMutableArray array];
if (stop[@"distance"]) [parts addObject:[NSString stringWithFormat:@"%@ m · %ld min zu Fuß",
stop[@"distance"], (long)[self walkMinutesToStop:stop]]];
else if (fav) [parts addObject:@"Favorit"];
NSString *dirFilter = [BVGSettings directionFilterForStop:stop[@"id"]];
if (dirFilter) [parts addObject:[@"nur → " stringByAppendingString:dirFilter]];
cell.metaLabel.text = [parts componentsJoinedByString:@" · "];
cell.starButton.tag = indexPath.section;
[cell.starButton setTitle:(fav ? @"★" : @"☆") forState:UIControlStateNormal];
[cell.starButton setTitleColor:(fav ? [BVGTheme orange] : [BVGTheme textTertiary]) forState:UIControlStateNormal];
cell.starButton.accessibilityLabel = fav ? @"Favorit entfernen" : @"Als Favorit merken";
cell.accessibilityLabel = stop[@"name"];
cell.accessibilityHint = @"Doppeltippen für alle Abfahrten";
cell.accessibilityTraits = UIAccessibilityTraitHeader | UIAccessibilityTraitButton;
return cell;
}
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.text = @"Alle Abfahrten";
cell.textLabel.font = [BVGTheme font:14 weight:UIFontWeightRegular];
cell.textLabel.textColor = [BVGTheme link];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
return cell;
@@ -494,7 +557,6 @@ static NSString * const kCacheKey = @"nearbyCache";
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];
@@ -505,16 +567,11 @@ static NSString * const kCacheKey = @"nearbyCache";
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 (indexPath.section >= (NSInteger)self.stops.count) return;
if ([self isMoreRow:indexPath]) {
if ([self isHeaderRow:indexPath] || [self isMoreRow:indexPath]) {
[self openStop:self.stops[indexPath.section]];
return;
}
@@ -532,7 +589,7 @@ static NSString * const kCacheKey = @"nearbyCache";
#pragma mark - Wischen → Wecker
- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath {
if ([self isMoreRow:indexPath]) return NO;
if ([self isHeaderRow:indexPath] || [self isMoreRow:indexPath]) return NO;
NSDictionary *d = [self departureAt:indexPath];
return d && NN(d[@"tripId"]) && ![NN(d[@"cancelled"]) boolValue];
}
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>de</string>
<key>CFBundleDisplayName</key>
<string>BVG Abfahrten</string>
<key>CFBundleExecutable</key>
<string>BVGWidget</string>
<key>CFBundleIdentifier</key>
<string>com.serverlele.BVGNearby.widget</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>BVGWidget</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>MinimumOSVersion</key>
<string>9.0</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
</array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Ohne Favoriten zeigt das Widget die Abfahrten an den nächsten Haltestellen.</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widget-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>TodayViewController</string>
</dict>
</dict>
</plist>
+333
View File
@@ -0,0 +1,333 @@
// Widget fuer die iOS-9-Mitteilungszentrale ("Heute"):
// zeigt die naechsten Abfahrten der Favoriten - ohne Favoriten die naechsten Haltestellen per Standort.
// Wird von deploy.sh direkt mit clang gebaut (kein eigenes Xcode-Target noetig).
#import <UIKit/UIKit.h>
#import <NotificationCenter/NotificationCenter.h>
#import <CoreLocation/CoreLocation.h>
static NSString * const kAppGroup = @"group.com.serverlele.BVGNearby";
static NSString * const kOwnBase = @"https://api.serverlele.de/live_navi/v6";
static NSString * const kPublicBase = @"https://v6.bvg.transport.rest";
static NSString * const kCacheKey = @"widgetCache";
static const NSInteger kMaxStops = 2, kPerStop = 3;
static const CGFloat kRowH = 30, kHeadH = 26;
static inline id NN(id v) { return (v == [NSNull null]) ? nil : v; }
@interface TodayViewController : UIViewController <NCWidgetProviding, CLLocationManagerDelegate>
@property (nonatomic, strong) NSArray *stops; // [{id, name, departures:[...]}]
@property (nonatomic, strong) UILabel *messageLabel;
@property (nonatomic, strong) CLLocationManager *locationManager;
@property (nonatomic, copy) void (^pending)(NCUpdateResult);
@property (nonatomic, assign) CGFloat laidOutWidth;
@end
@implementation TodayViewController
#pragma mark - Hilfen
+ (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 *)p {
if ([p isEqualToString:@"suburban"]) return [UIColor colorWithRed:0.00 green:0.55 blue:0.24 alpha:1];
if ([p isEqualToString:@"subway"]) return [UIColor colorWithRed:0.07 green:0.36 blue:0.66 alpha:1];
if ([p isEqualToString:@"tram"]) return [UIColor colorWithRed:0.80 green:0.04 blue:0.13 alpha:1];
if ([p isEqualToString:@"bus"]) return [UIColor colorWithRed:0.58 green:0.15 blue:0.43 alpha:1];
if ([p isEqualToString:@"ferry"]) return [UIColor colorWithRed:0.00 green:0.50 blue:0.73 alpha:1];
return [UIColor colorWithRed:0.89 green:0.00 blue:0.10 alpha:1];
}
+ (UIImage *)badgeForLine:(NSDictionary *)line {
NSString *name = NN(line[@"name"]) ?: @"?";
NSString *product = NN(line[@"product"]) ?: @"";
NSDictionary *c = NN(line[@"color"]);
UIColor *bg = [c isKindOfClass:[NSDictionary class]] ? [self colorFromHex:NN(c[@"bg"])] : nil;
UIColor *fg = [c isKindOfClass:[NSDictionary class]] ? [self colorFromHex:NN(c[@"fg"])] : nil;
NSDictionary *attrs = @{ NSFontAttributeName: [UIFont systemFontOfSize:12 weight:UIFontWeightBold],
NSForegroundColorAttributeName: fg ?: [UIColor whiteColor] };
CGSize t = [name sizeWithAttributes:attrs];
CGSize size = CGSizeMake(MAX(34, ceil(t.width) + 10), 19);
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
CGFloat r = [product isEqualToString:@"suburban"] ? size.height / 2 : ([product isEqualToString:@"subway"] ? 3 : 4);
[(bg ?: [self colorForProduct:product]) setFill];
[[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, size.width, size.height) cornerRadius:r] fill];
[name drawAtPoint:CGPointMake((size.width - t.width) / 2, (size.height - t.height) / 2) withAttributes:attrs];
UIImage *img = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return img;
}
+ (NSString *)shortName:(NSString *)name {
if (![name isKindOfClass:[NSString class]]) return @"";
return [name stringByReplacingOccurrencesOfString:@"\\s*\\((Berlin|Bln)\\)" withString:@""
options:NSRegularExpressionSearch range:NSMakeRange(0, name.length)];
}
+ (NSDate *)dateFromISO:(id)value {
if (![value isKindOfClass:[NSString class]]) return nil;
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";
});
return [f dateFromString:value];
}
+ (NSString *)encode:(NSString *)s {
NSCharacterSet *allowed = [NSCharacterSet characterSetWithCharactersInString:
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"];
return [s stringByAddingPercentEncodingWithAllowedCharacters:allowed] ?: @"";
}
- (NSUserDefaults *)shared {
return [[NSUserDefaults alloc] initWithSuiteName:kAppGroup];
}
#pragma mark - Netzwerk (eigener Server, sonst transport.rest)
- (void)getJSON:(NSString *)path completion:(void (^)(id json))completion {
BOOL own = ![[self shared] objectForKey:@"ownServer"] || [[self shared] boolForKey:@"ownServer"];
NSArray *bases = own ? @[kOwnBase, kPublicBase] : @[kPublicBase];
[self getJSON:path bases:bases index:0 completion:completion];
}
- (void)getJSON:(NSString *)path bases:(NSArray *)bases index:(NSUInteger)i completion:(void (^)(id json))completion {
if (i >= bases.count) { completion(nil); return; }
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:[bases[i] stringByAppendingString:path]]];
req.timeoutInterval = 10;
[req setValue:@"BVGNearby-Widget/1.0 (iOS 9; private app)" forHTTPHeaderField:@"User-Agent"];
[[[NSURLSession sharedSession] dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) {
NSInteger code = [resp isKindOfClass:[NSHTTPURLResponse class]] ? ((NSHTTPURLResponse *)resp).statusCode : 0;
id json = (data && code == 200) ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
if (!json) { [self getJSON:path bases:bases index:i + 1 completion:completion]; return; }
completion(json);
}] resume];
}
#pragma mark - Lebenszyklus
- (void)viewDidLoad {
[super viewDidLoad];
self.messageLabel = [[UILabel alloc] init];
self.messageLabel.font = [UIFont systemFontOfSize:13];
self.messageLabel.textColor = [UIColor colorWithWhite:1 alpha:0.7];
self.messageLabel.numberOfLines = 0;
[self.view addSubview:self.messageLabel];
// letzte Daten sofort zeigen
NSData *cache = [[NSUserDefaults standardUserDefaults] objectForKey:kCacheKey];
NSArray *stops = [cache isKindOfClass:[NSData class]] ? [NSJSONSerialization JSONObjectWithData:cache options:0 error:nil] : nil;
if ([stops isKindOfClass:[NSArray class]] && stops.count) [self showStops:stops];
else [self showMessage:@"Lade Abfahrten …"];
[self.view addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapped:)]];
}
// Breite steht erst nach dem Layout fest -> dann einmal neu aufbauen
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
CGFloat w = self.view.bounds.size.width;
if (w > 0 && fabs(w - self.laidOutWidth) > 0.5) {
self.laidOutWidth = w;
if (self.stops.count) [self showStops:self.stops];
}
}
// iOS 9 rueckt Widgets links weit ein - etwas weniger, damit mehr Platz fuer die Richtung bleibt
- (UIEdgeInsets)widgetMarginInsetsForProposedMarginInsets:(UIEdgeInsets)defaultMarginInsets {
return UIEdgeInsetsMake(0, 16, 0, 0);
}
- (void)widgetPerformUpdateWithCompletionHandler:(void (^)(NCUpdateResult))completionHandler {
NSArray *favs = [[self shared] arrayForKey:@"favorites"];
if (favs.count) {
NSMutableArray *list = [NSMutableArray array];
for (NSDictionary *f in favs) {
if (list.count >= kMaxStops) break;
if (f[@"id"]) [list addObject:@{ @"id": f[@"id"], @"name": f[@"name"] ?: @"?" }];
}
[self loadDeparturesFor:list completion:completionHandler];
return;
}
// keine Favoriten (oder App Group nicht verfuegbar): naechste Haltestellen per Standort
self.pending = completionHandler;
if (!self.locationManager) {
self.locationManager = [[CLLocationManager alloc] init];
self.locationManager.delegate = self;
self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters;
}
CLAuthorizationStatus st = [CLLocationManager authorizationStatus];
if (st == kCLAuthorizationStatusDenied || st == kCLAuthorizationStatusRestricted) {
[self showMessage:@"Merke dir in der App Haltestellen mit ☆ – sie erscheinen dann hier."];
[self finish:NCUpdateResultNoData];
return;
}
if (st == kCLAuthorizationStatusNotDetermined) [self.locationManager requestWhenInUseAuthorization];
[self.locationManager requestLocation];
}
- (void)finish:(NCUpdateResult)result {
void (^h)(NCUpdateResult) = self.pending;
self.pending = nil;
if (h) h(result);
}
- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray<CLLocation *> *)locations {
if (!self.pending) return; // nur einmal pro Aktualisierung
CLLocationCoordinate2D c = locations.lastObject.coordinate;
NSString *path = [NSString stringWithFormat:@"/locations/nearby?latitude=%.6f&longitude=%.6f&results=%ld&distance=1000&poi=false&addresses=false",
c.latitude, c.longitude, (long)kMaxStops];
[self getJSON:path completion:^(id json) {
NSMutableArray *list = [NSMutableArray array];
if ([json isKindOfClass:[NSArray class]]) {
for (NSDictionary *s in json) {
if ([s isKindOfClass:[NSDictionary class]] && NN(s[@"id"])) [list addObject:@{ @"id": s[@"id"], @"name": NN(s[@"name"]) ?: @"?" }];
}
}
dispatch_async(dispatch_get_main_queue(), ^{
void (^h)(NCUpdateResult) = self.pending;
self.pending = nil;
if (!list.count) {
[self showMessage:@"Keine Haltestellen in der Nähe gefunden."];
if (h) h(NCUpdateResultFailed);
return;
}
[self loadDeparturesFor:list completion:h];
});
}];
}
- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error {
if (!self.pending) return;
[self showMessage:@"Standort nicht verfügbar. Merke dir in der App Haltestellen mit ☆."];
[self finish:NCUpdateResultFailed];
}
#pragma mark - Abfahrten
- (void)loadDeparturesFor:(NSArray *)stops completion:(void (^)(NCUpdateResult))completion {
NSMutableArray *result = [NSMutableArray array];
for (NSDictionary *s in stops) [result addObject:[s mutableCopy]];
dispatch_group_t group = dispatch_group_create();
__block BOOL anyOK = NO;
for (NSMutableDictionary *s in result) {
dispatch_group_enter(group);
NSString *path = [NSString stringWithFormat:@"/stops/%@/departures?duration=60&results=%ld", s[@"id"], (long)(kPerStop + 3)];
[self getJSON:path completion:^(id json) {
NSArray *deps = ([json isKindOfClass:[NSDictionary class]] && [json[@"departures"] isKindOfClass:[NSArray class]])
? json[@"departures"] : nil;
@synchronized (s) {
if (deps) { s[@"departures"] = deps; anyOK = YES; }
}
dispatch_group_leave(group);
}];
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
if (anyOK) {
NSData *data = [NSJSONSerialization dataWithJSONObject:result options:0 error:nil];
if (data) [[NSUserDefaults standardUserDefaults] setObject:data forKey:kCacheKey];
[self showStops:result];
} else if (!self.stops.count) {
[self showMessage:@"Keine Verbindung zum Server."];
}
if (completion) completion(anyOK ? NCUpdateResultNewData : NCUpdateResultFailed);
});
}
#pragma mark - Darstellung
- (void)showMessage:(NSString *)text {
for (UIView *v in self.view.subviews) if (v != self.messageLabel) [v removeFromSuperview];
self.stops = @[];
self.messageLabel.hidden = NO;
self.messageLabel.text = text;
CGFloat w = self.view.bounds.size.width ?: [UIScreen mainScreen].bounds.size.width - 16;
self.messageLabel.frame = CGRectMake(0, 8, w - 16, 36);
self.preferredContentSize = CGSizeMake(0, 52);
}
- (void)showStops:(NSArray *)stops {
for (UIView *v in self.view.subviews) if (v != self.messageLabel) [v removeFromSuperview];
self.messageLabel.hidden = YES;
self.stops = stops;
CGFloat w = self.view.bounds.size.width ?: [UIScreen mainScreen].bounds.size.width - 16;
CGFloat y = 4;
NSDate *now = [NSDate date];
for (NSUInteger si = 0; si < stops.count; si++) {
NSDictionary *s = stops[si];
UILabel *head = [[UILabel alloc] initWithFrame:CGRectMake(0, y, w - 16, kHeadH)];
head.font = [UIFont systemFontOfSize:12 weight:UIFontWeightSemibold];
head.textColor = [UIColor colorWithWhite:1 alpha:0.6];
head.text = [[TodayViewController shortName:s[@"name"]] uppercaseString];
head.tag = 1000 + si; // Antippen -> Haltestelle in der App
[self.view addSubview:head];
y += kHeadH;
NSInteger shown = 0;
for (NSDictionary *d in s[@"departures"] ?: @[]) {
if (shown >= kPerStop) break;
if (![d isKindOfClass:[NSDictionary class]] || [NN(d[@"cancelled"]) boolValue]) continue;
NSDate *when = [TodayViewController dateFromISO:NN(d[@"when"])] ?: [TodayViewController dateFromISO:NN(d[@"plannedWhen"])];
NSInteger mins = when ? (NSInteger)floor([when timeIntervalSinceDate:now] / 60.0) : -1;
if (when && mins < 0) continue;
NSInteger delay = [NN(d[@"delay"]) integerValue] / 60;
UIImageView *badge = [[UIImageView alloc] initWithImage:[TodayViewController badgeForLine:NN(d[@"line"])]];
badge.frame = CGRectMake(0, y + (kRowH - 19) / 2, badge.image.size.width, 19);
[self.view addSubview:badge];
UILabel *dir = [[UILabel alloc] initWithFrame:CGRectMake(48, y, w - 48 - 16 - 62, kRowH)];
dir.font = [UIFont systemFontOfSize:14];
dir.textColor = [UIColor whiteColor];
dir.text = [TodayViewController shortName:NN(d[@"direction"]) ?: @""];
[self.view addSubview:dir];
UILabel *min = [[UILabel alloc] initWithFrame:CGRectMake(w - 16 - 60, y, 60, kRowH)];
min.font = [UIFont monospacedDigitSystemFontOfSize:15 weight:UIFontWeightSemibold];
min.textAlignment = NSTextAlignmentRight;
min.textColor = delay > 0 ? [UIColor colorWithRed:1 green:0.45 blue:0.4 alpha:1] : [UIColor whiteColor];
min.text = !when ? @"–" : (mins <= 0 ? @"jetzt" : [NSString stringWithFormat:@"%ld min", (long)mins]);
[self.view addSubview:min];
y += kRowH;
shown++;
}
if (!shown) {
UILabel *none = [[UILabel alloc] initWithFrame:CGRectMake(0, y, w - 16, kRowH)];
none.font = [UIFont systemFontOfSize:13];
none.textColor = [UIColor colorWithWhite:1 alpha:0.6];
none.text = @"Keine Abfahrten in der nächsten Stunde";
[self.view addSubview:none];
y += kRowH;
}
y += 6;
}
self.preferredContentSize = CGSizeMake(0, y + 2);
}
- (void)tapped:(UITapGestureRecognizer *)tap {
// welche Haltestelle wurde angetippt? (Bereich ab ihrer Ueberschrift)
CGPoint p = [tap locationInView:self.view];
NSDictionary *hit = nil;
for (UIView *v in self.view.subviews) {
if (v.tag >= 1000 && v.frame.origin.y <= p.y && (NSUInteger)(v.tag - 1000) < self.stops.count) hit = self.stops[v.tag - 1000];
}
NSString *urlString = hit
? [NSString stringWithFormat:@"bvgnearby://stop?id=%@&name=%@",
[TodayViewController encode:[hit[@"id"] description]], [TodayViewController encode:hit[@"name"] ?: @""]]
: @"bvgnearby://";
[self.extensionContext openURL:[NSURL URLWithString:urlString] completionHandler:nil];
}
@end
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.serverlele.BVGNearby</string>
</array>
</dict>
</plist>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.serverlele.BVGNearby</string>
</array>
</dict>
</plist>
+37 -1
View File
@@ -1,16 +1,52 @@
#!/bin/bash
# Baut BVGNearby + Widget, signiert mit ldid (AppSync) und installiert aufs iPhone.
# ./deploy.sh normal
# NO_WIDGET=1 ./deploy.sh ohne Widget/App Group (falls das Widget Probleme macht)
set -e
cd "$(dirname "$0")"
python3 BVGNearby/check_methods.py BVGNearby
# 1) App bauen
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
PB=/usr/libexec/PlistBuddy
plutil -remove UIMainStoryboardFile "$APP/Info.plist" 2>/dev/null || true
ldid -S "$APP/BVGNearby"
# URL-Schema bvgnearby:// (Widget oeffnet damit Haltestellen in der App)
$PB -c "Delete :CFBundleURLTypes" "$APP/Info.plist" 2>/dev/null || true
$PB -c "Add :CFBundleURLTypes array" \
-c "Add :CFBundleURLTypes:0 dict" \
-c "Add :CFBundleURLTypes:0:CFBundleURLName string com.serverlele.BVGNearby" \
-c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" \
-c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string bvgnearby" "$APP/Info.plist"
# 2) Widget bauen (ohne Xcode-Target, direkt mit clang)
rm -rf "$APP/PlugIns"
if [ -z "$NO_WIDGET" ]; then
APPEX="$APP/PlugIns/BVGWidget.appex"
mkdir -p "$APPEX"
xcrun -sdk iphoneos clang -arch armv7 -arch arm64 -miphoneos-version-min=9.0 \
-fobjc-arc -fmodules -fapplication-extension -e _NSExtensionMain \
-framework Foundation -framework UIKit -framework NotificationCenter -framework CoreLocation \
Widget/TodayViewController.m -o "$APPEX/BVGWidget"
cp Widget/Info.plist "$APPEX/Info.plist"
# Versionsnummern muessen zur App passen
V=$($PB -c "Print CFBundleShortVersionString" "$APP/Info.plist")
B=$($PB -c "Print CFBundleVersion" "$APP/Info.plist")
$PB -c "Set :CFBundleShortVersionString $V" -c "Set :CFBundleVersion $B" "$APPEX/Info.plist"
plutil -convert binary1 "$APPEX/Info.plist"
ldid -SWidget/widget.entitlements "$APPEX/BVGWidget"
ldid -SWidget/app.entitlements "$APP/BVGNearby"
else
ldid -S "$APP/BVGNearby"
fi
# 3) Paket bauen und installieren
rm -rf build/Payload build/BVGNearby.ipa
mkdir build/Payload && cp -r "$APP" build/Payload/
(cd build && zip -qr BVGNearby.ipa Payload)