// 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