diff --git a/BVGNearby/AlarmsViewController.m b/BVGNearby/AlarmsViewController.m index 7685e58..fe5552e 100644 --- a/BVGNearby/AlarmsViewController.m +++ b/BVGNearby/AlarmsViewController.m @@ -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]; diff --git a/BVGNearby/AppDelegate.m b/BVGNearby/AppDelegate.m index bb152da..20d944f 100644 --- a/BVGNearby/AppDelegate.m +++ b/BVGNearby/AppDelegate.m @@ -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> * _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 *)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 { diff --git a/BVGNearby/BVGAPI.h b/BVGNearby/BVGAPI.h index ba65011..660e5bd 100644 --- a/BVGNearby/BVGAPI.h +++ b/BVGNearby/BVGAPI.h @@ -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; diff --git a/BVGNearby/BVGAPI.m b/BVGNearby/BVGAPI.m index 0361dd6..151852c 100644 --- a/BVGNearby/BVGAPI.m +++ b/BVGNearby/BVGAPI.m @@ -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 { diff --git a/BVGNearby/BVGTheme.h b/BVGNearby/BVGTheme.h index 503c07e..0955d22 100644 --- a/BVGNearby/BVGTheme.h +++ b/BVGNearby/BVGTheme.h @@ -1,31 +1,40 @@ #import -// 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 diff --git a/BVGNearby/BVGTheme.m b/BVGNearby/BVGTheme.m index bca85c3..2f9bb26 100644 --- a/BVGNearby/BVGTheme.m +++ b/BVGNearby/BVGTheme.m @@ -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 diff --git a/BVGNearby/CardCell.h b/BVGNearby/CardCell.h index 08aae73..5986653 100644 --- a/BVGNearby/CardCell.h +++ b/BVGNearby/CardCell.h @@ -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 diff --git a/BVGNearby/CardCell.m b/BVGNearby/CardCell.m index 51cee5a..0126cc7 100644 --- a/BVGNearby/CardCell.m +++ b/BVGNearby/CardCell.m @@ -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 diff --git a/BVGNearby/CardTableViewController.m b/BVGNearby/CardTableViewController.m index 9f99cc6..0c854a3 100644 --- a/BVGNearby/CardTableViewController.m +++ b/BVGNearby/CardTableViewController.m @@ -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; diff --git a/BVGNearby/ChipBar.m b/BVGNearby/ChipBar.m index 0c583f9..b6ea122 100644 --- a/BVGNearby/ChipBar.m +++ b/BVGNearby/ChipBar.m @@ -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]; diff --git a/BVGNearby/DepartureCell.m b/BVGNearby/DepartureCell.m index 7fcf475..c646547 100644 --- a/BVGNearby/DepartureCell.m +++ b/BVGNearby/DepartureCell.m @@ -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; } diff --git a/BVGNearby/DisruptionsViewController.m b/BVGNearby/DisruptionsViewController.m index eade8ad..9461083 100644 --- a/BVGNearby/DisruptionsViewController.m +++ b/BVGNearby/DisruptionsViewController.m @@ -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]; diff --git a/BVGNearby/GuidanceManager.h b/BVGNearby/GuidanceManager.h index 70b6e93..ba5044e 100644 --- a/BVGNearby/GuidanceManager.h +++ b/BVGNearby/GuidanceManager.h @@ -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. diff --git a/BVGNearby/GuidanceManager.m b/BVGNearby/GuidanceManager.m index c4edd3e..273af7b 100644 --- a/BVGNearby/GuidanceManager.m +++ b/BVGNearby/GuidanceManager.m @@ -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 *)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]; diff --git a/BVGNearby/GuidanceViewController.m b/BVGNearby/GuidanceViewController.m index 8e332c7..c9cdec8 100644 --- a/BVGNearby/GuidanceViewController.m +++ b/BVGNearby/GuidanceViewController.m @@ -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]; diff --git a/BVGNearby/JourneyDetailViewController.m b/BVGNearby/JourneyDetailViewController.m index e2dfcc8..d2a5135 100644 --- a/BVGNearby/JourneyDetailViewController.m +++ b/BVGNearby/JourneyDetailViewController.m @@ -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; diff --git a/BVGNearby/JourneyViewController.m b/BVGNearby/JourneyViewController.m index 7ffb888..4624043 100644 --- a/BVGNearby/JourneyViewController.m +++ b/BVGNearby/JourneyViewController.m @@ -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 () -@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; } } diff --git a/BVGNearby/LIESMICH.txt b/BVGNearby/LIESMICH.txt index e30b91c..311972a 100644 --- a/BVGNearby/LIESMICH.txt +++ b/BVGNearby/LIESMICH.txt @@ -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 diff --git a/BVGNearby/MapViewController.m b/BVGNearby/MapViewController.m index 362c038..145713d 100644 --- a/BVGNearby/MapViewController.m +++ b/BVGNearby/MapViewController.m @@ -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; diff --git a/BVGNearby/SearchViewController.h b/BVGNearby/SearchViewController.h index 651751a..630e408 100644 --- a/BVGNearby/SearchViewController.h +++ b/BVGNearby/SearchViewController.h @@ -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 diff --git a/BVGNearby/SearchViewController.m b/BVGNearby/SearchViewController.m index e1d5245..0bd3b82 100644 --- a/BVGNearby/SearchViewController.m +++ b/BVGNearby/SearchViewController.m @@ -1,12 +1,14 @@ #import "SearchViewController.h" #import "StopViewController.h" #import "BVGAPI.h" +#import "BVGSettings.h" @interface SearchViewController () @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]; } diff --git a/BVGNearby/SettingsViewController.m b/BVGNearby/SettingsViewController.m index dd7700d..e769159 100644 --- a/BVGNearby/SettingsViewController.m +++ b/BVGNearby/SettingsViewController.m @@ -1,6 +1,8 @@ #import "SettingsViewController.h" #import "BVGSettings.h" #import "BVGAPI.h" +#import "AlarmsViewController.h" +#import "SearchViewController.h" #import #import @@ -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]; diff --git a/BVGNearby/StopViewController.m b/BVGNearby/StopViewController.m index 5be6762..625d599 100644 --- a/BVGNearby/StopViewController.m +++ b/BVGNearby/StopViewController.m @@ -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; diff --git a/BVGNearby/TripViewController.m b/BVGNearby/TripViewController.m index f4aef60..5369d45 100644 --- a/BVGNearby/TripViewController.m +++ b/BVGNearby/TripViewController.m @@ -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]; } diff --git a/BVGNearby/ViewController.m b/BVGNearby/ViewController.m index db94a56..ff6f705 100644 --- a/BVGNearby/ViewController.m +++ b/BVGNearby/ViewController.m @@ -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 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 () @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]; } diff --git a/Widget/Info.plist b/Widget/Info.plist new file mode 100644 index 0000000..a6d376f --- /dev/null +++ b/Widget/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + de + CFBundleDisplayName + BVG Abfahrten + CFBundleExecutable + BVGWidget + CFBundleIdentifier + com.serverlele.BVGNearby.widget + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + BVGWidget + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + CFBundleSupportedPlatforms + + iPhoneOS + + MinimumOSVersion + 9.0 + UIDeviceFamily + + 1 + + NSLocationWhenInUseUsageDescription + Ohne Favoriten zeigt das Widget die Abfahrten an den nächsten Haltestellen. + NSExtension + + NSExtensionPointIdentifier + com.apple.widget-extension + NSExtensionPrincipalClass + TodayViewController + + + diff --git a/Widget/TodayViewController.m b/Widget/TodayViewController.m new file mode 100644 index 0000000..45cc177 --- /dev/null +++ b/Widget/TodayViewController.m @@ -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 +#import +#import + +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 +@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 *)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 diff --git a/Widget/app.entitlements b/Widget/app.entitlements new file mode 100644 index 0000000..f9d46b0 --- /dev/null +++ b/Widget/app.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.serverlele.BVGNearby + + + diff --git a/Widget/widget.entitlements b/Widget/widget.entitlements new file mode 100644 index 0000000..f9d46b0 --- /dev/null +++ b/Widget/widget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.serverlele.BVGNearby + + + diff --git a/deploy.sh b/deploy.sh index 1625884..7680582 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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)