diff --git a/packages/react-native-carplay/ios/RCTConvert+RNCarPlay.h b/packages/react-native-carplay/ios/RCTConvert+RNCarPlay.h new file mode 100644 index 00000000..aad3e329 --- /dev/null +++ b/packages/react-native-carplay/ios/RCTConvert+RNCarPlay.h @@ -0,0 +1,18 @@ +#import +#import +#import +#import + +@interface RCTConvert (RNCarPlay) + ++ (CPTripEstimateStyle)CPTripEstimateStyle:(id)json; ++ (CPPanDirection)CPPanDirection:(id)json; ++ (CPAssistantCellPosition)CPAssistantCellPosition:(id)json; ++ (CPAssistantCellVisibility)CPAssistantCellVisibility:(id)json; ++ (CPAssistantCellActionType)CPAssistantCellActionType:(id)json; ++ (CPMapButton*)CPMapButton:(id)json withHandler:(void (^)(CPMapButton * _Nonnull mapButton))handler; ++ (CPRouteChoice*)CPRouteChoice:(id)json; ++ (MKMapItem*)MKMapItem:(id)json; ++ (CPPointOfInterest*)CPPointOfInterest:(id)json; ++ (CPAlertActionStyle)CPAlertActionStyle:(id)json; +@end diff --git a/packages/react-native-carplay/ios/RCTConvert+RNCarPlay.m b/packages/react-native-carplay/ios/RCTConvert+RNCarPlay.m new file mode 100644 index 00000000..fecec95d --- /dev/null +++ b/packages/react-native-carplay/ios/RCTConvert+RNCarPlay.m @@ -0,0 +1,96 @@ +#import "RCTConvert+RNCarPlay.h" +#import +#import + +@implementation RCTConvert (RNCarPlay) + +RCT_ENUM_CONVERTER(CPTripEstimateStyle, (@{ + @"light": @(CPTripEstimateStyleLight), + @"dark": @(CPTripEstimateStyleDark) + }), + CPTripEstimateStyleDark, + integerValue) + +RCT_ENUM_CONVERTER(CPPanDirection, (@{ + @"up": @(CPPanDirectionUp), + @"right": @(CPPanDirectionRight), + @"bottom": @(CPPanDirectionDown), + @"left": @(CPPanDirectionLeft), + @"none": @(CPPanDirectionNone) + }), CPPanDirectionNone, integerValue) + +RCT_ENUM_CONVERTER(CPAssistantCellPosition, (@{ + @"top": @(CPAssistantCellPositionTop), + @"bottom": @(CPAssistantCellPositionBottom) + }), CPAssistantCellPositionTop, integerValue) + +RCT_ENUM_CONVERTER(CPAssistantCellVisibility, (@{ + @"off": @(CPAssistantCellVisibilityOff), + @"always": @(CPAssistantCellVisibilityAlways), + @"limited": @(CPAssistantCellVisibilityWhileLimitedUIActive) + }), CPAssistantCellVisibilityOff, integerValue) + +RCT_ENUM_CONVERTER(CPAssistantCellActionType, (@{ + @"playMedia": @(CPAssistantCellActionTypePlayMedia), + @"startCall": @(CPAssistantCellActionTypeStartCall) + }), CPAssistantCellActionTypeStartCall, integerValue) + + ++ (CPMapButton*)CPMapButton:(id)json withHandler:(void (^)(CPMapButton * _Nonnull mapButton))handler { + CPMapButton *mapButton = [[CPMapButton alloc] initWithHandler:handler]; + + if ([json objectForKey:@"image"]) { + [mapButton setImage:[RCTConvert UIImage:json[@"image"]]]; + } + + if ([json objectForKey:@"focusedImage"]) { + [mapButton setImage:[RCTConvert UIImage:json[@"focusedImage"]]]; + } + + if ([json objectForKey:@"disabled"]) { + [mapButton setEnabled:![RCTConvert BOOL:json[@"disabled"]]]; + } + + if ([json objectForKey:@"hidden"]) { + [mapButton setHidden:[RCTConvert BOOL:json[@"hidden"]]]; + } + + return mapButton; +} + ++ (MKMapItem*)MKMapItem:(id)json { + CLLocationCoordinate2D coordinate = CLLocationCoordinate2DMake([RCTConvert double:json[@"latitude"]], [RCTConvert double:json[@"longitude"]]); + NSString *name = [RCTConvert NSString:json[@"name"]]; + MKPlacemark *placemark = [[MKPlacemark alloc] initWithCoordinate:coordinate]; + MKMapItem *mapItem = [[MKMapItem alloc] initWithPlacemark:placemark]; + mapItem.name = name; + return mapItem; +} + ++ (CPRouteChoice*)CPRouteChoice:(id)json { + return [[CPRouteChoice alloc] initWithSummaryVariants:[RCTConvert NSStringArray:json[@"additionalInformationVariants"]] additionalInformationVariants:[RCTConvert NSStringArray:json[@"selectionSummaryVariants"]] selectionSummaryVariants:[RCTConvert NSStringArray:json[@"summaryVariants"]]]; +} + ++ (CPPointOfInterest*)CPPointOfInterest:(id)json { + MKMapItem *location = [RCTConvert MKMapItem:json[@"location"]]; + NSString *title = [RCTConvert NSString:json[@"title"]]; + NSString *subtitle = [RCTConvert NSString:json[@"subtitle"]]; + NSString *summary = [RCTConvert NSString:json[@"summary"]]; + NSString *detailTitle = [RCTConvert NSString:json[@"detailTitle"]]; + NSString *detailSubtitle = [RCTConvert NSString:json[@"detailSubtitle"]]; + NSString *detailSummary = [RCTConvert NSString:json[@"detailSummary"]]; + + CPPointOfInterest *poi = [[CPPointOfInterest alloc] initWithLocation:location title:title subtitle:subtitle summary:summary detailTitle:detailTitle detailSubtitle:detailSubtitle detailSummary:detailSummary pinImage:nil]; + return poi; +} + ++ (CPAlertActionStyle)CPAlertActionStyle:(NSString*) json { + if ([json isEqualToString:@"cancel"]) { + return CPAlertActionStyleCancel; + } else if ([json isEqualToString:@"destructive"]) { + return CPAlertActionStyleDestructive; + } + return CPAlertActionStyleDefault; +} + +@end diff --git a/packages/react-native-carplay/ios/RNCPStore.h b/packages/react-native-carplay/ios/RNCPStore.h new file mode 100644 index 00000000..488d3f99 --- /dev/null +++ b/packages/react-native-carplay/ios/RNCPStore.h @@ -0,0 +1,22 @@ +#import +#import + +@interface RNCPStore : NSObject { + CPInterfaceController *interfaceController; + CPWindow *window; +} + +@property (nonatomic, retain) CPInterfaceController *interfaceController; +@property (nonatomic, retain) CPWindow *window; + ++ (id)sharedManager; +- (CPTemplate*) findTemplateById: (NSString*)templateId; +- (NSString*) setTemplate:(NSString*)templateId template:(CPTemplate*)carPlayTemplate; +- (CPTrip*) findTripById: (NSString*)tripId; +- (NSString*) setTrip:(NSString*)tripId trip:(CPTrip*)trip; +- (CPNavigationSession*) findNavigationSessionById:(NSString*)navigationSessionId; +- (NSString*) setNavigationSession:(NSString*)navigationSessionId navigationSession:(CPNavigationSession*)navigationSession; +- (Boolean) isConnected; +- (void) setConnected:(Boolean) isConnected; + +@end diff --git a/packages/react-native-carplay/ios/RNCPStore.m b/packages/react-native-carplay/ios/RNCPStore.m new file mode 100644 index 00000000..a4322e25 --- /dev/null +++ b/packages/react-native-carplay/ios/RNCPStore.m @@ -0,0 +1,68 @@ +#import "RNCPStore.h" + +@implementation RNCPStore { + NSMutableDictionary* _templatesStore; + NSMutableDictionary* _navigationSessionsStore; + NSMutableDictionary* _tripsStore; + Boolean _connected; +} + +@synthesize window; +@synthesize interfaceController; + +-(instancetype)init { + if (self = [super init]) { + _templatesStore = [[NSMutableDictionary alloc] init]; + _navigationSessionsStore = [[NSMutableDictionary alloc] init]; + _tripsStore = [[NSMutableDictionary alloc] init]; + _connected = false; + } + + return self; +} + ++ (RNCPStore*) sharedManager { + static RNCPStore *shared = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + shared = [[self alloc] init]; + }); + return shared; +} + +- (void) setConnected:(Boolean) isConnected { + _connected = isConnected; +} + +- (Boolean) isConnected { + return _connected; +} + +- (CPTemplate*) findTemplateById:(NSString*)templateId { + return [_templatesStore objectForKey:templateId]; +} + +- (NSString*) setTemplate:(NSString*)templateId template:(CPTemplate*)carPlayTemplate { + [_templatesStore setObject:carPlayTemplate forKey:templateId]; + return templateId; +} + +- (CPTrip*) findTripById:(NSString*)tripId { + return [_tripsStore objectForKey:tripId]; +} + +- (NSString*) setTrip:(NSString*)tripId trip:(CPTrip*)trip { + [_tripsStore setObject:trip forKey:tripId]; + return tripId; +} + +- (CPNavigationSession*) findNavigationSessionById:(NSString*)navigationSessionId { + return [_navigationSessionsStore objectForKey:navigationSessionId]; +} + +- (NSString*) setNavigationSession:(NSString*)navigationSessionId navigationSession:(CPNavigationSession*)navigationSession { + [_navigationSessionsStore setObject:navigationSession forKey:navigationSessionId]; + return navigationSessionId; +} + +@end diff --git a/packages/react-native-carplay/ios/RNCarPlay.h b/packages/react-native-carplay/ios/RNCarPlay.h new file mode 100644 index 00000000..09115c8e --- /dev/null +++ b/packages/react-native-carplay/ios/RNCarPlay.h @@ -0,0 +1,29 @@ +#import +#import +#import +#import +#import "RCTConvert+RNCarPlay.h" +#import "RNCPStore.h" + +typedef void(^SearchResultUpdateBlock)(NSArray * _Nonnull); +typedef void(^SelectedResultBlock)(void); + +@interface RNCarPlay : RCTEventEmitter { + CPInterfaceController *interfaceController; + CPWindow *window; + SearchResultUpdateBlock searchResultBlock; + SelectedResultBlock selectedResultBlock; + BOOL isNowPlayingActive; +} + +@property (nonatomic, retain) CPInterfaceController *interfaceController; +@property (nonatomic, retain) CPWindow *window; +@property (nonatomic, copy) SearchResultUpdateBlock searchResultBlock; +@property (nonatomic, copy) SelectedResultBlock selectedResultBlock; +@property (nonatomic) BOOL isNowPlayingActive; + ++ (void) connectWithInterfaceController:(CPInterfaceController*)interfaceController window:(CPWindow*)window; ++ (void) disconnect; +- (NSArray*) parseSections:(NSArray*)sections; + +@end diff --git a/packages/react-native-carplay/ios/RNCarPlay.m b/packages/react-native-carplay/ios/RNCarPlay.m new file mode 100644 index 00000000..672a032a --- /dev/null +++ b/packages/react-native-carplay/ios/RNCarPlay.m @@ -0,0 +1,1320 @@ +#import "RNCarPlay.h" +#import +#import + +@implementation RNCarPlay + +@synthesize interfaceController; +@synthesize window; +@synthesize searchResultBlock; +@synthesize selectedResultBlock; +@synthesize isNowPlayingActive; + ++ (NSDictionary *) getConnectedWindowInformation: (CPWindow *) window { + return @{ + @"width": @(window.bounds.size.width), + @"height": @(window.bounds.size.height), + @"scale": @(window.screen.scale) + }; +} + ++ (void) connectWithInterfaceController:(CPInterfaceController*)interfaceController window:(CPWindow*)window { + RNCPStore * store = [RNCPStore sharedManager]; + store.interfaceController = interfaceController; + store.window = window; + [store setConnected:true]; + + RNCarPlay *cp = [RNCarPlay allocWithZone:nil]; + if (cp.bridge) { + [cp sendEventWithName:@"didConnect" body:[self getConnectedWindowInformation:window]]; + } +} + ++ (void) disconnect { + RNCarPlay *cp = [RNCarPlay allocWithZone:nil]; + RNCPStore *store = [RNCPStore sharedManager]; + [store setConnected:false]; + [[store.window subviews] makeObjectsPerformSelector:@selector(removeFromSuperview)]; + + if (cp.bridge) { + [cp sendEventWithName:@"didDisconnect" body:@{}]; + } +} + +RCT_EXPORT_MODULE(); + ++ (id)allocWithZone:(NSZone *)zone { + static RNCarPlay *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [super allocWithZone:zone]; + }); + return sharedInstance; +} + +- (NSArray *)supportedEvents +{ + return @[ + @"didConnect", + @"didDisconnect", + // interface + @"barButtonPressed", + @"backButtonPressed", + @"didAppear", + @"didDisappear", + @"willAppear", + @"willDisappear", + @"buttonPressed", + // grid + @"gridButtonPressed", + // information + @"actionButtonPressed", + // list + @"didSelectListItem", + // search + @"updatedSearchText", + @"searchButtonPressed", + @"selectedResult", + // tabbar + @"didSelectTemplate", + // nowplaying + @"upNextButtonPressed", + @"albumArtistButtonPressed", + // poi + @"didSelectPointOfInterest", + // map + @"mapButtonPressed", + @"didUpdatePanGestureWithTranslation", + @"didEndPanGestureWithVelocity", + @"panBeganWithDirection", + @"panEndedWithDirection", + @"panWithDirection", + @"didBeginPanGesture", + @"didDismissPanningInterface", + @"willDismissPanningInterface", + @"didShowPanningInterface", + @"didDismissNavigationAlert", + @"willDismissNavigationAlert", + @"didShowNavigationAlert", + @"willShowNavigationAlert", + @"didCancelNavigation", + @"alertActionPressed", + @"selectedPreviewForTrip", + @"startedTrip", + ]; +} + +- (dispatch_queue_t)methodQueue +{ + return dispatch_get_main_queue(); +} + + +-(UIImage *)imageWithTint:(UIImage *)image andTintColor:(UIColor *)tintColor { + UIImage *imageNew = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + UIImageView *imageView = [[UIImageView alloc] initWithImage:imageNew]; + imageView.tintColor = tintColor; + UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 0.0); + [imageView.layer renderInContext:UIGraphicsGetCurrentContext()]; + UIImage *tintedImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return tintedImage; +} + +-(UIImage*)dynamicImageWithNormalImage:(UIImage*)normalImage darkImage:(UIImage*)darkImage { + RNCPStore *store = [RNCPStore sharedManager]; + if (normalImage == nil || darkImage == nil) { + return normalImage ? : darkImage; + } + if (@available(iOS 13.0, *)) { + UIImageAsset* imageAsset = darkImage.imageAsset; + + // darkImage + UITraitCollection* darkImageTraitCollection = [UITraitCollection traitCollectionWithTraitsFromCollections: + @[[UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleDark], + [UITraitCollection traitCollectionWithDisplayScale:normalImage.scale]]]; + [imageAsset registerImage:normalImage withTraitCollection:darkImageTraitCollection]; + + return [imageAsset imageWithTraitCollection: store.interfaceController.carTraitCollection]; + } + else { + return normalImage; + } +} + +- (UIImage *)imageWithSize:(UIImage *)image convertToSize:(CGSize)size { + UIGraphicsBeginImageContext(size); + [image drawInRect:CGRectMake(0, 0, size.width, size.height)]; + UIImage *destImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return destImage; +} + +RCT_EXPORT_METHOD(checkForConnection) { + RNCPStore *store = [RNCPStore sharedManager]; + if ([store isConnected]) { + [self sendEventWithName:@"didConnect" body:[RNCarPlay getConnectedWindowInformation: store.window]]; + } +} + +RCT_EXPORT_METHOD(createTemplate:(NSString *)templateId config:(NSDictionary*)config) { + // Get the shared instance of the RNCPStore class + RNCPStore *store = [RNCPStore sharedManager]; + + // Extract values from the 'config' dictionary + NSString *type = [RCTConvert NSString:config[@"type"]]; + NSString *title = [RCTConvert NSString:config[@"title"]]; + NSArray *leadingNavigationBarButtons = [self parseBarButtons:[RCTConvert NSArray:config[@"leadingNavigationBarButtons"]] templateId:templateId]; + NSArray *trailingNavigationBarButtons = [self parseBarButtons:[RCTConvert NSArray:config[@"trailingNavigationBarButtons"]] templateId:templateId]; + + // Create a new CPTemplate object + CPTemplate *carPlayTemplate = [[CPTemplate alloc] init]; + + if ([type isEqualToString:@"search"]) { + CPSearchTemplate *searchTemplate = [[CPSearchTemplate alloc] init]; + searchTemplate.delegate = self; + carPlayTemplate = searchTemplate; + } + else if ([type isEqualToString:@"grid"]) { + NSArray *buttons = [self parseGridButtons:[RCTConvert NSArray:config[@"buttons"]] templateId:templateId]; + CPGridTemplate *gridTemplate = [[CPGridTemplate alloc] initWithTitle:title gridButtons:buttons]; + [gridTemplate setLeadingNavigationBarButtons:leadingNavigationBarButtons]; + [gridTemplate setTrailingNavigationBarButtons:trailingNavigationBarButtons]; + carPlayTemplate = gridTemplate; + } + else if ([type isEqualToString:@"list"]) { + NSArray *sections = [self parseSections:[RCTConvert NSArray:config[@"sections"]]]; + CPListTemplate *listTemplate; + if (@available(iOS 15.0, *)) { + if ([config objectForKey:@"assistant"]) { + NSDictionary *assistant = [config objectForKey:@"assistant"]; + BOOL _enabled = [assistant valueForKey:@"enabled"]; + if (_enabled) { + CPAssistantCellConfiguration *conf = [[CPAssistantCellConfiguration alloc] initWithPosition:[RCTConvert CPAssistantCellPosition:[config valueForKey:@"position"]] visibility:[RCTConvert CPAssistantCellVisibility:[config valueForKey:@"visibility"]] assistantAction:[RCTConvert CPAssistantCellActionType:[config valueForKey:@"visibility"]]]; + listTemplate = [[CPListTemplate alloc] initWithTitle:title sections:sections assistantCellConfiguration:conf]; + } + } + } + if (listTemplate == nil) { + // Fallback on earlier versions + listTemplate = [[CPListTemplate alloc] initWithTitle:title sections:sections]; + } + [listTemplate setLeadingNavigationBarButtons:leadingNavigationBarButtons]; + [listTemplate setTrailingNavigationBarButtons:trailingNavigationBarButtons]; + if (![RCTConvert BOOL:config[@"backButtonHidden"]]) { + CPBarButton *backButton = [[CPBarButton alloc] initWithTitle:@" Back" handler:^(CPBarButton * _Nonnull barButton) { + [self sendEventWithName:@"backButtonPressed" body:@{@"templateId":templateId}]; + [self popTemplate:false]; + }]; + [listTemplate setBackButton:backButton]; + } + if (config[@"emptyViewTitleVariants"]) { + listTemplate.emptyViewTitleVariants = [RCTConvert NSArray:config[@"emptyViewTitleVariants"]]; + } + if (config[@"emptyViewSubtitleVariants"]) { + listTemplate.emptyViewSubtitleVariants = [RCTConvert NSArray:config[@"emptyViewSubtitleVariants"]]; + } + listTemplate.delegate = self; + carPlayTemplate = listTemplate; + } + else if ([type isEqualToString:@"map"]) { + CPMapTemplate *mapTemplate = [[CPMapTemplate alloc] init]; + + [self applyConfigForMapTemplate:mapTemplate templateId:templateId config:config]; + [mapTemplate setLeadingNavigationBarButtons:leadingNavigationBarButtons]; + [mapTemplate setTrailingNavigationBarButtons:trailingNavigationBarButtons]; + [mapTemplate setUserInfo:@{ @"templateId": templateId }]; + mapTemplate.mapDelegate = self; + + carPlayTemplate = mapTemplate; + } else if ([type isEqualToString:@"voicecontrol"]) { + CPVoiceControlTemplate *voiceTemplate = [[CPVoiceControlTemplate alloc] initWithVoiceControlStates: [self parseVoiceControlStates:config[@"voiceControlStates"]]]; + carPlayTemplate = voiceTemplate; + } else if ([type isEqualToString:@"nowplaying"]) { + CPNowPlayingTemplate *nowPlayingTemplate = [CPNowPlayingTemplate sharedTemplate]; + [nowPlayingTemplate setAlbumArtistButtonEnabled:[RCTConvert BOOL:config[@"albumArtistButtonEnabled"]]]; + [nowPlayingTemplate setUpNextTitle:[RCTConvert NSString:config[@"upNextButtonTitle"]]]; + [nowPlayingTemplate setUpNextButtonEnabled:[RCTConvert BOOL:config[@"upNextButtonEnabled"]]]; + NSMutableArray *buttons = [NSMutableArray new]; + NSArray *_buttons = [RCTConvert NSDictionaryArray:config[@"buttons"]]; + for (NSDictionary *_button in _buttons) { + NSString *buttonType = [RCTConvert NSString:_button[@"type"]]; + NSDictionary *body = @{@"templateId":templateId, @"id": _button[@"id"] }; + if ([buttonType isEqualToString:@"shuffle"]) { + [buttons addObject:[[CPNowPlayingShuffleButton alloc] initWithHandler:^(__kindof CPNowPlayingButton * _Nonnull) { + [self sendEventWithName:@"buttonPressed" body:body]; + }]]; + } else if ([buttonType isEqualToString:@"add-to-library"]) { + [buttons addObject:[[CPNowPlayingAddToLibraryButton alloc] initWithHandler:^(__kindof CPNowPlayingButton * _Nonnull) { + [self sendEventWithName:@"buttonPressed" body:body]; + }]]; + } else if ([buttonType isEqualToString:@"more"]) { + [buttons addObject:[[CPNowPlayingMoreButton alloc] initWithHandler:^(__kindof CPNowPlayingButton * _Nonnull) { + [self sendEventWithName:@"buttonPressed" body:body]; + }]]; + } else if ([buttonType isEqualToString:@"playback"]) { + [buttons addObject:[[CPNowPlayingPlaybackRateButton alloc] initWithHandler:^(__kindof CPNowPlayingButton * _Nonnull) { + [self sendEventWithName:@"buttonPressed" body:body]; + }]]; + } else if ([buttonType isEqualToString:@"repeat"]) { + [buttons addObject:[[CPNowPlayingRepeatButton alloc] initWithHandler:^(__kindof CPNowPlayingButton * _Nonnull) { + [self sendEventWithName:@"buttonPressed" body:body]; + }]]; + } else if ([buttonType isEqualToString:@"image"]) { + [buttons addObject:[[CPNowPlayingImageButton alloc] initWithImage:[RCTConvert UIImage:[_button objectForKey:@"image"]] handler:^(__kindof CPNowPlayingButton * _Nonnull) { + [self sendEventWithName:@"buttonPressed" body:body]; + }]]; + } + } + [nowPlayingTemplate updateNowPlayingButtons:buttons]; + carPlayTemplate = nowPlayingTemplate; + } else if ([type isEqualToString:@"tabbar"]) { + CPTabBarTemplate *tabBarTemplate = [[CPTabBarTemplate alloc] initWithTemplates:[self parseTemplatesFrom:config]]; + tabBarTemplate.delegate = self; + carPlayTemplate = tabBarTemplate; + } else if ([type isEqualToString:@"contact"]) { + NSString *nm = [RCTConvert NSString:config[@"name"]]; + UIImage *img = [RCTConvert UIImage:config[@"image"]]; + CPContact *contact = [[CPContact alloc] initWithName:nm image:img]; + [contact setSubtitle:config[@"subtitle"]]; + [contact setActions:[self parseButtons:config[@"actions"] templateId:templateId]]; + CPContactTemplate *contactTemplate = [[CPContactTemplate alloc] initWithContact:contact]; + carPlayTemplate = contactTemplate; + } else if ([type isEqualToString:@"actionsheet"]) { + NSString *title = [RCTConvert NSString:config[@"title"]]; + NSString *message = [RCTConvert NSString:config[@"message"]]; + NSMutableArray *actions = [NSMutableArray new]; + NSArray *_actions = [RCTConvert NSDictionaryArray:config[@"actions"]]; + for (NSDictionary *_action in _actions) { + CPAlertAction *action = [[CPAlertAction alloc] initWithTitle:[RCTConvert NSString:_action[@"title"]] style:[RCTConvert CPAlertActionStyle:_action[@"style"]] handler:^(CPAlertAction *a) { + [self sendEventWithName:@"actionButtonPressed" body:@{@"templateId":templateId, @"id": _action[@"id"] }]; + }]; + [actions addObject:action]; + } + CPActionSheetTemplate *actionSheetTemplate = [[CPActionSheetTemplate alloc] initWithTitle:title message:message actions:actions]; + carPlayTemplate = actionSheetTemplate; + } else if ([type isEqualToString:@"alert"]) { + NSMutableArray *actions = [NSMutableArray new]; + NSArray *_actions = [RCTConvert NSDictionaryArray:config[@"actions"]]; + for (NSDictionary *_action in _actions) { + CPAlertAction *action = [[CPAlertAction alloc] initWithTitle:[RCTConvert NSString:_action[@"title"]] style:[RCTConvert CPAlertActionStyle:_action[@"style"]] handler:^(CPAlertAction *a) { + [self sendEventWithName:@"actionButtonPressed" body:@{@"templateId":templateId, @"id": _action[@"id"] }]; + }]; + [actions addObject:action]; + } + NSArray* titleVariants = [RCTConvert NSArray:config[@"titleVariants"]]; + CPAlertTemplate *alertTemplate = [[CPAlertTemplate alloc] initWithTitleVariants:titleVariants actions:actions]; + carPlayTemplate = alertTemplate; + } else if ([type isEqualToString:@"poi"]) { + NSString *title = [RCTConvert NSString:config[@"title"]]; + NSMutableArray<__kindof CPPointOfInterest *> * items = [NSMutableArray new]; + NSUInteger selectedIndex = 0; + + NSArray *_items = [RCTConvert NSDictionaryArray:config[@"items"]]; + for (NSDictionary *_item in _items) { + CPPointOfInterest *poi = [RCTConvert CPPointOfInterest:_item]; + [poi setUserInfo:_item]; + [items addObject:poi]; + } + + CPPointOfInterestTemplate *poiTemplate = [[CPPointOfInterestTemplate alloc] initWithTitle:title pointsOfInterest:items selectedIndex:selectedIndex]; + poiTemplate.pointOfInterestDelegate = self; + carPlayTemplate = poiTemplate; + } else if ([type isEqualToString:@"information"]) { + NSString *title = [RCTConvert NSString:config[@"title"]]; + CPInformationTemplateLayout layout = [RCTConvert BOOL:config[@"leading"]] ? CPInformationTemplateLayoutLeading : CPInformationTemplateLayoutTwoColumn; + NSMutableArray<__kindof CPInformationItem *> * items = [NSMutableArray new]; + NSMutableArray<__kindof CPTextButton *> * actions = [NSMutableArray new]; + + NSArray *_items = [RCTConvert NSDictionaryArray:config[@"items"]]; + for (NSDictionary *_item in _items) { + [items addObject:[[CPInformationItem alloc] initWithTitle:_item[@"title"] detail:_item[@"detail"]]]; + } + + NSArray *_actions = [RCTConvert NSDictionaryArray:config[@"actions"]]; + for (NSDictionary *_action in _actions) { + CPTextButton *action = [[CPTextButton alloc] initWithTitle:_action[@"title"] textStyle:CPTextButtonStyleNormal handler:^(__kindof CPTextButton * _Nonnull contactButton) { + [self sendEventWithName:@"actionButtonPressed" body:@{@"templateId":templateId, @"id": _action[@"id"] }]; + }]; + [actions addObject:action]; + } + + CPInformationTemplate *informationTemplate = [[CPInformationTemplate alloc] initWithTitle:title layout:layout items:items actions:actions]; + carPlayTemplate = informationTemplate; + } + + if (config[@"tabSystemItem"]) { + carPlayTemplate.tabSystemItem = [RCTConvert NSInteger:config[@"tabSystemItem"]]; + } + if (config[@"tabSystemImageName"]) { + carPlayTemplate.tabImage = [UIImage systemImageNamed:[RCTConvert NSString:config[@"tabSystemImageName"]]]; + } + if (config[@"tabImage"]) { + carPlayTemplate.tabImage = [RCTConvert UIImage:config[@"tabImage"]]; + } + if (config[@"tabTitle"]) { + carPlayTemplate.tabTitle = [RCTConvert NSString:config[@"tabTitle"]]; + } + + [carPlayTemplate setUserInfo:@{ @"templateId": templateId }]; + [store setTemplate:templateId template:carPlayTemplate]; +} + +RCT_EXPORT_METHOD(createTrip:(NSString*)tripId config:(NSDictionary*)config) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTrip *trip = [self parseTrip:config]; + NSMutableDictionary *userInfo = trip.userInfo; + if (!userInfo) { + userInfo = [[NSMutableDictionary alloc] init]; + trip.userInfo = userInfo; + } + + [userInfo setValue:tripId forKey:@"id"]; + [store setTrip:tripId trip:trip]; +} + +RCT_EXPORT_METHOD(updateTravelEstimatesForTrip:(NSString*)templateId tripId:(NSString*)tripId travelEstimates:(NSDictionary*)travelEstimates timeRemainingColor:(NSUInteger*)timeRemainingColor) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + CPMapTemplate *mapTemplate = (CPMapTemplate*) template; + CPTrip *trip = [[RNCPStore sharedManager] findTripById:tripId]; + if (trip) { + CPTravelEstimates *estimates = [self parseTravelEstimates:travelEstimates]; + [mapTemplate updateTravelEstimates:estimates forTrip:trip withTimeRemainingColor:(CPTimeRemainingColor) timeRemainingColor]; + } + } +} + +RCT_REMAP_METHOD(startNavigationSession, + templateId:(NSString *)templateId + tripId:(NSString *)tripId + startNavigationSessionWithResolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + CPMapTemplate *mapTemplate = (CPMapTemplate*) template; + CPTrip *trip = [[RNCPStore sharedManager] findTripById:tripId]; + if (trip) { + CPNavigationSession *navigationSession = [mapTemplate startNavigationSessionForTrip:trip]; + [store setNavigationSession:tripId navigationSession:navigationSession]; + resolve(@{ @"tripId": tripId, @"navigationSessionId": tripId }); + } + } else { + reject(@"template_not_found", @"Template not found in store", nil); + } +} + +RCT_EXPORT_METHOD(updateManeuversNavigationSession:(NSString*)navigationSessionId maneuvers:(NSArray*)maneuvers) { + CPNavigationSession* navigationSession = [[RNCPStore sharedManager] findNavigationSessionById:navigationSessionId]; + if (navigationSession) { + NSMutableArray* upcomingManeuvers = [NSMutableArray array]; + for (NSDictionary *maneuver in maneuvers) { + [upcomingManeuvers addObject:[self parseManeuver:maneuver]]; + } + [navigationSession setUpcomingManeuvers:upcomingManeuvers]; + } +} + +RCT_EXPORT_METHOD(updateTravelEstimatesNavigationSession:(NSString*)navigationSessionId maneuverIndex:(NSUInteger)maneuverIndex travelEstimates:(NSDictionary*)travelEstimates) { + CPNavigationSession* navigationSession = [[RNCPStore sharedManager] findNavigationSessionById:navigationSessionId]; + if (navigationSession) { + CPManeuver *maneuver = [[navigationSession upcomingManeuvers] objectAtIndex:maneuverIndex]; + if (maneuver) { + [navigationSession updateTravelEstimates:[self parseTravelEstimates:travelEstimates] forManeuver:maneuver]; + } + } +} + +RCT_EXPORT_METHOD(pauseNavigationSession:(NSString*)navigationSessionId reason:(NSUInteger*)reason description:(NSString*)description) { + CPNavigationSession* navigationSession = [[RNCPStore sharedManager] findNavigationSessionById:navigationSessionId]; + if (navigationSession) { + [navigationSession pauseTripForReason:(CPTripPauseReason) reason description:description]; + } else { + NSLog(@"Could not find session"); + } +} + +RCT_EXPORT_METHOD(cancelNavigationSession:(NSString*)navigationSessionId) { + CPNavigationSession* navigationSession = [[RNCPStore sharedManager] findNavigationSessionById:navigationSessionId]; + if (navigationSession) { + [navigationSession cancelTrip]; + } else { + NSLog(@"Could not cancel. No session found."); + } +} + +RCT_EXPORT_METHOD(finishNavigationSession:(NSString*)navigationSessionId) { + CPNavigationSession* navigationSession = [[RNCPStore sharedManager] findNavigationSessionById:navigationSessionId]; + if (navigationSession) { + [navigationSession finishTrip]; + } +} + +RCT_EXPORT_METHOD(setRootTemplate:(NSString *)templateId animated:(BOOL)animated) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + + store.interfaceController.delegate = self; + + if (template) { + [store.interfaceController setRootTemplate:template animated:animated completion:^(BOOL done, NSError * _Nullable err) { + NSLog(@"error %@", err); + // noop + }]; + } else { + NSLog(@"Failed to find template %@", template); + } +} + +RCT_EXPORT_METHOD(pushTemplate:(NSString *)templateId animated:(BOOL)animated) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + [store.interfaceController pushTemplate:template animated:animated completion:^(BOOL done, NSError * _Nullable err) { + NSLog(@"error %@", err); + // noop + }]; + } else { + NSLog(@"Failed to find template %@", template); + } +} + +RCT_EXPORT_METHOD(popToTemplate:(NSString *)templateId animated:(BOOL)animated) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + [store.interfaceController popToTemplate:template animated:animated completion:^(BOOL done, NSError * _Nullable err) { + NSLog(@"error %@", err); + // noop + }]; + } else { + NSLog(@"Failed to find template %@", template); + } +} + +RCT_EXPORT_METHOD(popToRootTemplate:(BOOL)animated) { + RNCPStore *store = [RNCPStore sharedManager]; + [store.interfaceController popToRootTemplateAnimated:animated completion:^(BOOL done, NSError * _Nullable err) { + NSLog(@"error %@", err); + // noop + }]; +} + +RCT_EXPORT_METHOD(popTemplate:(BOOL)animated) { + RNCPStore *store = [RNCPStore sharedManager]; + [store.interfaceController popTemplateAnimated:animated completion:^(BOOL done, NSError * _Nullable err) { + NSLog(@"error %@", err); + // noop + }]; +} + +RCT_EXPORT_METHOD(presentTemplate:(NSString *)templateId animated:(BOOL)animated) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + [store.interfaceController presentTemplate:template animated:animated completion:^(BOOL done, NSError * _Nullable err) { + NSLog(@"error %@", err); + // noop + }]; + } else { + NSLog(@"Failed to find template %@", template); + } +} + +RCT_EXPORT_METHOD(dismissTemplate:(BOOL)animated) { + RNCPStore *store = [RNCPStore sharedManager]; + [store.interfaceController dismissTemplateAnimated:animated]; +} + +RCT_EXPORT_METHOD(updateListTemplate:(NSString*)templateId config:(NSDictionary*)config) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template && [template isKindOfClass:[CPListTemplate class]]) { + CPListTemplate *listTemplate = (CPListTemplate *)template; + if (config[@"leadingNavigationBarButtons"]) { + NSArray *leadingNavigationBarButtons = [self parseBarButtons:[RCTConvert NSArray:config[@"leadingNavigationBarButtons"]] templateId:templateId]; + [listTemplate setLeadingNavigationBarButtons:leadingNavigationBarButtons]; + } + if (config[@"trailingNavigationBarButtons"]) { + NSArray *trailingNavigationBarButtons = [self parseBarButtons:[RCTConvert NSArray:config[@"trailingNavigationBarButtons"]] templateId:templateId]; + [listTemplate setTrailingNavigationBarButtons:trailingNavigationBarButtons]; + } + if (config[@"emptyViewTitleVariants"]) { + listTemplate.emptyViewTitleVariants = [RCTConvert NSArray:config[@"emptyViewTitleVariants"]]; + } + if (config[@"emptyViewSubtitleVariants"]) { + NSLog(@"%@", [RCTConvert NSArray:config[@"emptyViewSubtitleVariants"]]); + listTemplate.emptyViewSubtitleVariants = [RCTConvert NSArray:config[@"emptyViewSubtitleVariants"]]; + } + } +} + +RCT_EXPORT_METHOD(updateTabBarTemplates:(NSString *)templateId templates:(NSDictionary*)config) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + CPTabBarTemplate *tabBarTemplate = (CPTabBarTemplate*) template; + [tabBarTemplate updateTemplates:[self parseTemplatesFrom:config]]; + } else { + NSLog(@"Failed to find template %@", template); + } +} + + +RCT_EXPORT_METHOD(updateListTemplateSections:(NSString *)templateId sections:(NSArray*)sections) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + CPListTemplate *listTemplate = (CPListTemplate*) template; + [listTemplate updateSections:[self parseSections:sections]]; + } else { + NSLog(@"Failed to find template %@", template); + } +} + +RCT_EXPORT_METHOD(updateListTemplateItem:(NSString *)templateId config:(NSDictionary*)config) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + CPListTemplate *listTemplate = (CPListTemplate*) template; + NSInteger sectionIndex = [RCTConvert NSInteger:config[@"sectionIndex"]]; + if (sectionIndex >= listTemplate.sections.count) { + NSLog(@"Failed to update item at section %d, sections size is %d", index, listTemplate.sections.count); + return; + } + CPListSection *section = listTemplate.sections[sectionIndex]; + NSInteger index = [RCTConvert NSInteger:config[@"itemIndex"]]; + if (index >= section.items.count) { + NSLog(@"Failed to update item at index %d, section size is %d", index, section.items.count); + return; + } + CPListItem *item = (CPListItem *)section.items[index]; + if (config[@"imgUrl"]) { + [item setImage:[[UIImage alloc] initWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:[RCTConvert NSString:config[@"imgUrl"]]]]]]; + } + if (config[@"image"]) { + [item setImage:[RCTConvert UIImage:config[@"image"]]]; + } + if (config[@"text"]) { + [item setText:[RCTConvert NSString:config[@"text"]]]; + } + if (config[@"detailText"]) { + [item setDetailText:[RCTConvert NSString:config[@"detailText"]]]; + } + if (config[@"isPlaying"]) { + [item setPlaying:[RCTConvert BOOL:config[@"isPlaying"]]]; + } + } else { + NSLog(@"Failed to find template %@", template); + } +} + +RCT_EXPORT_METHOD(updateInformationTemplateItems:(NSString *)templateId items:(NSArray*)items) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + CPInformationTemplate *informationTemplate = (CPInformationTemplate*) template; + informationTemplate.items = [self parseInformationItems:items]; + } else { + NSLog(@"Failed to find template %@", template); + } +} + +RCT_EXPORT_METHOD(updateInformationTemplateActions:(NSString *)templateId items:(NSArray*)actions) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + CPInformationTemplate *informationTemplate = (CPInformationTemplate*) template; + informationTemplate.actions = [self parseInformationActions:actions templateId:templateId]; + } else { + NSLog(@"Failed to find template %@", template); + } +} + +RCT_EXPORT_METHOD(getMaximumListItemCount:(NSString *)templateId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + CPListTemplate *listTemplate = (CPListTemplate*) template; + resolve(@(CPListTemplate.maximumItemCount)); + } else { + NSLog(@"Failed to find template %@", template); + reject(@"template_not_found", @"Template not found in store", nil); + } +} + +RCT_EXPORT_METHOD(getMaximumListSectionCount:(NSString *)templateId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + RNCPStore *store = [RNCPStore sharedManager]; + CPTemplate *template = [store findTemplateById:templateId]; + if (template) { + CPListTemplate *listTemplate = (CPListTemplate*) template; + resolve(@(CPListTemplate.maximumSectionCount)); + } else { + NSLog(@"Failed to find template %@", template); + reject(@"template_not_found", @"Template not found in store", nil); + } +} + +RCT_EXPORT_METHOD(updateMapTemplateConfig:(NSString *)templateId config:(NSDictionary*)config) { + CPTemplate *template = [[RNCPStore sharedManager] findTemplateById:templateId]; + if (template) { + CPMapTemplate *mapTemplate = (CPMapTemplate*) template; + [self applyConfigForMapTemplate:mapTemplate templateId:templateId config:config]; + } else { + NSLog(@"Failed to find template %@", template); + } +} + +RCT_EXPORT_METHOD(showPanningInterface:(NSString *)templateId animated:(BOOL)animated) { + CPTemplate *template = [[RNCPStore sharedManager] findTemplateById:templateId]; + if (template) { + CPMapTemplate *mapTemplate = (CPMapTemplate*) template; + [mapTemplate showPanningInterfaceAnimated:animated]; + } else { + NSLog(@"Failed to find template %@", template); + } +} + +RCT_EXPORT_METHOD(dismissPanningInterface:(NSString *)templateId animated:(BOOL)animated) { + CPTemplate *template = [[RNCPStore sharedManager] findTemplateById:templateId]; + if (template) { + CPMapTemplate *mapTemplate = (CPMapTemplate*) template; + [mapTemplate dismissPanningInterfaceAnimated:animated]; + } else { + NSLog(@"Failed to find template %@", template); + } +} + +RCT_EXPORT_METHOD(enableNowPlaying:(BOOL)enable) { + if (enable && !isNowPlayingActive) { + [CPNowPlayingTemplate.sharedTemplate addObserver:self]; + } else if (!enable && isNowPlayingActive) { + [CPNowPlayingTemplate.sharedTemplate removeObserver:self]; + } +} + +RCT_EXPORT_METHOD(hideTripPreviews:(NSString*)templateId) { + CPTemplate *template = [[RNCPStore sharedManager] findTemplateById:templateId]; + if (template) { + CPMapTemplate *mapTemplate = (CPMapTemplate*) template; + [mapTemplate hideTripPreviews]; + } +} + +RCT_EXPORT_METHOD(showTripPreviews:(NSString*)templateId tripIds:(NSArray*)tripIds tripConfiguration:(NSDictionary*)tripConfiguration) { + CPTemplate *template = [[RNCPStore sharedManager] findTemplateById:templateId]; + NSMutableArray *trips = [[NSMutableArray alloc] init]; + + for (NSString *tripId in tripIds) { + CPTrip *trip = [[RNCPStore sharedManager] findTripById:tripId]; + if (trip) { + [trips addObject:trip]; + } + } + + if (template) { + CPMapTemplate *mapTemplate = (CPMapTemplate*) template; + [mapTemplate showTripPreviews:trips textConfiguration:[self parseTripPreviewTextConfiguration:tripConfiguration]]; + } +} + +RCT_EXPORT_METHOD(showRouteChoicesPreviewForTrip:(NSString*)templateId tripId:(NSString*)tripId tripConfiguration:(NSDictionary*)tripConfiguration) { + CPTemplate *template = [[RNCPStore sharedManager] findTemplateById:templateId]; + CPTrip *trip = [[RNCPStore sharedManager] findTripById:tripId]; + + if (template) { + CPMapTemplate *mapTemplate = (CPMapTemplate*) template; + [mapTemplate showRouteChoicesPreviewForTrip:trip textConfiguration:[self parseTripPreviewTextConfiguration:tripConfiguration]]; + } +} + +RCT_EXPORT_METHOD(presentNavigationAlert:(NSString*)templateId json:(NSDictionary*)json animated:(BOOL)animated) { + CPTemplate *template = [[RNCPStore sharedManager] findTemplateById:templateId]; + if (template) { + CPMapTemplate *mapTemplate = (CPMapTemplate*) template; + [mapTemplate presentNavigationAlert:[self parseNavigationAlert:json templateId:templateId] animated:animated]; + } +} + +RCT_EXPORT_METHOD(dismissNavigationAlert:(NSString*)templateId animated:(BOOL)animated) { + CPTemplate *template = [[RNCPStore sharedManager] findTemplateById:templateId]; + if (template) { + CPMapTemplate *mapTemplate = (CPMapTemplate*) template; + [mapTemplate dismissNavigationAlertAnimated:YES completion:^(BOOL completion) { + [self sendTemplateEventWithName:template name:@"didDismissNavigationAlert"]; + }]; + } +} + +RCT_EXPORT_METHOD(activateVoiceControlState:(NSString*)templateId identifier:(NSString*)identifier) { + CPTemplate *template = [[RNCPStore sharedManager] findTemplateById:templateId]; + if (template) { + CPVoiceControlTemplate *voiceTemplate = (CPVoiceControlTemplate*) template; + [voiceTemplate activateVoiceControlStateWithIdentifier:identifier]; + } +} + +RCT_EXPORT_METHOD(reactToUpdatedSearchText:(NSArray *)items) { + NSArray *sectionsItems = [self parseListItems:items startIndex:0]; + + if (self.searchResultBlock) { + self.searchResultBlock(sectionsItems); + self.searchResultBlock = nil; + } +} + +RCT_EXPORT_METHOD(reactToSelectedResult:(BOOL)status) { + if (self.selectedResultBlock) { + self.selectedResultBlock(); + self.selectedResultBlock = nil; + } +} + +RCT_EXPORT_METHOD(updateMapTemplateMapButtons:(NSString*) templateId mapButtons:(NSArray*) mapButtonConfig) { + CPTemplate *template = [[RNCPStore sharedManager] findTemplateById:templateId]; + if (template) { + CPMapTemplate *mapTemplate = (CPMapTemplate*) template; + NSArray *mapButtons = [RCTConvert NSArray:mapButtonConfig]; + NSMutableArray *result = [NSMutableArray array]; + for (NSDictionary *mapButton in mapButtons) { + NSString *_id = [mapButton objectForKey:@"id"]; + [result addObject:[RCTConvert CPMapButton:mapButton withHandler:^(CPMapButton * _Nonnull mapButton) { + [self sendTemplateEventWithName:mapTemplate name:@"mapButtonPressed" json:@{ @"id": _id }]; + }]]; + } + [mapTemplate setMapButtons:result]; + } +} + +# pragma parsers + +- (void) applyConfigForMapTemplate:(CPMapTemplate*)mapTemplate templateId:(NSString*)templateId config:(NSDictionary*)config { + RNCPStore *store = [RNCPStore sharedManager]; + + if ([config objectForKey:@"guidanceBackgroundColor"]) { + [mapTemplate setGuidanceBackgroundColor:[RCTConvert UIColor:config[@"guidanceBackgroundColor"]]]; + } + else { + [mapTemplate setGuidanceBackgroundColor:UIColor.systemGray5Color]; + } + + if ([config objectForKey:@"tripEstimateStyle"]) { + [mapTemplate setTripEstimateStyle:[RCTConvert CPTripEstimateStyle:config[@"tripEstimateStyle"]]]; + } + else { + [mapTemplate setTripEstimateStyle:CPTripEstimateStyleDark]; + } + + if ([config objectForKey:@"leadingNavigationBarButtons"]){ + NSArray *leadingNavigationBarButtons = [self parseBarButtons:[RCTConvert NSArray:config[@"leadingNavigationBarButtons"]] templateId:templateId]; + [mapTemplate setLeadingNavigationBarButtons:leadingNavigationBarButtons]; + } + + if ([config objectForKey:@"trailingNavigationBarButtons"]){ + NSArray *trailingNavigationBarButtons = [self parseBarButtons:[RCTConvert NSArray:config[@"trailingNavigationBarButtons"]] templateId:templateId]; + [mapTemplate setTrailingNavigationBarButtons:trailingNavigationBarButtons]; + } + + if ([config objectForKey:@"mapButtons"]) { + NSArray *mapButtons = [RCTConvert NSArray:config[@"mapButtons"]]; + NSMutableArray *result = [NSMutableArray array]; + for (NSDictionary *mapButton in mapButtons) { + NSString *_id = [mapButton objectForKey:@"id"]; + [result addObject:[RCTConvert CPMapButton:mapButton withHandler:^(CPMapButton * _Nonnull mapButton) { + [self sendTemplateEventWithName:mapTemplate name:@"mapButtonPressed" json:@{ @"id": _id }]; + }]]; + } + [mapTemplate setMapButtons:result]; + } + + if ([config objectForKey:@"automaticallyHidesNavigationBar"]) { + [mapTemplate setAutomaticallyHidesNavigationBar:[RCTConvert BOOL:config[@"automaticallyHidesNavigationBar"]]]; + } + + if ([config objectForKey:@"hidesButtonsWithNavigationBar"]) { + [mapTemplate setHidesButtonsWithNavigationBar:[RCTConvert BOOL:config[@"hidesButtonsWithNavigationBar"]]]; + } + + if ([config objectForKey:@"render"]) { + RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:self.bridge moduleName:templateId initialProperties:@{}]; + [rootView setFrame:store.window.frame]; + [[store.window subviews] makeObjectsPerformSelector:@selector(removeFromSuperview)]; + [store.window addSubview:rootView]; + } +} + +- (NSArray<__kindof CPTemplate*>*) parseTemplatesFrom:(NSDictionary*)config { + RNCPStore *store = [RNCPStore sharedManager]; + NSMutableArray<__kindof CPTemplate*> *templates = [NSMutableArray new]; + NSArray *tpls = [RCTConvert NSDictionaryArray:config[@"templates"]]; + for (NSDictionary *tpl in tpls) { + CPTemplate *templ = [store findTemplateById:tpl[@"id"]]; + // @todo UITabSystemItem + [templates addObject:templ]; + } + return templates; +} + +- (NSArray*) parseButtons:(NSArray*)buttons templateId:(NSString *)templateId { + NSMutableArray *result = [NSMutableArray array]; + for (NSDictionary *button in buttons) { + CPButton *_button; + NSString *_id = [button objectForKey:@"id"]; + NSString *type = [button objectForKey:@"type"]; + if ([type isEqualToString:@"call"]) { + _button = [[CPContactCallButton alloc] initWithHandler:^(__kindof CPButton * _Nonnull contactButton) { + [self sendEventWithName:@"buttonPressed" body:@{@"id": _id, @"templateId":templateId}]; + }]; + } else if ([type isEqualToString:@"message"]) { + _button = [[CPContactMessageButton alloc] initWithPhoneOrEmail:[button objectForKey:@"phoneOrEmail"]]; + } else if ([type isEqualToString:@"directions"]) { + _button = [[CPContactDirectionsButton alloc] initWithHandler:^(__kindof CPButton * _Nonnull contactButton) { + [self sendEventWithName:@"buttonPressed" body:@{@"id": _id, @"templateId":templateId}]; + }]; + } + + BOOL _disabled = [button objectForKey:@"disabled"]; + [_button setEnabled:!_disabled]; + + NSString *_title = [button objectForKey:@"title"]; + [_button setTitle:_title]; + + [result addObject:_button]; + } + return result; +} + +- (NSArray*) parseBarButtons:(NSArray*)barButtons templateId:(NSString *)templateId { + NSMutableArray *result = [NSMutableArray array]; + for (NSDictionary *barButton in barButtons) { + CPBarButtonType _type; + NSString *_id = [barButton objectForKey:@"id"]; + NSString *type = [barButton objectForKey:@"type"]; + if (type && [type isEqualToString:@"image"]) { + _type = CPBarButtonTypeImage; + } else { + _type = CPBarButtonTypeText; + } + CPBarButton *_barButton = [[CPBarButton alloc] initWithType:_type handler:^(CPBarButton * _Nonnull barButton) { + [self sendEventWithName:@"barButtonPressed" body:@{@"id": _id, @"templateId":templateId}]; + }]; + BOOL _disabled = [barButton objectForKey:@"disabled"]; + [_barButton setEnabled:!_disabled]; + + if (_type == CPBarButtonTypeText) { + NSString *_title = [barButton objectForKey:@"title"]; + [_barButton setTitle:_title]; + } else if (_type == CPBarButtonTypeImage) { + UIImage *_image = [RCTConvert UIImage:[barButton objectForKey:@"image"]]; + [_barButton setImage:_image]; + } + [result addObject:_barButton]; + } + return result; +} + +- (NSArray*)parseSections:(NSArray*)sections { + NSMutableArray *result = [NSMutableArray array]; + int index = 0; + for (NSDictionary *section in sections) { + NSArray *items = [section objectForKey:@"items"]; + NSString *_sectionIndexTitle = [section objectForKey:@"sectionIndexTitle"]; + NSString *_header = [section objectForKey:@"header"]; + NSArray *_items = [self parseListItems:items startIndex:index]; + CPListSection *_section = [[CPListSection alloc] initWithItems:_items header:_header sectionIndexTitle:_sectionIndexTitle]; + [result addObject:_section]; + int count = (int) [items count]; + index = index + count; + } + return result; +} + +- (NSArray*)parseListItems:(NSArray*)items startIndex:(int)startIndex { + NSMutableArray *_items = [NSMutableArray array]; + int index = startIndex; + for (NSDictionary *item in items) { + BOOL _showsDisclosureIndicator = [item objectForKey:@"showsDisclosureIndicator"]; + NSString *_detailText = [item objectForKey:@"detailText"]; + NSString *_text = [item objectForKey:@"text"]; + UIImage *_image = [RCTConvert UIImage:[item objectForKey:@"image"]]; + if (item[@"imgUrl"]) { + _image = [[UIImage alloc] initWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:[RCTConvert NSString:item[@"imgUrl"]]]]]; + } + CPListItem *_item = [[CPListItem alloc] initWithText:_text detailText:_detailText image:_image showsDisclosureIndicator:_showsDisclosureIndicator]; + if ([item objectForKey:@"isPlaying"]) { + [_item setPlaying:[RCTConvert BOOL:[item objectForKey:@"isPlaying"]]]; + } + [_item setUserInfo:@{ @"index": @(index) }]; + [_items addObject:_item]; + index = index + 1; + } + return _items; +} + +- (NSArray*)parseInformationItems:(NSArray*)items { + NSMutableArray *_items = [NSMutableArray array]; + for (NSDictionary *item in items) { + [_items addObject:[[CPInformationItem alloc] initWithTitle:item[@"title"] detail:item[@"detail"]]]; + } + + return _items; +} + +- (NSArray*)parseInformationActions:(NSArray*)actions templateId:(NSString *)templateId { + NSMutableArray *_actions = [NSMutableArray array]; + for (NSDictionary *action in actions) { + CPTextButton *_action = [[CPTextButton alloc] initWithTitle:action[@"title"] textStyle:CPTextButtonStyleNormal handler:^(__kindof CPTextButton * _Nonnull contactButton) { + [self sendEventWithName:@"actionButtonPressed" body:@{@"templateId":templateId, @"id": action[@"id"] }]; + }]; + [_actions addObject:_action]; + } + + return _actions; +} + +- (NSArray*)parseGridButtons:(NSArray*)buttons templateId:(NSString*)templateId { + NSMutableArray *result = [NSMutableArray array]; + int index = 0; + for (NSDictionary *button in buttons) { + NSString *_id = [button objectForKey:@"id"]; + NSArray *_titleVariants = [button objectForKey:@"titleVariants"]; + UIImage *_image = [RCTConvert UIImage:[button objectForKey:@"image"]]; + CPGridButton *_button = [[CPGridButton alloc] initWithTitleVariants:_titleVariants image:_image handler:^(CPGridButton * _Nonnull barButton) { + [self sendEventWithName:@"gridButtonPressed" body:@{@"id": _id, @"templateId":templateId, @"index": @(index) }]; + }]; + BOOL _disabled = [button objectForKey:@"disabled"]; + [_button setEnabled:!_disabled]; + [result addObject:_button]; + index = index + 1; + } + return result; +} + +- (CPTravelEstimates*)parseTravelEstimates: (NSDictionary*)json { + NSString *units = [RCTConvert NSString:json[@"distanceUnits"]]; + double value = [RCTConvert double:json[@"distanceRemaining"]]; + + NSUnit *unit = [NSUnitLength kilometers]; + if (units && [units isEqualToString: @"meters"]) { + unit = [NSUnitLength meters]; + } + else if (units && [units isEqualToString: @"miles"]) { + unit = [NSUnitLength miles]; + } + else if (units && [units isEqualToString: @"feet"]) { + unit = [NSUnitLength feet]; + } + else if (units && [units isEqualToString: @"yards"]) { + unit = [NSUnitLength yards]; + } + + NSMeasurement *distance = [[NSMeasurement alloc] initWithDoubleValue:value unit:unit]; + double time = [RCTConvert double:json[@"timeRemaining"]]; + + return [[CPTravelEstimates alloc] initWithDistanceRemaining:distance timeRemaining:time]; +} + +- (CPManeuver*)parseManeuver:(NSDictionary*)json { + CPManeuver* maneuver = [[CPManeuver alloc] init]; + + if ([json objectForKey:@"junctionImage"]) { + UIImage *junctionImage = [RCTConvert UIImage:json[@"junctionImage"]]; + [maneuver setJunctionImage:[self imageWithTint:junctionImage andTintColor:[UIColor whiteColor]]]; + } + + if ([json objectForKey:@"initialTravelEstimates"]) { + CPTravelEstimates* travelEstimates = [self parseTravelEstimates:json[@"initialTravelEstimates"]]; + [maneuver setInitialTravelEstimates:travelEstimates]; + } + + if ([json objectForKey:@"symbolImage"]) { + UIImage *symbolImage = [RCTConvert UIImage:json[@"symbolImage"]]; + + if ([json objectForKey:@"resizeSymbolImage"]) { + NSString *resizeType = [RCTConvert NSString:json[@"resizeSymbolImage"]]; + if ([resizeType isEqualToString: @"primary"]) { + symbolImage = [self imageWithSize:symbolImage convertToSize:CGSizeMake(100, 100)]; + } + if ([resizeType isEqualToString: @"secondary"]) { + symbolImage = [self imageWithSize:symbolImage convertToSize:CGSizeMake(50, 50)]; + } + } + + BOOL shouldTint = [RCTConvert BOOL:json[@"tintSymbolImage"]]; + if ([json objectForKey:@"tintSymbolImage"]) { + UIColor *tintColor = [RCTConvert UIColor:json[@"tintSymbolImage"]]; + UIImage *darkImage = symbolImage; + UIImage *lightImage = [self imageWithTint:symbolImage andTintColor:tintColor]; + symbolImage = [self dynamicImageWithNormalImage:lightImage darkImage:darkImage]; + } + + + [maneuver setSymbolImage:symbolImage]; + } + + if ([json objectForKey:@"instructionVariants"]) { + [maneuver setInstructionVariants:[RCTConvert NSStringArray:json[@"instructionVariants"]]]; + } + + return maneuver; +} + +- (CPTripPreviewTextConfiguration*)parseTripPreviewTextConfiguration:(NSDictionary*)json { + return [[CPTripPreviewTextConfiguration alloc] initWithStartButtonTitle:[RCTConvert NSString:json[@"startButtonTitle"]] additionalRoutesButtonTitle:[RCTConvert NSString:json[@"additionalRoutesButtonTitle"]] overviewButtonTitle:[RCTConvert NSString:json[@"overviewButtonTitle"]]]; +} + +- (CPTrip*)parseTrip:(NSDictionary*)config { + if ([config objectForKey:@"config"]) { + config = [config objectForKey:@"config"]; + } + MKMapItem *origin = [RCTConvert MKMapItem:config[@"origin"]]; + MKMapItem *destination = [RCTConvert MKMapItem:config[@"destination"]]; + NSMutableArray *routeChoices = [NSMutableArray array]; + if ([config objectForKey:@"routeChoices"]) { + NSInteger index = 0; + for (NSDictionary *routeChoice in [RCTConvert NSArray:config[@"routeChoices"]]) { + CPRouteChoice *cpRouteChoice = [RCTConvert CPRouteChoice:routeChoice]; + NSMutableDictionary *userInfo = cpRouteChoice.userInfo; + if (!userInfo) { + userInfo = [[NSMutableDictionary alloc] init]; + cpRouteChoice.userInfo = userInfo; + } + [userInfo setValue:[NSNumber numberWithInteger:index] forKey:@"index"]; + [routeChoices addObject:cpRouteChoice]; + index++; + } + } + return [[CPTrip alloc] initWithOrigin:origin destination:destination routeChoices:routeChoices]; +} + +- (CPNavigationAlert*)parseNavigationAlert:(NSDictionary*)json templateId:(NSString*)templateId { + CPImageSet *imageSet; + if ([json objectForKey:@"lightImage"] && [json objectForKey:@"darkImage"]) { + imageSet = [[CPImageSet alloc] initWithLightContentImage:[RCTConvert UIImage:json[@"lightImage"]] darkContentImage:[RCTConvert UIImage:json[@"darkImage"]]]; + } + CPAlertAction *secondaryAction = [json objectForKey:@"secondaryAction"] ? [self parseAlertAction:json[@"secondaryAction"] body:@{ @"templateId": templateId, @"secondary": @(YES) }] : nil; + + return [[CPNavigationAlert alloc] initWithTitleVariants:[RCTConvert NSStringArray:json[@"titleVariants"]] subtitleVariants:[RCTConvert NSStringArray:json[@"subtitleVariants"]] imageSet:imageSet primaryAction:[self parseAlertAction:json[@"primaryAction"] body:@{ @"templateId": templateId, @"primary": @(YES) }] secondaryAction:secondaryAction duration:[RCTConvert double:json[@"duration"]]]; +} + +- (CPAlertAction*)parseAlertAction:(NSDictionary*)json body:(NSDictionary*)body { + return [[CPAlertAction alloc] initWithTitle:[RCTConvert NSString:json[@"title"]] style:(CPAlertActionStyle) [RCTConvert NSUInteger:json[@"style"]] handler:^(CPAlertAction * _Nonnull action) { + [self sendEventWithName:@"alertActionPressed" body:body]; + }]; +} + +- (NSArray*)parseVoiceControlStates:(NSArray*)items { + NSMutableArray* res = [NSMutableArray array]; + for (NSDictionary *item in items) { + [res addObject:[self parseVoiceControlState:item]]; + } + return res; +} + +- (CPVoiceControlState*)parseVoiceControlState:(NSDictionary*)json { + return [[CPVoiceControlState alloc] initWithIdentifier:[RCTConvert NSString:json[@"identifier"]] titleVariants:[RCTConvert NSStringArray:json[@"titleVariants"]] image:[RCTConvert UIImage:json[@"image"]] repeats:[RCTConvert BOOL:json[@"repeats"]]]; +} + +- (NSString*)panDirectionToString:(CPPanDirection)panDirection { + switch (panDirection) { + case CPPanDirectionUp: return @"up"; + case CPPanDirectionRight: return @"right"; + case CPPanDirectionDown: return @"down"; + case CPPanDirectionLeft: return @"left"; + case CPPanDirectionNone: return @"none"; + } +} + +- (NSDictionary*)navigationAlertToJson:(CPNavigationAlert*)navigationAlert dismissalContext:(CPNavigationAlertDismissalContext)dismissalContext { + NSString *dismissalCtx = @"none"; + if (dismissalContext) { + switch (dismissalContext) { + case CPNavigationAlertDismissalContextTimeout: + dismissalCtx = @"timeout"; + break; + case CPNavigationAlertDismissalContextSystemDismissed: + dismissalCtx = @"system"; + break; + case CPNavigationAlertDismissalContextUserDismissed: + dismissalCtx = @"user"; + break; + } + } + + return @{ + @"todo": @(YES), + @"reason": dismissalCtx + }; +} +- (NSDictionary*)navigationAlertToJson:(CPNavigationAlert*)navigationAlert { + return @{ @"todo": @(YES) }; + // NSMutableDictionary *res = [[NSMutableDictionary alloc] init]; + // return @{ + // }; +} + +- (void)sendTemplateEventWithName:(CPTemplate *)template name:(NSString*)name { + [self sendTemplateEventWithName:template name:name json:@{}]; +} + +- (void)sendTemplateEventWithName:(CPTemplate *)template name:(NSString*)name json:(NSDictionary*)json { + NSMutableDictionary *body = [[NSMutableDictionary alloc] initWithDictionary:json]; + NSDictionary *userInfo = [template userInfo]; + [body setObject:[userInfo objectForKey:@"templateId"] forKey:@"templateId"]; + [self sendEventWithName:name body:body]; +} + + +# pragma MapTemplate + +- (void)mapTemplate:(CPMapTemplate *)mapTemplate selectedPreviewForTrip:(CPTrip *)trip usingRouteChoice:(CPRouteChoice *)routeChoice { + NSDictionary *userInfo = trip.userInfo; + NSString *tripId = [userInfo valueForKey:@"id"]; + + NSDictionary *routeUserInfo = routeChoice.userInfo; + NSString *routeIndex = [routeUserInfo valueForKey:@"index"]; + [self sendTemplateEventWithName:mapTemplate name:@"selectedPreviewForTrip" json:@{ @"tripId": tripId, @"routeIndex": routeIndex}]; +} + +- (void)mapTemplate:(CPMapTemplate *)mapTemplate startedTrip:(CPTrip *)trip usingRouteChoice:(CPRouteChoice *)routeChoice { + NSDictionary *userInfo = trip.userInfo; + NSString *tripId = [userInfo valueForKey:@"id"]; + + NSDictionary *routeUserInfo = routeChoice.userInfo; + NSString *routeIndex = [routeUserInfo valueForKey:@"index"]; + + [self sendTemplateEventWithName:mapTemplate name:@"startedTrip" json:@{ @"tripId": tripId, @"routeIndex": routeIndex}]; +} + +- (void)mapTemplateDidCancelNavigation:(CPMapTemplate *)mapTemplate { + [self sendTemplateEventWithName:mapTemplate name:@"didCancelNavigation"]; +} + +//- (BOOL)mapTemplate:(CPMapTemplate *)mapTemplate shouldShowNotificationForManeuver:(CPManeuver *)maneuver { +// // @todo +//} +//- (BOOL)mapTemplate:(CPMapTemplate *)mapTemplate shouldUpdateNotificationForManeuver:(CPManeuver *)maneuver withTravelEstimates:(CPTravelEstimates *)travelEstimates { +// // @todo +//} +//- (BOOL)mapTemplate:(CPMapTemplate *)mapTemplate shouldShowNotificationForNavigationAlert:(CPNavigationAlert *)navigationAlert { +// // @todo +//} + +- (void)mapTemplate:(CPMapTemplate *)mapTemplate willShowNavigationAlert:(CPNavigationAlert *)navigationAlert { + [self sendTemplateEventWithName:mapTemplate name:@"willShowNavigationAlert" json:[self navigationAlertToJson:navigationAlert]]; +} +- (void)mapTemplate:(CPMapTemplate *)mapTemplate didShowNavigationAlert:(CPNavigationAlert *)navigationAlert { + [self sendTemplateEventWithName:mapTemplate name:@"didShowNavigationAlert" json:[self navigationAlertToJson:navigationAlert]]; +} +- (void)mapTemplate:(CPMapTemplate *)mapTemplate willDismissNavigationAlert:(CPNavigationAlert *)navigationAlert dismissalContext:(CPNavigationAlertDismissalContext)dismissalContext { + [self sendTemplateEventWithName:mapTemplate name:@"willDismissNavigationAlert" json:[self navigationAlertToJson:navigationAlert dismissalContext:dismissalContext]]; +} +- (void)mapTemplate:(CPMapTemplate *)mapTemplate didDismissNavigationAlert:(CPNavigationAlert *)navigationAlert dismissalContext:(CPNavigationAlertDismissalContext)dismissalContext { + [self sendTemplateEventWithName:mapTemplate name:@"didDismissNavigationAlert" json:[self navigationAlertToJson:navigationAlert dismissalContext:dismissalContext]]; +} + +- (void)mapTemplateDidShowPanningInterface:(CPMapTemplate *)mapTemplate { + [self sendTemplateEventWithName:mapTemplate name:@"didShowPanningInterface"]; +} +- (void)mapTemplateWillDismissPanningInterface:(CPMapTemplate *)mapTemplate { + [self sendTemplateEventWithName:mapTemplate name:@"willDismissPanningInterface"]; +} +- (void)mapTemplateDidDismissPanningInterface:(CPMapTemplate *)mapTemplate { + [self sendTemplateEventWithName:mapTemplate name:@"didDismissPanningInterface"]; +} +- (void)mapTemplateDidBeginPanGesture:(CPMapTemplate *)mapTemplate { + [self sendTemplateEventWithName:mapTemplate name:@"didBeginPanGesture"]; +} +- (void)mapTemplate:(CPMapTemplate *)mapTemplate panWithDirection:(CPPanDirection)direction { + [self sendTemplateEventWithName:mapTemplate name:@"panWithDirection" json:@{ @"direction": [self panDirectionToString:direction] }]; +} +- (void)mapTemplate:(CPMapTemplate *)mapTemplate panBeganWithDirection:(CPPanDirection)direction { + [self sendTemplateEventWithName:mapTemplate name:@"panBeganWithDirection" json:@{ @"direction": [self panDirectionToString:direction] }]; +} +- (void)mapTemplate:(CPMapTemplate *)mapTemplate panEndedWithDirection:(CPPanDirection)direction { + [self sendTemplateEventWithName:mapTemplate name:@"panEndedWithDirection" json:@{ @"direction": [self panDirectionToString:direction] }]; +} +- (void)mapTemplate:(CPMapTemplate *)mapTemplate didEndPanGestureWithVelocity:(CGPoint)velocity { + [self sendTemplateEventWithName:mapTemplate name:@"didEndPanGestureWithVelocity" json:@{ @"velocity": @{ @"x": @(velocity.x), @"y": @(velocity.y) }}]; +} +- (void)mapTemplate:(CPMapTemplate *)mapTemplate didUpdatePanGestureWithTranslation:(CGPoint)translation velocity:(CGPoint)velocity { + [self sendTemplateEventWithName:mapTemplate name:@"didUpdatePanGestureWithTranslation" json:@{ @"translation": @{ @"x": @(translation.x), @"y": @(translation.y) }, @"velocity": @{ @"x": @(velocity.x), @"y": @(velocity.y) }}]; +} + + + +# pragma SearchTemplate + +- (void)searchTemplate:(CPSearchTemplate *)searchTemplate selectedResult:(CPListItem *)item completionHandler:(void (^)(void))completionHandler { + NSNumber* index = [item.userInfo objectForKey:@"index"]; + [self sendTemplateEventWithName:searchTemplate name:@"selectedResult" json:@{ @"index": index }]; + self.selectedResultBlock = completionHandler; +} + +- (void)searchTemplateSearchButtonPressed:(CPSearchTemplate *)searchTemplate { + [self sendTemplateEventWithName:searchTemplate name:@"searchButtonPressed"]; +} + +- (void)searchTemplate:(CPSearchTemplate *)searchTemplate updatedSearchText:(NSString *)searchText completionHandler:(void (^)(NSArray * _Nonnull))completionHandler { + [self sendTemplateEventWithName:searchTemplate name:@"updatedSearchText" json:@{ @"searchText": searchText }]; + self.searchResultBlock = completionHandler; +} + +# pragma ListTemplate + +- (void)listTemplate:(CPListTemplate *)listTemplate didSelectListItem:(CPListItem *)item completionHandler:(void (^)(void))completionHandler { + NSNumber* index = [item.userInfo objectForKey:@"index"]; + [self sendTemplateEventWithName:listTemplate name:@"didSelectListItem" json:@{ @"index": index }]; + self.selectedResultBlock = completionHandler; +} + +# pragma TabBarTemplate +- (void)tabBarTemplate:(CPTabBarTemplate *)tabBarTemplate didSelectTemplate:(__kindof CPTemplate *)selectedTemplate { + NSString* selectedTemplateId = [[selectedTemplate userInfo] objectForKey:@"templateId"]; + [self sendTemplateEventWithName:tabBarTemplate name:@"didSelectTemplate" json:@{@"selectedTemplateId":selectedTemplateId}]; +} + +# pragma PointOfInterest +-(void)pointOfInterestTemplate:(CPPointOfInterestTemplate *)pointOfInterestTemplate didChangeMapRegion:(MKCoordinateRegion)region { + // noop +} + +-(void)pointOfInterestTemplate:(CPPointOfInterestTemplate *)pointOfInterestTemplate didSelectPointOfInterest:(CPPointOfInterest *)pointOfInterest { + [self sendTemplateEventWithName:pointOfInterestTemplate name:@"didSelectPointOfInterest" json:[pointOfInterest userInfo]]; +} + +# pragma InterfaceController + +- (void)templateDidAppear:(CPTemplate *)aTemplate animated:(BOOL)animated { + [self sendTemplateEventWithName:aTemplate name:@"didAppear" json:@{ @"animated": @(animated) }]; +} + +- (void)templateDidDisappear:(CPTemplate *)aTemplate animated:(BOOL)animated { + [self sendTemplateEventWithName:aTemplate name:@"didDisappear" json:@{ @"animated": @(animated) }]; +} + +- (void)templateWillAppear:(CPTemplate *)aTemplate animated:(BOOL)animated { + [self sendTemplateEventWithName:aTemplate name:@"willAppear" json:@{ @"animated": @(animated) }]; +} + +- (void)templateWillDisappear:(CPTemplate *)aTemplate animated:(BOOL)animated { + [self sendTemplateEventWithName:aTemplate name:@"willDisappear" json:@{ @"animated": @(animated) }]; +} + +# pragma NowPlaying + +- (void)nowPlayingTemplateUpNextButtonTapped:(CPNowPlayingTemplate *)nowPlayingTemplate { + [self sendTemplateEventWithName:nowPlayingTemplate name:@"upNextButtonPressed"]; +} + +- (void)nowPlayingTemplateAlbumArtistButtonTapped:(CPNowPlayingTemplate *)nowPlayingTemplate { + [self sendTemplateEventWithName:nowPlayingTemplate name:@"albumArtistButtonPressed"]; +} + +@end diff --git a/packages/react-native-carplay/ios/RNCarPlay.xcodeproj/project.pbxproj b/packages/react-native-carplay/ios/RNCarPlay.xcodeproj/project.pbxproj new file mode 100644 index 00000000..ca557f0b --- /dev/null +++ b/packages/react-native-carplay/ios/RNCarPlay.xcodeproj/project.pbxproj @@ -0,0 +1,300 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + E344BCC72249585C006FD80D /* RNCarPlay.m in Sources */ = {isa = PBXBuildFile; fileRef = E344BBF222494D53006FD80D /* RNCarPlay.m */; }; + E344BCC82249585C006FD80D /* RNCPStore.m in Sources */ = {isa = PBXBuildFile; fileRef = E344BBF422494D9D006FD80D /* RNCPStore.m */; }; + E35223EB224BD4E500623F30 /* RCTConvert+RNCarPlay.m in Sources */ = {isa = PBXBuildFile; fileRef = E35223EA224BD4E500623F30 /* RCTConvert+RNCarPlay.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + E344BCBC22495851006FD80D /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "include/$(PRODUCT_NAME)"; + dstSubfolderSpec = 16; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + E344BBEA22494CBD006FD80D /* RNCarPlay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNCarPlay.h; sourceTree = ""; }; + E344BBF222494D53006FD80D /* RNCarPlay.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNCarPlay.m; sourceTree = ""; }; + E344BBF422494D9D006FD80D /* RNCPStore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNCPStore.m; sourceTree = ""; }; + E344BBF622494DAC006FD80D /* RNCPStore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNCPStore.h; sourceTree = ""; }; + E344BCBE22495851006FD80D /* libRNCarPlay.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRNCarPlay.a; sourceTree = BUILT_PRODUCTS_DIR; }; + E35223E9224BD4C400623F30 /* RCTConvert+RNCarPlay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RCTConvert+RNCarPlay.h"; sourceTree = ""; }; + E35223EA224BD4E500623F30 /* RCTConvert+RNCarPlay.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "RCTConvert+RNCarPlay.m"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + E344BCBB22495851006FD80D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + E344BBDD22494CBD006FD80D = { + isa = PBXGroup; + children = ( + E35223EA224BD4E500623F30 /* RCTConvert+RNCarPlay.m */, + E35223E9224BD4C400623F30 /* RCTConvert+RNCarPlay.h */, + E344BBEA22494CBD006FD80D /* RNCarPlay.h */, + E344BBF222494D53006FD80D /* RNCarPlay.m */, + E344BBF622494DAC006FD80D /* RNCPStore.h */, + E344BBF422494D9D006FD80D /* RNCPStore.m */, + E344BBE822494CBD006FD80D /* Products */, + ); + sourceTree = ""; + }; + E344BBE822494CBD006FD80D /* Products */ = { + isa = PBXGroup; + children = ( + E344BCBE22495851006FD80D /* libRNCarPlay.a */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E344BCBD22495851006FD80D /* RNCarPlay */ = { + isa = PBXNativeTarget; + buildConfigurationList = E344BCC422495851006FD80D /* Build configuration list for PBXNativeTarget "RNCarPlay" */; + buildPhases = ( + E344BCBA22495851006FD80D /* Sources */, + E344BCBB22495851006FD80D /* Frameworks */, + E344BCBC22495851006FD80D /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RNCarPlay; + productName = RNCarPlay; + productReference = E344BCBE22495851006FD80D /* libRNCarPlay.a */; + productType = "com.apple.product-type.library.static"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E344BBDE22494CBD006FD80D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1010; + ORGANIZATIONNAME = "SOLID Mobile"; + TargetAttributes = { + E344BCBD22495851006FD80D = { + CreatedOnToolsVersion = 10.1; + }; + }; + }; + buildConfigurationList = E344BBE122494CBD006FD80D /* Build configuration list for PBXProject "RNCarPlay" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + ); + mainGroup = E344BBDD22494CBD006FD80D; + productRefGroup = E344BBE822494CBD006FD80D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E344BCBD22495851006FD80D /* RNCarPlay */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + E344BCBA22495851006FD80D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E35223EB224BD4E500623F30 /* RCTConvert+RNCarPlay.m in Sources */, + E344BCC72249585C006FD80D /* RNCarPlay.m in Sources */, + E344BCC82249585C006FD80D /* RNCPStore.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + E344BBED22494CBD006FD80D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + E344BBEE22494CBD006FD80D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + E344BCC522495851006FD80D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5PD7JU2FJC; + OTHER_LDFLAGS = "-ObjC"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E344BCC622495851006FD80D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5PD7JU2FJC; + OTHER_LDFLAGS = "-ObjC"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E344BBE122494CBD006FD80D /* Build configuration list for PBXProject "RNCarPlay" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E344BBED22494CBD006FD80D /* Debug */, + E344BBEE22494CBD006FD80D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E344BCC422495851006FD80D /* Build configuration list for PBXNativeTarget "RNCarPlay" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E344BCC522495851006FD80D /* Debug */, + E344BCC622495851006FD80D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = E344BBDE22494CBD006FD80D /* Project object */; +} diff --git a/packages/react-native-carplay/package.json b/packages/react-native-carplay/package.json new file mode 100644 index 00000000..e0e6c1bc --- /dev/null +++ b/packages/react-native-carplay/package.json @@ -0,0 +1,76 @@ +{ + "name": "react-native-carplay", + "version": "2.3.0", + "description": "CarPlay for React Native", + "main": "lib/index.js", + "files": [ + "src", + "lib", + "ios", + "react-native-carplay.podspec" + ], + "podspecPath": "./react-native-carplay.podspec", + "scripts": { + "prepare": "yarn run build", + "lint": "eslint . --ext .ts,.tsx,.js,.jsx", + "typecheck": "tsc --noEmit", + "build": "tsc", + "fix-all-files": "eslint . --ext .ts,.tsx,.js,.jsx --fix" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/birkir/react-native-carplay.git" + }, + "keywords": [ + "react", + "native", + "carplay", + "navigation", + "car", + "auto" + ], + "author": "Birkir Gudjonsson ", + "license": "MIT", + "bugs": { + "url": "https://github.com/birkir/react-native-carplay/issues" + }, + "homepage": "https://github.com/birkir/react-native-carplay#readme", + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0", + "react-native": "^0.60.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + }, + "devDependencies": { + "@tsconfig/react-native": "3.0.2", + "@types/node": "17.0.25", + "@types/react": "18.0.6", + "@types/react-native": "0.67.5", + "@typescript-eslint/eslint-plugin": "5.20.0", + "@typescript-eslint/parser": "5.20.0", + "cross-env": "7.0.3", + "docusaurus-plugin-typedoc": "0.19.2", + "eslint": "8.13.0", + "eslint-config-prettier": "8.5.0", + "eslint-import-resolver-typescript": "2.7.1", + "eslint-plugin-import": "2.26.0", + "eslint-plugin-prettier": "4.0.0", + "eslint-plugin-react": "7.29.4", + "eslint-plugin-react-hooks": "4.4.0", + "microbundle": "0.14.2", + "prettier": "2.6.2", + "react": "18.0.0", + "react-native": "0.68.1", + "rimraf": "3.0.2", + "typedoc": "0.24.7", + "typedoc-github-wiki-theme": "1.1.0", + "typedoc-plugin-markdown": "3.15.3", + "typescript": "5.0.4" + } +} diff --git a/packages/react-native-carplay/react-native-carplay.podspec b/packages/react-native-carplay/react-native-carplay.podspec new file mode 100644 index 00000000..1fce051b --- /dev/null +++ b/packages/react-native-carplay/react-native-carplay.podspec @@ -0,0 +1,21 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + s.name = package['name'] + s.version = package['version'] + s.summary = package['description'] + + s.homepage = package['repository']['url'] + + s.license = package['license'] + s.authors = package['author'] + s.ios.deployment_target = '12.0' + + s.source = { :git => "https://github.com/birkir/react-native-carplay.git" } + + s.source_files = "ios/*.{h,m}" + + s.dependency 'React' +end diff --git a/packages/react-native-carplay/src/CarPlay.ts b/packages/react-native-carplay/src/CarPlay.ts new file mode 100644 index 00000000..e41f8185 --- /dev/null +++ b/packages/react-native-carplay/src/CarPlay.ts @@ -0,0 +1,189 @@ +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; +import { ActionSheetTemplate } from './templates/ActionSheetTemplate'; +import { AlertTemplate } from './templates/AlertTemplate'; +import { ContactTemplate } from './templates/ContactTemplate'; +import { GridTemplate } from './templates/GridTemplate'; +import { InformationTemplate } from './templates/InformationTemplate'; +import { ListTemplate } from './templates/ListTemplate'; +import { MapTemplate } from './templates/MapTemplate'; +import { PointOfInterestTemplate } from './templates/PointOfInterestTemplate'; +import { SearchTemplate } from './templates/SearchTemplate'; +import { TabBarTemplate } from './templates/TabBarTemplate'; +import { VoiceControlTemplate } from './templates/VoiceControlTemplate'; +import { NowPlayingTemplate } from './templates/NowPlayingTemplate'; + +const { RNCarPlay } = NativeModules; + +type PushableTemplates = + | MapTemplate + | SearchTemplate + | GridTemplate + | PointOfInterestTemplate + | ListTemplate + | InformationTemplate + | ContactTemplate + | NowPlayingTemplate; +type PresentableTemplates = AlertTemplate | ActionSheetTemplate | VoiceControlTemplate; + +type WindowInformation = { + width: number, + height: number, + scale: number, +} + +type OnConnectCallback = (window: WindowInformation) => void +type OnDisconnectCallback = () => void + +/** + * A controller that manages all user interface elements appearing on your map displayed on the CarPlay screen. + */ +class CarPlayInterface { + /** + * React Native bridge to the CarPlay interface + */ + public bridge = RNCarPlay; + + /** + * Boolean to denote if carplay is currently connected. + */ + public connected = false; + + /** + * CarPlay Event Emitter + */ + public emitter = new NativeEventEmitter(RNCarPlay); + + private onConnectCallbacks = new Set(); + private onDisconnectCallbacks = new Set(); + + constructor() { + if (Platform.OS !== 'ios') { + return; + } + + this.emitter.addListener('didConnect', (window: WindowInformation) => { + this.connected = true; + this.onConnectCallbacks.forEach(callback => { + callback(window); + }); + }); + this.emitter.addListener('didDisconnect', () => { + this.connected = false; + this.onDisconnectCallbacks.forEach(callback => { + callback(); + }); + }); + + // check if already connected this will fire any 'didConnect' events + // if a connected is already present. + this.bridge.checkForConnection(); + } + + /** + * Fired when CarPlay is connected to the device. + */ + public registerOnConnect = (callback: OnConnectCallback) => { + this.onConnectCallbacks.add(callback); + }; + + public unregisterOnConnect = (callback: OnConnectCallback) => { + this.onConnectCallbacks.delete(callback); + }; + + /** + * Fired when CarPlay is disconnected from the device. + */ + public registerOnDisconnect = (callback: OnDisconnectCallback) => { + this.onDisconnectCallbacks.add(callback); + }; + + public unregisterOnDisconnect = (callback: OnDisconnectCallback) => { + this.onDisconnectCallbacks.delete(callback); + }; + + /** + * Sets the root template, starting a new stack for the template navigation hierarchy. + * @param rootTemplate The root template. Replaces the current rootTemplate, if one exists. + * @param animated Set TRUE to animate the presentation of the root template; ignored if there isn't a current rootTemplate. + */ + public setRootTemplate(rootTemplate: PushableTemplates | TabBarTemplate, animated = true) { + return this.bridge.setRootTemplate(rootTemplate.id, animated); + } + + /** + * Pushes a template onto the navigation stack and updates the display. + * @param templateToPush The template to push onto the navigation stack. + * @param animated Set TRUE to animate the presentation of the template. + */ + public pushTemplate(templateToPush: PushableTemplates, animated = true) { + return this.bridge.pushTemplate(templateToPush.id, animated); + } + + /** + * Pops templates until the specified template is at the top of the navigation stack. + * @param targetTemplate The template that you want at the top of the stack. The template must be on the navigation stack before calling this method. + * @param animated A Boolean value that indicates whether the system animates the display of transitioning templates. + */ + public popToTemplate(targetTemplate: PushableTemplates, animated = true) { + return this.bridge.popToTemplate(targetTemplate.id, animated); + } + + /** + * Pops all templates on the stack—except the root template—and updates the display. + * @param animated A Boolean value that indicates whether the system animates the display of transitioning templates. + */ + public popToRootTemplate(animated = true) { + return this.bridge.popToRootTemplate(animated); + } + + /** + * Pops the top template from the navigation stack and updates the display. + * @param animated A Boolean value that indicates whether the system animates the display of transitioning templates. + */ + public popTemplate(animated = true) { + return this.bridge.popTemplate(animated); + } + + /** + * presents a presentable template, alert / action / voice + * @param templateToPresent The presentable template to present + * @param animated A Boolean value that indicates whether the system animates the display of transitioning templates. + */ + public presentTemplate(templateToPresent: PresentableTemplates, animated = true) { + return this.bridge.presentTemplate(templateToPresent.id, animated); + } + + /** + * Dismisses the current presented template + * * @param animated A Boolean value that indicates whether the system animates the display of transitioning templates. + */ + public dismissTemplate(animated = true) { + return this.bridge.dismissTemplate(animated); + } + + /** + * The current root template in the template navigation hierarchy. + * @todo Not implemented yet + */ + public get rootTemplate(): Promise { + return Promise.resolve(''); + } + + /** + * The top-most template in the navigation hierarchy stack. + * @todo Not implemented yet + */ + public get topTemplate(): Promise { + return Promise.resolve(''); + } + + /** + * Control now playing template state + * @param enable A Boolean value that indicates whether the system use now playing template. + */ + public enableNowPlaying(enable = true) { + return this.bridge.enableNowPlaying(enable); + } +} + +export const CarPlay = new CarPlayInterface(); diff --git a/packages/react-native-carplay/src/index.ts b/packages/react-native-carplay/src/index.ts new file mode 100644 index 00000000..f73c0200 --- /dev/null +++ b/packages/react-native-carplay/src/index.ts @@ -0,0 +1,19 @@ +export { ListTemplate, ListTemplateConfig } from './templates/ListTemplate'; +export { GridTemplate, GridTemplateConfig } from './templates/GridTemplate'; +export { SearchTemplate, SearchTemplateConfig } from './templates/SearchTemplate'; +export { MapTemplate, MapTemplateConfig } from './templates/MapTemplate'; +export { TabBarTemplate, TabBarTemplateConfig } from './templates/TabBarTemplate'; +export { VoiceControlTemplate, VoiceControlTemplateConfig } from './templates/VoiceControlTemplate'; +export { NavigationSession } from './navigation/NavigationSession'; +export { ContactTemplate, ContactTemplateConfig } from './templates/ContactTemplate'; +export { ActionSheetTemplate, ActionSheetTemplateConfig } from './templates/ActionSheetTemplate'; +export { AlertTemplate, AlertTemplateConfig } from './templates/AlertTemplate'; +export { InformationTemplate, InformationTemplateConfig } from './templates/InformationTemplate'; +export { NowPlayingTemplate } from './templates/NowPlayingTemplate'; +export { + PointOfInterestTemplate, + PointOfInterestTemplateConfig, + PointOfInterestItem, +} from './templates/PointOfInterestTemplate'; +export { Trip, TripConfig, TripPoint } from './navigation/Trip'; +export { CarPlay } from './CarPlay'; diff --git a/packages/react-native-carplay/src/interfaces/AlertAction.ts b/packages/react-native-carplay/src/interfaces/AlertAction.ts new file mode 100644 index 00000000..cbddf244 --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/AlertAction.ts @@ -0,0 +1,5 @@ +export interface AlertAction { + id: string; + title: string; + style?: 'default' | 'cancel' | 'destructive'; +} diff --git a/packages/react-native-carplay/src/interfaces/BarButton.ts b/packages/react-native-carplay/src/interfaces/BarButton.ts new file mode 100644 index 00000000..61a591b1 --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/BarButton.ts @@ -0,0 +1,41 @@ +import { ImageSourcePropType } from "react-native"; + +interface BarButtonBase { + /** + * Button ID + */ + id: string; + /** + * A Boolean value that enables and disables the bar button. + */ + disabled?: boolean; +} + +interface BarButtonText extends BarButtonBase { + /** + * A text style bar button. + */ + type: 'text'; + /** + * The title displayed on the button. + */ + title: string; +} + +interface BarButtonImage extends BarButtonBase { + /** + * An image style bar button. + */ + type: 'image'; + /** + * The image displayed on the button. + * + * If you provide an animated image, the button displays only the first image in the animation sequence. + */ + image: ImageSourcePropType; +} + +/** + * A button in a navigation bar. + */ +export type BarButton = BarButtonImage | BarButtonText; diff --git a/packages/react-native-carplay/src/interfaces/GridButton.ts b/packages/react-native-carplay/src/interfaces/GridButton.ts new file mode 100644 index 00000000..f9670d81 --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/GridButton.ts @@ -0,0 +1,25 @@ +import { ImageSourcePropType } from "react-native"; + +/** + * A menu item button displayed on a grid template. + */ +export interface GridButton { + /** + * Button ID + */ + id: string; + /** + * An array of title variants for the button. + * + * When the system displays the button, it selects the title that best fits the available screen space, so arrange the titles from most to least preferred when creating a grid button. Also, localize each title for display to the user, and be sure to include at least one title in the array. + */ + titleVariants: string[]; + /** + * The image displayed on the button. + * + * When creating a grid button, don't provide an animated image. If you do, the button uses the first image in the animation sequence. + */ + image: ImageSourcePropType; + + disabled?: boolean; +} diff --git a/packages/react-native-carplay/src/interfaces/ListItem.ts b/packages/react-native-carplay/src/interfaces/ListItem.ts new file mode 100644 index 00000000..07a565d1 --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/ListItem.ts @@ -0,0 +1,31 @@ +import { ImageSourcePropType } from "react-native"; + +/** + * A list item that appears in a list template. + */ +export interface ListItem { + /** + * The primary text displayed in the list item cell. + */ + text: string; + /** + * Extra text displayed below the primary text in the list item cell. + */ + detailText?: string; + /** + * The image displayed on the leading edge of the list item cell. + */ + image?: ImageSourcePropType; + /** + * The image from file system displayed on the leading edge of the list item cell. + */ + imgUrl?: null; + /** + * A Boolean value indicating whether the list item cell shows a disclosure indicator on the trailing edge of the list item cell. + */ + showsDisclosureIndicator?: boolean; + /** + * Is Playing flag. + */ + isPlaying?: boolean; +} diff --git a/packages/react-native-carplay/src/interfaces/ListItemUpdate.ts b/packages/react-native-carplay/src/interfaces/ListItemUpdate.ts new file mode 100644 index 00000000..00271fce --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/ListItemUpdate.ts @@ -0,0 +1,14 @@ +import { ListItem } from './ListItem'; +/** + * A list item update payload. + */ +export interface ListItemUpdate extends ListItem { + /** + * The section of item. + */ + sectionIndex: number; + /** + * The index of item. + */ + itemIndex: number; +} diff --git a/packages/react-native-carplay/src/interfaces/ListSection.ts b/packages/react-native-carplay/src/interfaces/ListSection.ts new file mode 100644 index 00000000..2058c7e6 --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/ListSection.ts @@ -0,0 +1,21 @@ +import { ListItem } from './ListItem'; + +/** + * A section of list items that appear in a list template. + */ +export interface ListSection { + /** + * The section header text. + */ + header?: string; + /** + * The section index title. + * + * The system displays only the first character of the section index title. + */ + sectionIndexTitle?: string; + /** + * The list of items for the section. + */ + items: ListItem[]; +} diff --git a/packages/react-native-carplay/src/interfaces/Maneuver.ts b/packages/react-native-carplay/src/interfaces/Maneuver.ts new file mode 100644 index 00000000..e587d5b5 --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/Maneuver.ts @@ -0,0 +1,34 @@ +import { TravelEstimates } from './TravelEstimates'; +import { ImageSourcePropType, ProcessedColorValue } from 'react-native'; + +/** + * Navigation instructions and distance from the previous maneuver. + */ +export interface Maneuver { + junctionImage?: ImageSourcePropType; + initialTravelEstimates?: TravelEstimates; + symbolImage?: ImageSourcePropType; + /** + * Allows the supplied symbol image to be resized + * to the suitable scal for it's use as a primary + * or secondary image. This functionality would usually + * be available via the `` tag but carplay + * requires an image asset, so this resizing is done + * on the native side. + */ + resizeSymbolImage?: 'primary' | 'secondary'; + /** + * Allows the supplied symbol image to be tinted + * via a color, ie. 'red'. This functionality would usually + * be available via the `` tag but carplay requires + * an image asset to this tinting is done on the native side. + * If a string is supplied, it will be passed to `processColor`. + * You may also use `processColor` yourself. + */ + tintSymbolImage?: ProcessedColorValue; + instructionVariants: string[]; + + // not yet implemented + dashboardInstructionVariants?: string[]; + notificationInstructionVariants?: string[]; +} diff --git a/packages/react-native-carplay/src/interfaces/MapButton.ts b/packages/react-native-carplay/src/interfaces/MapButton.ts new file mode 100644 index 00000000..c9bec9e6 --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/MapButton.ts @@ -0,0 +1,27 @@ +import { ImageSourcePropType } from "react-native"; + +/** + * A button representing an action that a map template displays on the CarPlay screen. + */ +export interface MapButton { + /** + * Button ID + */ + id: string; + /** + * The image to display on the button. + */ + image?: ImageSourcePropType; + /** + * The image to display when focus is on the button. + */ + focusedImage?: ImageSourcePropType; + /** + * A Boolean value that enables and disables the map button. + */ + disabled?: boolean; + /** + * A Boolean value that hides and shows the map button. + */ + hidden?: boolean; +} diff --git a/packages/react-native-carplay/src/interfaces/NavigationAlert.ts b/packages/react-native-carplay/src/interfaces/NavigationAlert.ts new file mode 100644 index 00000000..dda54fff --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/NavigationAlert.ts @@ -0,0 +1,46 @@ +import { ImageSourcePropType } from "react-native"; + +export enum NavigationAlertActionStyle { + Default = 0, + Cancel = 1, + Destructive = 2, +} + +export interface NavigationAlertAction { + /** + * The action button's title. + */ + title: string; + /** + * The display style for the action button. + */ + style?: NavigationAlertActionStyle; +} + +/** + * An alert panel that displays map or navigation related information to the user. + */ +export interface NavigationAlert { + lightImage?: ImageSourcePropType; + darkImage?: ImageSourcePropType; + /** + * An array of title strings. + */ + titleVariants: string[]; + /** + * An array of subtitle strings. + */ + subtitleVariants?: string[]; + /** + * The primary action, and button, for the navigation alert. + */ + primaryAction: NavigationAlertAction; + /** + * An optional, secondary action (and button) for the navigation alert. + */ + secondaryAction?: NavigationAlertAction; + /** + * The amount of time, in seconds, that the alert is visible. + */ + duration: number; +} diff --git a/packages/react-native-carplay/src/interfaces/PauseReason.ts b/packages/react-native-carplay/src/interfaces/PauseReason.ts new file mode 100644 index 00000000..10a77ab7 --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/PauseReason.ts @@ -0,0 +1,7 @@ +export enum PauseReason { + Arrived = 1, + Loading = 2, + Locating = 3, + Rerouting = 4, + ProceedToRoute = 5, +} diff --git a/packages/react-native-carplay/src/interfaces/TextConfiguration.ts b/packages/react-native-carplay/src/interfaces/TextConfiguration.ts new file mode 100644 index 00000000..7aa6d9a7 --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/TextConfiguration.ts @@ -0,0 +1,5 @@ +export interface TextConfiguration { + startButtonTitle?: string; + additionalRoutesButtonTitle?: string; + overviewButtonTitle?: string; +} diff --git a/packages/react-native-carplay/src/interfaces/TimeRemainingColor.ts b/packages/react-native-carplay/src/interfaces/TimeRemainingColor.ts new file mode 100644 index 00000000..9ae4886c --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/TimeRemainingColor.ts @@ -0,0 +1 @@ +export type TimeRemainingColor = 0 | 1 | 2 | 3; diff --git a/packages/react-native-carplay/src/interfaces/TravelEstimates.ts b/packages/react-native-carplay/src/interfaces/TravelEstimates.ts new file mode 100644 index 00000000..02f03203 --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/TravelEstimates.ts @@ -0,0 +1,17 @@ +export type DistanceUnits = 'meters' | 'miles' | 'kilometers' | 'yards' | 'feet'; + +export interface TravelEstimates { + /** + * Distance remaining + */ + distanceRemaining: number; + /** + * Time remaining in seconds + */ + timeRemaining: number; + /** + * unit of measurement for the + * distance, defaults to kilometer + */ + distanceUnits?: DistanceUnits; +} diff --git a/packages/react-native-carplay/src/interfaces/VoiceControlState.ts b/packages/react-native-carplay/src/interfaces/VoiceControlState.ts new file mode 100644 index 00000000..8d7bbac8 --- /dev/null +++ b/packages/react-native-carplay/src/interfaces/VoiceControlState.ts @@ -0,0 +1,8 @@ +import { ImageSourcePropType } from "react-native"; + +export interface VoiceControlState { + identifier: string; + image?: ImageSourcePropType; + repeats: boolean; + titleVariants: string[]; +} diff --git a/packages/react-native-carplay/src/navigation/NavigationSession.ts b/packages/react-native-carplay/src/navigation/NavigationSession.ts new file mode 100644 index 00000000..8edc1265 --- /dev/null +++ b/packages/react-native-carplay/src/navigation/NavigationSession.ts @@ -0,0 +1,52 @@ +import { CarPlay } from '../CarPlay'; +import { Maneuver } from '../interfaces/Maneuver'; +import { PauseReason } from '../interfaces/PauseReason'; +import { TravelEstimates } from '../interfaces/TravelEstimates'; +import { MapTemplate } from '../templates/MapTemplate'; +import { Trip } from './Trip'; +import { Image, processColor } from 'react-native'; + +export class NavigationSession { + public maneuvers: Maneuver[]; + + constructor(public id: string, public trip: Trip, public mapTemplate: MapTemplate) {} + + public updateManeuvers(maneuvers: Maneuver[]) { + this.maneuvers = maneuvers; + + CarPlay.bridge.updateManeuversNavigationSession( + this.id, + maneuvers.map(maneuver => { + if (maneuver.symbolImage) { + maneuver.symbolImage = Image.resolveAssetSource(maneuver.symbolImage); + } + if (maneuver.junctionImage) { + maneuver.junctionImage = Image.resolveAssetSource(maneuver.junctionImage); + } + if (maneuver.tintSymbolImage && typeof maneuver.tintSymbolImage === 'string') { + maneuver.tintSymbolImage = processColor(maneuver.tintSymbolImage); + } + return maneuver; + }), + ); + } + + public updateTravelEstimates(maneuverIndex: number, travelEstimates: TravelEstimates) { + if (!travelEstimates.distanceUnits) { + travelEstimates.distanceUnits = 'kilometers'; + } + CarPlay.bridge.updateTravelEstimatesNavigationSession(this.id, maneuverIndex, travelEstimates); + } + + public cancel() { + CarPlay.bridge.cancelNavigationSession(this.id); + } + + public finish() { + CarPlay.bridge.finishNavigationSession(this.id); + } + + public pause(reason: PauseReason, description?: string) { + CarPlay.bridge.pauseNavigationSession(this.id, reason, description); + } +} diff --git a/packages/react-native-carplay/src/navigation/Trip.ts b/packages/react-native-carplay/src/navigation/Trip.ts new file mode 100644 index 00000000..12f5f36e --- /dev/null +++ b/packages/react-native-carplay/src/navigation/Trip.ts @@ -0,0 +1,36 @@ +import { CarPlay } from '../CarPlay'; + +export interface RouteChoice { + additionalInformationVariants: string[]; + selectionSummaryVariants: string[]; + summaryVariants: string[]; +} + +export interface TripPoint { + latitude: number; + longitude: number; + name: string; +} + +export interface TripConfig { + id?: string; + origin: TripPoint; + destination: TripPoint; + routeChoices: RouteChoice[]; +} + +export class Trip { + public id: string; + + constructor(public config: TripConfig) { + if (config.id) { + this.id = config.id; + } + + if (!this.id) { + this.id = `trip-${Date.now()}-${Math.round(Math.random() * Number.MAX_SAFE_INTEGER)}`; + } + + CarPlay.bridge.createTrip(this.id, config); + } +} diff --git a/packages/react-native-carplay/src/templates/ActionSheetTemplate.ts b/packages/react-native-carplay/src/templates/ActionSheetTemplate.ts new file mode 100644 index 00000000..2980373f --- /dev/null +++ b/packages/react-native-carplay/src/templates/ActionSheetTemplate.ts @@ -0,0 +1,21 @@ +import { AlertAction } from '../interfaces/AlertAction'; +import { Template, TemplateConfig } from './Template'; + +export interface ActionSheetTemplateConfig extends TemplateConfig { + title: string; + message?: string; + actions: AlertAction[]; + onActionButtonPressed?(e: { id: string; templateId: string }): void; +} + +export class ActionSheetTemplate extends Template { + public get type(): string { + return 'actionsheet'; + } + + get eventMap() { + return { + actionButtonPressed: 'onActionButtonPressed', + }; + } +} diff --git a/packages/react-native-carplay/src/templates/AlertTemplate.ts b/packages/react-native-carplay/src/templates/AlertTemplate.ts new file mode 100644 index 00000000..be63cc7f --- /dev/null +++ b/packages/react-native-carplay/src/templates/AlertTemplate.ts @@ -0,0 +1,20 @@ +import { AlertAction } from '../interfaces/AlertAction'; +import { Template, TemplateConfig } from './Template'; + +export interface AlertTemplateConfig extends TemplateConfig { + titleVariants: string[]; + actions?: AlertAction[]; + onActionButtonPressed?(e: { id: string; templateId: string }): void; +} + +export class AlertTemplate extends Template { + public get type(): string { + return 'alert'; + } + + get eventMap() { + return { + actionButtonPressed: 'onActionButtonPressed', + }; + } +} diff --git a/packages/react-native-carplay/src/templates/ContactTemplate.ts b/packages/react-native-carplay/src/templates/ContactTemplate.ts new file mode 100644 index 00000000..d6608292 --- /dev/null +++ b/packages/react-native-carplay/src/templates/ContactTemplate.ts @@ -0,0 +1,45 @@ +import { ImageSourcePropType } from 'react-native'; +import { Template, TemplateConfig } from './Template'; + +interface ContactButtonEvent { + id: string; + templateId: string; +} + +interface ContactActionBase { + id: string; + type: 'call' | 'directions' | 'message'; + disabled?: boolean; + title?: string; +} + +interface ContactActionMessage extends ContactActionBase { + type: 'message'; + phoneOrEmail: string; +} + +type ContactAction = ContactActionBase | ContactActionMessage; + +export interface ContactTemplateConfig extends TemplateConfig { + name: string; + image: ImageSourcePropType; + subtitle?: string; + informativeText?: string; + actions?: ContactAction[]; + /** + * Fired when bar button is pressed + * @param e Event + */ + onButtonPressed?(e: ContactButtonEvent): void; +} + +export class ContactTemplate extends Template { + public get type(): string { + return 'contact'; + } + get eventMap() { + return { + buttonPressed: 'onButtonPressed', + }; + } +} diff --git a/packages/react-native-carplay/src/templates/GridTemplate.ts b/packages/react-native-carplay/src/templates/GridTemplate.ts new file mode 100644 index 00000000..b5e54cac --- /dev/null +++ b/packages/react-native-carplay/src/templates/GridTemplate.ts @@ -0,0 +1,45 @@ +import { CarPlay } from '../CarPlay'; +import { GridButton } from '../interfaces/GridButton'; +import { BaseEvent, Template, TemplateConfig } from './Template'; + +interface ButtonPressedEvent extends BaseEvent { + /** + * Button ID + */ + id: string; + /** + * Button Index + */ + index: number; + /** + * template ID + */ + templateId: string; +} + +export interface GridTemplateConfig extends TemplateConfig { + /** + * The title displayed in the navigation bar while the list template is visible. + */ + title?: string; + /** + * The array of grid buttons displayed on the template. + */ + buttons: GridButton[]; + /** + * Fired when a button is pressed + */ + onButtonPressed?(e: ButtonPressedEvent): void; +} + +export class GridTemplate extends Template { + public get type(): string { + return 'grid'; + } + + get eventMap() { + return { + gridButtonPressed: 'onButtonPressed', + }; + } +} diff --git a/packages/react-native-carplay/src/templates/InformationTemplate.ts b/packages/react-native-carplay/src/templates/InformationTemplate.ts new file mode 100644 index 00000000..55d40e83 --- /dev/null +++ b/packages/react-native-carplay/src/templates/InformationTemplate.ts @@ -0,0 +1,40 @@ +import { Template, TemplateConfig } from './Template'; +import { CarPlay } from '../CarPlay'; + +interface InformationItem { + title: string; + detail: string; +} + +interface InformationAction { + id: string; + title: string; +} + +export interface InformationTemplateConfig extends TemplateConfig { + title: string; + leading?: boolean; + items: InformationItem[]; + actions: InformationAction[]; + onActionButtonPressed(e: { id: string; templateId: string }): void; +} + +export class InformationTemplate extends Template { + public get type(): string { + return 'information'; + } + + get eventMap() { + return { + actionButtonPressed: 'onActionButtonPressed', + }; + } + + public updateInformationTemplateItems = (items: InformationItem[]) => { + return CarPlay.bridge.updateInformationTemplateItems(this.id, this.parseConfig(items)); + }; + + public updateInformationTemplateActions = (actions: InformationAction[]) => { + return CarPlay.bridge.updateInformationTemplateActions(this.id, this.parseConfig(actions)); + }; +} diff --git a/packages/react-native-carplay/src/templates/ListTemplate.ts b/packages/react-native-carplay/src/templates/ListTemplate.ts new file mode 100644 index 00000000..5b26446c --- /dev/null +++ b/packages/react-native-carplay/src/templates/ListTemplate.ts @@ -0,0 +1,113 @@ +import { CarPlay } from '../CarPlay'; +import { ListItemUpdate } from '../interfaces/ListItemUpdate'; +import { ListSection } from '../interfaces/ListSection'; +import { Template, TemplateConfig } from './Template'; +export interface ListTemplateConfig extends TemplateConfig { + /** + * The title displayed in the navigation bar while the list template is visible. + */ + title?: string; + /** + * The sections displayed in the list. + */ + sections: ListSection[]; + /** + * An optional array of strings, ordered from most to least preferred. + * The variant strings should be provided as localized, displayable content. + * The system will select the first variant that fits the available space. + * If the list template does not contain any items (itemCount == 0), then + * the template will display an empty view with a title and subtitle to indicate + * that the template has no list items. + * If the list template is updated to contain items, the empty view will be automatically + * removed. + */ + emptyViewTitleVariants?: string[]; + /** + * An optional array of strings, ordered from most to least preferred. + * The variant strings should be provided as localized, displayable content. + * The system will select the first variant that fits the available space. + * If the list template does not contain any items (itemCount == 0), then + * the template will display an empty view with a title and subtitle to indicate + * that the template has no list items. + * If the list template is updated to contain items, the empty view will be automatically + * removed. + */ + emptyViewSubtitleVariants?: string[]; + /** + * Fired when list item is selected. + * Spinner shows by default. + * When the returned promise is resolved the spinner will hide. + * @param item Object with the selected index + */ + onItemSelect?(item: { index: number }): Promise; + + /** + * Fired when the back button is pressed + */ + onBackButtonPressed?(): void; + + /** + * Option to hide back button + * (defaults to false) + */ + backButtonHidden?: Boolean; + + /** + * Assistant Configuration + * @see https://developer.apple.com/documentation/carplay/cplisttemplate#3762508 + */ + assistant?: { + enabled: boolean; + position: 'top' | 'bottom'; + visibility: 'off' | 'always' | 'limited'; + action: 'playMedia' | 'startCall'; + } +} + +/** + * A hierarchical list of menu items can be displayed on the CarPlay screen using a list template. + * + * The List Template allows navigation apps to present a hierarchical list of menu items. It includes a navigation bar and a list view. + * + * The navigation bar includes a title, and up to two (2) leading buttons and two (2) trailing buttons. You can customize the appearance of these buttons with icons or text. + * + * Each item in the list view may include an icon, title, subtitle, and an optional disclosure indicator indicating the presence of a submenu. The depth of the menu hierarchy may not exceed 5 levels. Note that some cars limit the total number of items that may be shown in a list. + */ +export class ListTemplate extends Template { + public get type(): string { + return 'list'; + } + + get eventMap() { + return { + backButtonPressed: 'onBackButtonPressed', + }; + } + + constructor(public config: ListTemplateConfig) { + super(config); + + CarPlay.emitter.addListener('didSelectListItem', e => { + if (config.onItemSelect && e.templateId === this.id) { + const x = config.onItemSelect(e); + Promise.resolve(x).then(() => CarPlay.bridge.reactToSelectedResult(true)); + } + }); + } + + public updateSections = (sections: ListSection[]) => { + return CarPlay.bridge.updateListTemplateSections(this.id, this.parseConfig(sections)); + }; + + public updateListTemplateItem = (config: ListItemUpdate) => { + return CarPlay.bridge.updateListTemplateItem(this.id, this.parseConfig(config)); + }; + + public getMaximumListItemCount () { + return CarPlay.bridge.getMaximumListItemCount(this.id); + } + + public getMaximumListSectionCount () { + return CarPlay.bridge.getMaximumListSectionCount(this.id); + } +} diff --git a/packages/react-native-carplay/src/templates/MapTemplate.ts b/packages/react-native-carplay/src/templates/MapTemplate.ts new file mode 100644 index 00000000..cbfb41ad --- /dev/null +++ b/packages/react-native-carplay/src/templates/MapTemplate.ts @@ -0,0 +1,176 @@ +import { AppRegistry } from 'react-native'; +import { CarPlay } from '../CarPlay'; +import { MapButton } from '../interfaces/MapButton'; +import { NavigationAlert } from '../interfaces/NavigationAlert'; +import { TextConfiguration } from '../interfaces/TextConfiguration'; +import { TimeRemainingColor } from '../interfaces/TimeRemainingColor'; +import { TravelEstimates } from '../interfaces/TravelEstimates'; +import { NavigationSession } from '../navigation/NavigationSession'; +import { Trip } from '../navigation/Trip'; +import { Template, TemplateConfig } from './Template'; + +export interface MapTemplateConfig extends TemplateConfig { + guidanceBackgroundColor?: string; + tripEstimateStyle?: 'dark' | 'light'; + /** + * Your component to render inside CarPlay + * Example `component: MyComponent` + */ + component: React.ComponentType; + /** + * An array of map buttons displayed on the trailing bottom corner of the map template. + * + * If the array contains more than three buttons, the map template displays only the first three buttons, ignoring the remaining buttons. + */ + mapButtons?: MapButton[]; + /** + * A Boolean value that indicates whether the navigation bar hides automatically. + */ + automaticallyHidesNavigationBar?: boolean; + /** + * A Boolean value that tells the system to hide the map buttons when hiding the navigation bar. + */ + hidesButtonsWithNavigationBar?: boolean; + + /** + * Fired when Alert Action button is pressed + * @param e Event + */ + onAlertActionPressed?(e: { secondary?: boolean; primary?: boolean }): void; + onMapButtonPressed?(e: { id: string; template: string }): void; + onPanWithDirection?(e: { direction: string }): void; + onPanBeganWithDirection?(e: { direction: string }): void; + onPanEndedWithDirection?(e: { direction: string }): void; + onSelectedPreviewForTrip?(e: { tripId: string; routeIndex: number }): void; + onDidCancelNavigation?(e: {}): void; + onStartedTrip?(e: { tripId: string; routeIndex: number }): void; +} + +/** + * The Map Template is a control layer that appears as an overlay over the base view and allows you to present user controls. + * + * The control layer consists of a navigation bar and map buttons. By default, the navigation bar appears when the user interacts with the app, and disappears after a period of inactivity. + * + * The navigation bar includes up to two leading buttons and two trailing buttons. You can customize the appearance of these buttons with icons or text. + * + * The control layer may also include up to four map buttons. The map buttons are always shown as icons. + * + * Navigation apps enter panning mode, zoom in or out, and perform other functions by responding to user actions on these buttons. + */ +export class MapTemplate extends Template { + public get type(): string { + return 'map'; + } + + get eventMap() { + return { + alertActionPressed: 'onAlertActionPressed', + mapButtonPressed: 'onMapButtonPressed', + panWithDirection: 'onPanWithDirection', + panBeganWithDirection: 'onPanBeganWithDirection', + panEndedWithDirection: 'onPanEndedWithDirection', + selectedPreviewForTrip: 'onSelectedPreviewForTrip', + didCancelNavigation: 'onDidCancelNavigation', + startedTrip: 'onStartedTrip', + }; + } + + constructor(public config: MapTemplateConfig) { + super(config); + + if (config.component) { + AppRegistry.registerComponent(this.id, () => config.component); + } + + CarPlay.bridge.createTemplate( + this.id, + this.parseConfig({ type: this.type, ...config, render: true }), + ); + } + + /** + * Begins guidance for a trip. + * + * Keep a reference to the navigation session to perform guidance updates. + * @param trip Trip class instance + */ + public async startNavigationSession(trip: Trip): Promise { + const res = await CarPlay.bridge.startNavigationSession(this.id, trip.id); + return new NavigationSession(res.navigationSessionId, trip, this); + } + + public updateTravelEstimates( + trip: Trip, + travelEstimates: TravelEstimates, + timeRemainingColor: TimeRemainingColor = 0, + ) { + if (!travelEstimates.distanceUnits) { + travelEstimates.distanceUnits = 'kilometers'; + } + CarPlay.bridge.updateTravelEstimatesForTrip( + this.id, + trip.id, + travelEstimates, + timeRemainingColor, + ); + } + /** + * Update MapTemplate configuration + */ + public updateConfig(config: MapTemplateConfig) { + CarPlay.bridge.updateMapTemplateConfig(this.id, this.parseConfig(config)); + } + + public updateMapButtons(mapButtons: MapButton[]) { + CarPlay.bridge.updateMapTemplateMapButtons(this.id, this.parseConfig(mapButtons)); + } + + /** + * Hides the display of trip previews. + */ + public hideTripPreviews() { + CarPlay.bridge.hideTripPreviews(this.id); + } + + public showTripPreviews(tripPreviews: Trip[], textConfiguration: TextConfiguration = {}) { + CarPlay.bridge.showTripPreviews( + this.id, + tripPreviews.map(trip => trip.id), + textConfiguration, + ); + } + + public showRouteChoicesPreviewForTrip(trip: Trip, textConfiguration: TextConfiguration = {}) { + CarPlay.bridge.showRouteChoicesPreviewForTrip(this.id, trip.id, textConfiguration); + } + + public presentNavigationAlert(config: NavigationAlert, animated = true) { + CarPlay.bridge.presentNavigationAlert(this.id, config, animated); + } + + public dismissNavigationAlert(animated = true) { + CarPlay.bridge.dismissNavigationAlert(this.id, animated); + } + + /** + * Shows the panning interface over the map. + * + * Calling this method while displaying the panning interface has no effect. + * + * While showing the panning interface, the system hides all map buttons. The system doesn't provide a button to dismiss the panning interface. Instead, you must provide a map button in the navigation bar that the user taps to dismiss the panning interface. + * @param animated A Boolean value that determines whether to animate the panning interface. + */ + public showPanningInterface(animated = false) { + CarPlay.bridge.showPanningInterface(this.id, animated); + } + + /** + * Dismisses the panning interface. + * + * When dismissing the panning interface, the system shows the previously hidden map buttons. + * @param animated A Boolean value that determines whether to animate the dismissal of the panning interface. + */ + public dismissPanningInterface(animated = false) { + CarPlay.bridge.dismissPanningInterface(this.id, animated); + } +} diff --git a/packages/react-native-carplay/src/templates/NowPlayingTemplate.ts b/packages/react-native-carplay/src/templates/NowPlayingTemplate.ts new file mode 100644 index 00000000..002625ca --- /dev/null +++ b/packages/react-native-carplay/src/templates/NowPlayingTemplate.ts @@ -0,0 +1,38 @@ +import { ImageSourcePropType } from 'react-native'; +import { Template, TemplateConfig } from './Template'; + +export type NowPlayingButton = { + id: string; +} & ( + | { + type: 'shuffle' | 'add-to-library' | 'more' | 'playback' | 'repeat'; + } + | { + type: 'image'; + image: ImageSourcePropType; + } +); + +export interface NowPlayingTemplateConfig extends TemplateConfig { + albumArtistButtonEnabled?: boolean; + upNextButtonTitle?: string; + upNextButtonEnabled?: boolean; + onAlbumArtistButtonPressed?(): void; + onUpNextButtonPressed?(): void; + onButtonPressed?(e: { id: string; templateId: string }): void; + buttons?: NowPlayingButton[]; +} + +export class NowPlayingTemplate extends Template { + public get type(): string { + return 'nowplaying'; + } + + get eventMap() { + return { + albumArtistButtonPressed: 'onAlbumArtistButtonPressed', + upNextButtonPressed: 'onUpNextButtonPressed', + buttonPressed: 'onButtonPressed', + }; + } +} diff --git a/packages/react-native-carplay/src/templates/PointOfInterestTemplate.ts b/packages/react-native-carplay/src/templates/PointOfInterestTemplate.ts new file mode 100644 index 00000000..f53ea3b6 --- /dev/null +++ b/packages/react-native-carplay/src/templates/PointOfInterestTemplate.ts @@ -0,0 +1,40 @@ +import { Template, TemplateConfig } from './Template'; + +export interface PointOfInterestItem { + id: string; + location: { + latitude: number; + longitude: number; + }; + title: string; + subtitle?: string; + summary?: string; + detailTitle?: string; + detailSubtitle?: string; + detailSummary?: string; +} + +export interface PointOfInterestTemplateConfig extends TemplateConfig { + title: string; + items: PointOfInterestItem[]; + onPointOfInterestSelect?(e: PointOfInterestItem): void; + onChangeMapRegion(e: { + latitude: number; + longitude: number; + latitudeDelta: number; + longitudeDelta: number; + }): void; +} + +export class PointOfInterestTemplate extends Template { + public get type(): string { + return 'poi'; + } + + get eventMap() { + return { + didSelectPointOfInterest: 'onPointOfInterestSelect', + didChangeMapRegion: 'onChangeMapRegion', + }; + } +} diff --git a/packages/react-native-carplay/src/templates/SearchTemplate.ts b/packages/react-native-carplay/src/templates/SearchTemplate.ts new file mode 100644 index 00000000..a7062fd5 --- /dev/null +++ b/packages/react-native-carplay/src/templates/SearchTemplate.ts @@ -0,0 +1,63 @@ +import { CarPlay } from '../CarPlay'; +import { ListItem } from '../interfaces/ListItem'; +import { BaseEvent, Template, TemplateConfig } from './Template'; +import { Image } from 'react-native'; + +export interface SearchTemplateConfig extends TemplateConfig { + /** + * Fired when search input is changed. + * Must return list of items to show. + * @param query Search query + */ + onSearch?(query: string): Promise; + /** + * Fired when result item is selected. + * Spinner shows by default. + * When the returned promise is resolved the spinner will hide. + * @param item Object with the selected index + */ + onItemSelect?(item: { index: number }): Promise; + /** + * Fired when search button is pressed + */ + onSearchButtonPressed?(e: BaseEvent): void; +} + +export class SearchTemplate extends Template { + public get type(): string { + return 'search'; + } + + get eventMap() { + return { + searchButtonPressed: 'onSearchButtonPressed', + }; + } + + constructor(public config: SearchTemplateConfig) { + // parse out any images in the results + + super(config); + + CarPlay.emitter.addListener('updatedSearchText', e => { + if (config.onSearch && e.templateId === this.id) { + const x = config.onSearch(e.searchText); + + Promise.resolve(x).then((result = []) => { + const parsedResults = result.map(item => ({ + ...item, + image: item.image ? Image.resolveAssetSource(item.image) : undefined, + })); + CarPlay.bridge.reactToUpdatedSearchText(parsedResults); + }); + } + }); + + CarPlay.emitter.addListener('selectedResult', e => { + if (config.onItemSelect && e.templateId === this.id) { + const x = config.onItemSelect(e); + Promise.resolve(x).then(() => CarPlay.bridge.reactToSelectedResult(true)); + } + }); + } +} diff --git a/packages/react-native-carplay/src/templates/TabBarTemplate.ts b/packages/react-native-carplay/src/templates/TabBarTemplate.ts new file mode 100644 index 00000000..58e194d9 --- /dev/null +++ b/packages/react-native-carplay/src/templates/TabBarTemplate.ts @@ -0,0 +1,48 @@ +import { CarPlay } from '../CarPlay'; +import { GridTemplate } from './GridTemplate'; +import { InformationTemplate } from './InformationTemplate'; +import { ListTemplate } from './ListTemplate'; +import { PointOfInterestTemplate } from './PointOfInterestTemplate'; +import { Template, TemplateConfig } from './Template'; + +type TabBarTemplates = ListTemplate | GridTemplate | InformationTemplate | PointOfInterestTemplate; + +export interface TabBarTemplateConfig extends TemplateConfig { + /** + * The title displayed in the navigation bar while the tab bar template is visible. + */ + title?: string; + /** + * The templates to show as tabs. + */ + templates: TabBarTemplates[]; + + onTemplateSelect( + template: TabBarTemplates, + e: { templateId: string; selectedTemplateId: string }, + ): void; +} + +/**/ +export class TabBarTemplate extends Template { + public get type(): string { + return 'tabbar'; + } + + constructor(public config: TabBarTemplateConfig) { + super(config); + + CarPlay.emitter.addListener('didSelectTemplate', e => { + if (config.onTemplateSelect && e.templateId === this.id) { + config.onTemplateSelect( + config.templates.find(tpl => tpl.id === e.selectedTemplateId), + e, + ); + } + }); + } + + public updateTemplates = (config: TabBarTemplateConfig) => { + return CarPlay.bridge.updateTabBarTemplates(this.id, this.parseConfig(config)); + }; +} diff --git a/packages/react-native-carplay/src/templates/Template.ts b/packages/react-native-carplay/src/templates/Template.ts new file mode 100644 index 00000000..0a6ae5c5 --- /dev/null +++ b/packages/react-native-carplay/src/templates/Template.ts @@ -0,0 +1,135 @@ +import { ImageSourcePropType } from 'react-native'; +import { CarPlay } from '../CarPlay'; +import { BarButton } from '../interfaces/BarButton'; + +const resolveAssetSource = require('react-native/Libraries/Image/resolveAssetSource'); + +export interface BaseEvent { + /** + * Template id that fired the event + */ + templateId: string; +} + +interface BarButtonEvent extends BaseEvent { + id: string; +} + +export interface TemplateConfig { + /** + * Give the template your own ID. Must be unique. + */ + id?: string; + /** + * An array of bar buttons to display on the leading side of the navigation bar. + * + * The navigation bar displays up to two buttons in the leading space. When including more than two buttons in the array, the system displays only the first two buttons. + */ + leadingNavigationBarButtons?: BarButton[]; + /** + * An array of bar buttons to display on the trailing side of the navigation bar. + * + * The navigation bar displays up to two buttons in the trailing space. When including more than two buttons in the array, the system displays only the first two buttons. + */ + trailingNavigationBarButtons?: BarButton[]; + /** + * UITabBarSystemItem + */ + tabSystemItem?: number; + /** + * Name of system image for tab + */ + tabSystemImageName?: string; + /** + * Image source for tab + */ + tabImage?: ImageSourcePropType; + /** + * Set tab title + */ + tabTitle?: string; + /** + * Fired before template appears + * @param e Event + */ + onWillAppear?(e: BaseEvent): void; + /** + * Fired before template disappears + * @param e Event + */ + onWillDisappear?(e: BaseEvent): void; + /** + * Fired after template appears + * @param e Event + */ + onDidAppear?(e: BaseEvent): void; + /** + * Fired after template disappears + * @param e Event + */ + onDidDisappear?(e: BaseEvent): void; + + /** + * Fired when bar button is pressed + * @param e Event + */ + onBarButtonPressed?(e: BarButtonEvent): void; +} + +export class Template

{ + public get type(): string { + return 'unset'; + } + public id: string; + + public get eventMap() { + return {}; + } + + constructor(public config: TemplateConfig & P) { + if (config.id) { + this.id = config.id; + } + + if (!this.id) { + this.id = `${this.type}-${Date.now()}-${Math.round(Math.random() * Number.MAX_SAFE_INTEGER)}`; + } + + const eventMap = { + barButtonPressed: 'onBarButtonPressed', + didAppear: 'onDidAppear', + didDisappear: 'onDidDisappear', + willAppear: 'onWillAppear', + willDisappear: 'onWillDisappear', + ...(this.eventMap || {}), + }; + + Object.entries(eventMap).forEach(([eventName, callbackName]: any) => { + CarPlay.emitter.addListener(eventName, e => { + if (config[callbackName] && e.templateId === this.id) { + config[callbackName](e); + } + }); + }); + + if (this.type !== 'map') { + CarPlay.bridge.createTemplate(this.id, this.parseConfig({ type: this.type, ...config })); + } + } + + public parseConfig(config: any) { + function traverse(obj: any) { + for (const i in obj) { + if (obj[i] !== null && typeof obj[i] === 'object') { + traverse(obj[i]); + } + if (String(i).match(/[Ii]mage$/)) { + obj[i] = resolveAssetSource(obj[i]); + } + } + } + const result = JSON.parse(JSON.stringify(config)); + traverse(result); + return result; + } +} diff --git a/packages/react-native-carplay/src/templates/VoiceControlTemplate.ts b/packages/react-native-carplay/src/templates/VoiceControlTemplate.ts new file mode 100644 index 00000000..cad5b598 --- /dev/null +++ b/packages/react-native-carplay/src/templates/VoiceControlTemplate.ts @@ -0,0 +1,25 @@ +import { CarPlay } from '../CarPlay'; +import { VoiceControlState } from '../interfaces/VoiceControlState'; +import { Template } from './Template'; + +export interface VoiceControlTemplateConfig { + /** + * The array of voice control states that can be used by your voice control template. + */ + voiceControlStates: VoiceControlState[]; +} + +/** + * Displays a voice control indicator on the CarPlay screen. + * + * CarPlay navigation apps must show the voice control template during audio input. + */ +export class VoiceControlTemplate extends Template { + public get type(): string { + return 'voicecontrol'; + } + + public activateVoiceControlState(identifier: string) { + CarPlay.bridge.activateVoiceControlState(this.id, identifier); + } +} diff --git a/packages/react-native-carplay/tsconfig.json b/packages/react-native-carplay/tsconfig.json new file mode 100644 index 00000000..b9d9e093 --- /dev/null +++ b/packages/react-native-carplay/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@tsconfig/react-native/tsconfig.json", + "compilerOptions": { + "baseUrl": "./src" + }, + "exclude": ["**/node_modules", "**/.*/", "lib", "build"] +}