feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import <UIKit/UIKit.h>
@interface NSTextStorage (FontScaling)
- (void)scaleFontSizeToFitSize:(CGSize)size
minimumFontSize:(CGFloat)minimumFontSize
maximumFontSize:(CGFloat)maximumFontSize;
- (void)scaleFontSizeWithRatio:(CGFloat)ratio
minimumFontSize:(CGFloat)minimumFontSize
maximumFontSize:(CGFloat)maximumFontSize;
@end

View File

@@ -0,0 +1,117 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import "NSTextStorage+FontScaling.h"
typedef NS_OPTIONS(NSInteger, RCTTextSizeComparisonOptions) {
RCTTextSizeComparisonSmaller = 1 << 0,
RCTTextSizeComparisonLarger = 1 << 1,
RCTTextSizeComparisonWithinRange = 1 << 2,
};
@implementation NSTextStorage (FontScaling)
- (void)scaleFontSizeToFitSize:(CGSize)size
minimumFontSize:(CGFloat)minimumFontSize
maximumFontSize:(CGFloat)maximumFontSize
{
CGFloat bottomRatio = 1.0 / 128.0;
CGFloat topRatio = 128.0;
CGFloat ratio = 1.0;
NSAttributedString *originalAttributedString = [self copy];
CGFloat lastRatioWhichFits = 0.02;
while (true) {
[self scaleFontSizeWithRatio:ratio minimumFontSize:minimumFontSize maximumFontSize:maximumFontSize];
RCTTextSizeComparisonOptions comparison = [self compareToSize:size thresholdRatio:0.01];
if ((comparison & RCTTextSizeComparisonWithinRange) && (comparison & RCTTextSizeComparisonSmaller)) {
return;
} else if (comparison & RCTTextSizeComparisonSmaller) {
bottomRatio = ratio;
lastRatioWhichFits = ratio;
} else {
topRatio = ratio;
}
ratio = (topRatio + bottomRatio) / 2.0;
CGFloat kRatioThreshold = 0.005;
if (ABS(topRatio - bottomRatio) < kRatioThreshold || ABS(topRatio - ratio) < kRatioThreshold ||
ABS(bottomRatio - ratio) < kRatioThreshold) {
[self replaceCharactersInRange:(NSRange){0, self.length} withAttributedString:originalAttributedString];
[self scaleFontSizeWithRatio:lastRatioWhichFits minimumFontSize:minimumFontSize maximumFontSize:maximumFontSize];
return;
}
[self replaceCharactersInRange:(NSRange){0, self.length} withAttributedString:originalAttributedString];
}
}
- (RCTTextSizeComparisonOptions)compareToSize:(CGSize)size thresholdRatio:(CGFloat)thresholdRatio
{
NSLayoutManager *layoutManager = self.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
[layoutManager ensureLayoutForTextContainer:textContainer];
// Does it fit the text container?
NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
NSRange truncatedGlyphRange = [layoutManager truncatedGlyphRangeInLineFragmentForGlyphAtIndex:glyphRange.length - 1];
if (truncatedGlyphRange.location != NSNotFound) {
return RCTTextSizeComparisonLarger;
}
CGSize measuredSize = [layoutManager usedRectForTextContainer:textContainer].size;
// Does it fit the size?
BOOL fitsSize = size.width >= measuredSize.width && size.height >= measuredSize.height;
CGSize thresholdSize = (CGSize){
size.width * thresholdRatio,
size.height * thresholdRatio,
};
RCTTextSizeComparisonOptions result = 0;
result |= (fitsSize) ? RCTTextSizeComparisonSmaller : RCTTextSizeComparisonLarger;
if (ABS(measuredSize.width - size.width) < thresholdSize.width) {
result = result | RCTTextSizeComparisonWithinRange;
}
return result;
}
- (void)scaleFontSizeWithRatio:(CGFloat)ratio
minimumFontSize:(CGFloat)minimumFontSize
maximumFontSize:(CGFloat)maximumFontSize
{
[self beginEditing];
[self enumerateAttribute:NSFontAttributeName
inRange:(NSRange){0, self.length}
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(UIFont *_Nullable font, NSRange range, BOOL *_Nonnull stop) {
if (!font) {
return;
}
CGFloat fontSize = MAX(MIN(font.pointSize * ratio, maximumFontSize), minimumFontSize);
[self addAttribute:NSFontAttributeName value:[font fontWithSize:fontSize] range:range];
}];
[self endEditing];
}
@end

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import <Foundation/Foundation.h>
#import <React/RCTConvert.h>
typedef NS_ENUM(NSInteger, RCTDynamicTypeRamp) {
RCTDynamicTypeRampUndefined,
RCTDynamicTypeRampCaption2,
RCTDynamicTypeRampCaption1,
RCTDynamicTypeRampFootnote,
RCTDynamicTypeRampSubheadline,
RCTDynamicTypeRampCallout,
RCTDynamicTypeRampBody,
RCTDynamicTypeRampHeadline,
RCTDynamicTypeRampTitle3,
RCTDynamicTypeRampTitle2,
RCTDynamicTypeRampTitle1,
RCTDynamicTypeRampLargeTitle
};
@interface RCTConvert (DynamicTypeRamp)
+ (RCTDynamicTypeRamp)RCTDynamicTypeRamp:(nullable id)json;
@end
/// Generates a `UIFontMetrics` instance representing a particular Dynamic Type ramp.
UIFontMetrics *_Nonnull RCTUIFontMetricsForDynamicTypeRamp(RCTDynamicTypeRamp dynamicTypeRamp);
/// The "reference" size for a particular font scale ramp, equal to a text element's size under default text size
/// settings.
CGFloat RCTBaseSizeForDynamicTypeRamp(RCTDynamicTypeRamp dynamicTypeRamp);

View File

@@ -0,0 +1,82 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import <React/RCTDynamicTypeRamp.h>
@implementation RCTConvert (DynamicTypeRamp)
RCT_ENUM_CONVERTER(
RCTDynamicTypeRamp,
(@{
@"caption2" : @(RCTDynamicTypeRampCaption2),
@"caption1" : @(RCTDynamicTypeRampCaption1),
@"footnote" : @(RCTDynamicTypeRampFootnote),
@"subheadline" : @(RCTDynamicTypeRampSubheadline),
@"callout" : @(RCTDynamicTypeRampCallout),
@"body" : @(RCTDynamicTypeRampBody),
@"headline" : @(RCTDynamicTypeRampHeadline),
@"title3" : @(RCTDynamicTypeRampTitle3),
@"title2" : @(RCTDynamicTypeRampTitle2),
@"title1" : @(RCTDynamicTypeRampTitle1),
@"largeTitle" : @(RCTDynamicTypeRampLargeTitle),
}),
RCTDynamicTypeRampUndefined,
integerValue)
@end
UIFontMetrics *RCTUIFontMetricsForDynamicTypeRamp(RCTDynamicTypeRamp dynamicTypeRamp)
{
static NSDictionary<NSNumber *, UIFontTextStyle> *mapping;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
mapping = @{
@(RCTDynamicTypeRampCaption2) : UIFontTextStyleCaption2,
@(RCTDynamicTypeRampCaption1) : UIFontTextStyleCaption1,
@(RCTDynamicTypeRampFootnote) : UIFontTextStyleFootnote,
@(RCTDynamicTypeRampSubheadline) : UIFontTextStyleSubheadline,
@(RCTDynamicTypeRampCallout) : UIFontTextStyleCallout,
@(RCTDynamicTypeRampBody) : UIFontTextStyleBody,
@(RCTDynamicTypeRampHeadline) : UIFontTextStyleHeadline,
@(RCTDynamicTypeRampTitle3) : UIFontTextStyleTitle3,
@(RCTDynamicTypeRampTitle2) : UIFontTextStyleTitle2,
@(RCTDynamicTypeRampTitle1) : UIFontTextStyleTitle1,
@(RCTDynamicTypeRampLargeTitle) : UIFontTextStyleLargeTitle,
};
});
id textStyle =
mapping[@(dynamicTypeRamp)] ?: UIFontTextStyleBody; // Default to body if we don't recognize the specified ramp
return [UIFontMetrics metricsForTextStyle:textStyle];
}
CGFloat RCTBaseSizeForDynamicTypeRamp(RCTDynamicTypeRamp dynamicTypeRamp)
{
static NSDictionary<NSNumber *, NSNumber *> *mapping;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// Values taken from
// https://developer.apple.com/design/human-interface-guidelines/foundations/typography/#specifications
mapping = @{
@(RCTDynamicTypeRampCaption2) : @11,
@(RCTDynamicTypeRampCaption1) : @12,
@(RCTDynamicTypeRampFootnote) : @13,
@(RCTDynamicTypeRampSubheadline) : @15,
@(RCTDynamicTypeRampCallout) : @16,
@(RCTDynamicTypeRampBody) : @17,
@(RCTDynamicTypeRampHeadline) : @17,
@(RCTDynamicTypeRampTitle3) : @20,
@(RCTDynamicTypeRampTitle2) : @22,
@(RCTDynamicTypeRampTitle1) : @28,
@(RCTDynamicTypeRampLargeTitle) : @34,
};
});
NSNumber *baseSize =
mapping[@(dynamicTypeRamp)] ?: @17; // Default to body size if we don't recognize the specified ramp
return CGFLOAT_IS_DOUBLE ? [baseSize doubleValue] : [baseSize floatValue];
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import <React/RCTShadowView.h>
#import "RCTBaseTextShadowView.h"
NS_ASSUME_NONNULL_BEGIN
@interface RCTTextShadowView : RCTBaseTextShadowView
- (instancetype)initWithBridge:(RCTBridge *)bridge;
@property (nonatomic, assign) NSInteger maximumNumberOfLines;
@property (nonatomic, assign) NSLineBreakMode lineBreakMode;
@property (nonatomic, assign) BOOL adjustsFontSizeToFit;
@property (nonatomic, assign) CGFloat minimumFontScale;
@property (nonatomic, copy) RCTDirectEventBlock onTextLayout;
- (void)uiManagerWillPerformMounting;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,420 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import <React/RCTTextShadowView.h>
#import <React/RCTBridge.h>
#import <React/RCTShadowView+Layout.h>
#import <React/RCTUIManager.h>
#import <yoga/Yoga.h>
#import <React/RCTTextView.h>
#import "NSTextStorage+FontScaling.h"
@implementation RCTTextShadowView {
__weak RCTBridge *_bridge;
BOOL _needsUpdateView;
NSMapTable<id, NSTextStorage *> *_cachedTextStorages;
}
- (instancetype)initWithBridge:(RCTBridge *)bridge
{
if (self = [super init]) {
_bridge = bridge;
_cachedTextStorages = [NSMapTable strongToStrongObjectsMapTable];
_needsUpdateView = YES;
YGNodeSetMeasureFunc(self.yogaNode, RCTTextShadowViewMeasure);
YGNodeSetBaselineFunc(self.yogaNode, RCTTextShadowViewBaseline);
}
return self;
}
- (void)didSetProps:(NSArray<NSString *> *)changedProps
{
[super didSetProps:changedProps];
// When applying a semi-transparent background color to Text component
// we must set the root text nodes text attribute background color to nil
// because the background color is drawn on the RCTTextView itself, as well
// as on the glphy background draw step. By setting this to nil, we allow
// the RCTTextView backgroundColor to be used, without affecting nested Text
// components.
self.textAttributes.backgroundColor = nil;
self.textAttributes.opacity = NAN;
}
- (BOOL)isYogaLeafNode
{
return YES;
}
- (void)dirtyLayout
{
[super dirtyLayout];
YGNodeMarkDirty(self.yogaNode);
[self invalidateCache];
}
- (void)invalidateCache
{
[_cachedTextStorages removeAllObjects];
_needsUpdateView = YES;
}
#pragma mark - RCTUIManagerObserver
- (void)uiManagerWillPerformMounting
{
if (YGNodeIsDirty(self.yogaNode)) {
return;
}
if (!_needsUpdateView) {
return;
}
_needsUpdateView = NO;
CGRect contentFrame = self.contentFrame;
NSTextStorage *textStorage = [self textStorageAndLayoutManagerThatFitsSize:self.contentFrame.size
exclusiveOwnership:YES];
NSNumber *tag = self.reactTag;
NSMutableArray<NSNumber *> *descendantViewTags = [NSMutableArray new];
[textStorage enumerateAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
inRange:NSMakeRange(0, textStorage.length)
options:0
usingBlock:^(RCTShadowView *shadowView, NSRange range, __unused BOOL *stop) {
if (!shadowView) {
return;
}
[descendantViewTags addObject:shadowView.reactTag];
}];
[_bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
RCTTextView *textView = (RCTTextView *)viewRegistry[tag];
if (!textView) {
return;
}
NSMutableArray<UIView *> *descendantViews = [NSMutableArray arrayWithCapacity:descendantViewTags.count];
[descendantViewTags
enumerateObjectsUsingBlock:^(NSNumber *_Nonnull descendantViewTag, NSUInteger index, BOOL *_Nonnull stop) {
UIView *descendantView = viewRegistry[descendantViewTag];
if (!descendantView) {
return;
}
[descendantViews addObject:descendantView];
}];
// Removing all references to Shadow Views to avoid unnecessary retaining.
[textStorage removeAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
range:NSMakeRange(0, textStorage.length)];
[textView setTextStorage:textStorage contentFrame:contentFrame descendantViews:descendantViews];
}];
}
- (void)postprocessAttributedText:(NSMutableAttributedString *)attributedText
{
__block CGFloat maximumLineHeight = 0;
[attributedText enumerateAttribute:NSParagraphStyleAttributeName
inRange:NSMakeRange(0, attributedText.length)
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(NSParagraphStyle *paragraphStyle, __unused NSRange range, __unused BOOL *stop) {
if (!paragraphStyle) {
return;
}
maximumLineHeight = MAX(paragraphStyle.maximumLineHeight, maximumLineHeight);
}];
if (maximumLineHeight == 0) {
// `lineHeight` was not specified, nothing to do.
return;
}
__block CGFloat maximumFontLineHeight = 0;
[attributedText enumerateAttribute:NSFontAttributeName
inRange:NSMakeRange(0, attributedText.length)
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) {
if (!font) {
return;
}
if (maximumFontLineHeight <= font.lineHeight) {
maximumFontLineHeight = font.lineHeight;
}
}];
if (maximumLineHeight < maximumFontLineHeight) {
return;
}
CGFloat baseLineOffset = maximumLineHeight / 2.0 - maximumFontLineHeight / 2.0;
[attributedText addAttribute:NSBaselineOffsetAttributeName
value:@(baseLineOffset)
range:NSMakeRange(0, attributedText.length)];
}
- (NSAttributedString *)attributedTextWithMeasuredAttachmentsThatFitSize:(CGSize)size
{
static UIImage *placeholderImage;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
placeholderImage = [UIImage new];
});
NSMutableAttributedString *attributedText =
[[NSMutableAttributedString alloc] initWithAttributedString:[self attributedTextWithBaseTextAttributes:nil]];
[attributedText beginEditing];
[attributedText enumerateAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
inRange:NSMakeRange(0, attributedText.length)
options:0
usingBlock:^(RCTShadowView *shadowView, NSRange range, __unused BOOL *stop) {
if (!shadowView) {
return;
}
CGSize fittingSize = [shadowView sizeThatFitsMinimumSize:CGSizeZero maximumSize:size];
NSTextAttachment *attachment = [NSTextAttachment new];
attachment.bounds = (CGRect){CGPointZero, fittingSize};
attachment.image = placeholderImage;
[attributedText addAttribute:NSAttachmentAttributeName value:attachment range:range];
}];
[attributedText endEditing];
return [attributedText copy];
}
- (NSTextStorage *)textStorageAndLayoutManagerThatFitsSize:(CGSize)size exclusiveOwnership:(BOOL)exclusiveOwnership
{
NSValue *key = [NSValue valueWithCGSize:size];
NSTextStorage *cachedTextStorage = [_cachedTextStorages objectForKey:key];
if (cachedTextStorage) {
if (exclusiveOwnership) {
[_cachedTextStorages removeObjectForKey:key];
}
return cachedTextStorage;
}
NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:size];
textContainer.lineFragmentPadding = 0.0; // Note, the default value is 5.
textContainer.lineBreakMode = _maximumNumberOfLines > 0 ? _lineBreakMode : NSLineBreakByClipping;
textContainer.maximumNumberOfLines = _maximumNumberOfLines;
NSLayoutManager *layoutManager = [NSLayoutManager new];
layoutManager.usesFontLeading = NO;
[layoutManager addTextContainer:textContainer];
NSTextStorage *textStorage =
[[NSTextStorage alloc] initWithAttributedString:[self attributedTextWithMeasuredAttachmentsThatFitSize:size]];
[self postprocessAttributedText:textStorage];
[textStorage addLayoutManager:layoutManager];
if (_adjustsFontSizeToFit) {
CGFloat minimumFontSize = MAX(_minimumFontScale * (self.textAttributes.effectiveFont.pointSize), 4.0);
[textStorage scaleFontSizeToFitSize:size
minimumFontSize:minimumFontSize
maximumFontSize:self.textAttributes.effectiveFont.pointSize];
}
if (!exclusiveOwnership) {
[_cachedTextStorages setObject:textStorage forKey:key];
}
return textStorage;
}
- (void)layoutWithMetrics:(RCTLayoutMetrics)layoutMetrics layoutContext:(RCTLayoutContext)layoutContext
{
// If the view got new `contentFrame`, we have to redraw it because
// and sizes of embedded views may change.
if (!CGRectEqualToRect(self.layoutMetrics.contentFrame, layoutMetrics.contentFrame)) {
_needsUpdateView = YES;
}
if (self.textAttributes.layoutDirection != layoutMetrics.layoutDirection) {
self.textAttributes.layoutDirection = layoutMetrics.layoutDirection;
[self invalidateCache];
}
[super layoutWithMetrics:layoutMetrics layoutContext:layoutContext];
}
- (void)layoutSubviewsWithContext:(RCTLayoutContext)layoutContext
{
NSTextStorage *textStorage = [self textStorageAndLayoutManagerThatFitsSize:self.availableSize exclusiveOwnership:NO];
NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL];
[textStorage
enumerateAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
inRange:characterRange
options:0
usingBlock:^(RCTShadowView *shadowView, NSRange range, BOOL *stop) {
if (!shadowView) {
return;
}
CGRect glyphRect = [layoutManager boundingRectForGlyphRange:range inTextContainer:textContainer];
NSTextAttachment *attachment = [textStorage attribute:NSAttachmentAttributeName
atIndex:range.location
effectiveRange:nil];
CGSize attachmentSize = attachment.bounds.size;
UIFont *font = [textStorage attribute:NSFontAttributeName atIndex:range.location effectiveRange:nil];
CGRect frame = {
{RCTRoundPixelValue(glyphRect.origin.x),
RCTRoundPixelValue(
glyphRect.origin.y + glyphRect.size.height - attachmentSize.height + font.descender)},
{RCTRoundPixelValue(attachmentSize.width), RCTRoundPixelValue(attachmentSize.height)}};
NSRange truncatedGlyphRange =
[layoutManager truncatedGlyphRangeInLineFragmentForGlyphAtIndex:range.location];
BOOL viewIsTruncated = NSIntersectionRange(range, truncatedGlyphRange).length != 0;
RCTLayoutContext localLayoutContext = layoutContext;
localLayoutContext.absolutePosition.x += frame.origin.x;
localLayoutContext.absolutePosition.y += frame.origin.y;
[shadowView layoutWithMinimumSize:{shadowView.minWidth.value, shadowView.minHeight.value}
maximumSize:frame.size
layoutDirection:self.layoutMetrics.layoutDirection
layoutContext:localLayoutContext];
RCTLayoutMetrics localLayoutMetrics = shadowView.layoutMetrics;
localLayoutMetrics.frame.origin =
frame.origin; // Reinforcing a proper frame origin for the Shadow View.
if (viewIsTruncated) {
localLayoutMetrics.displayType = RCTDisplayTypeNone;
}
[shadowView layoutWithMetrics:localLayoutMetrics layoutContext:localLayoutContext];
}];
if (_onTextLayout) {
NSMutableArray *lineData = [NSMutableArray new];
[layoutManager enumerateLineFragmentsForGlyphRange:glyphRange
usingBlock:^(
CGRect overallRect,
CGRect usedRect,
NSTextContainer *_Nonnull usedTextContainer,
NSRange lineGlyphRange,
BOOL *_Nonnull stop) {
NSRange range = [layoutManager characterRangeForGlyphRange:lineGlyphRange
actualGlyphRange:nil];
NSString *renderedString = [textStorage.string substringWithRange:range];
UIFont *font = [[textStorage attributedSubstringFromRange:range]
attribute:NSFontAttributeName
atIndex:0
effectiveRange:nil];
[lineData addObject:@{
@"text" : renderedString,
@"x" : @(usedRect.origin.x),
@"y" : @(usedRect.origin.y),
@"width" : @(usedRect.size.width),
@"height" : @(usedRect.size.height),
@"descender" : @(-font.descender),
@"capHeight" : @(font.capHeight),
@"ascender" : @(font.ascender),
@"xHeight" : @(font.xHeight),
}];
}];
NSDictionary *payload = @{
@"lines" : lineData,
};
_onTextLayout(payload);
}
}
- (CGFloat)lastBaselineForSize:(CGSize)size
{
NSAttributedString *attributedText = [self textStorageAndLayoutManagerThatFitsSize:size exclusiveOwnership:NO];
__block CGFloat maximumDescender = 0.0;
[attributedText enumerateAttribute:NSFontAttributeName
inRange:NSMakeRange(0, attributedText.length)
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) {
if (maximumDescender > font.descender) {
maximumDescender = font.descender;
}
}];
return size.height + maximumDescender;
}
static YGSize RCTTextShadowViewMeasure(
YGNodeConstRef node,
float width,
YGMeasureMode widthMode,
float height,
YGMeasureMode heightMode)
{
CGSize maximumSize = (CGSize){
widthMode == YGMeasureModeUndefined ? CGFLOAT_MAX : RCTCoreGraphicsFloatFromYogaFloat(width),
heightMode == YGMeasureModeUndefined ? CGFLOAT_MAX : RCTCoreGraphicsFloatFromYogaFloat(height),
};
RCTTextShadowView *shadowTextView = (__bridge RCTTextShadowView *)YGNodeGetContext(node);
NSTextStorage *textStorage = [shadowTextView textStorageAndLayoutManagerThatFitsSize:maximumSize
exclusiveOwnership:NO];
NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
[layoutManager ensureLayoutForTextContainer:textContainer];
CGSize size = [layoutManager usedRectForTextContainer:textContainer].size;
CGFloat letterSpacing = shadowTextView.textAttributes.letterSpacing;
if (!isnan(letterSpacing) && letterSpacing < 0) {
size.width -= letterSpacing;
}
size = (CGSize){
MIN(RCTCeilPixelValue(size.width), maximumSize.width), MIN(RCTCeilPixelValue(size.height), maximumSize.height)};
// Adding epsilon value illuminates problems with converting values from
// `double` to `float`, and then rounding them to pixel grid in Yoga.
CGFloat epsilon = 0.001;
return (YGSize){
RCTYogaFloatFromCoreGraphicsFloat(size.width + epsilon),
RCTYogaFloatFromCoreGraphicsFloat(size.height + epsilon)};
}
static float RCTTextShadowViewBaseline(YGNodeConstRef node, const float width, const float height)
{
RCTTextShadowView *shadowTextView = (__bridge RCTTextShadowView *)YGNodeGetContext(node);
CGSize size = (CGSize){RCTCoreGraphicsFloatFromYogaFloat(width), RCTCoreGraphicsFloatFromYogaFloat(height)};
CGFloat lastBaseline = [shadowTextView lastBaselineForSize:size];
return RCTYogaFloatFromCoreGraphicsFloat(lastBaseline);
}
@end

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import <React/RCTComponent.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface RCTTextView : UIView
@property (nonatomic, assign) BOOL selectable;
- (void)setTextStorage:(NSTextStorage *)textStorage
contentFrame:(CGRect)contentFrame
descendantViews:(NSArray<UIView *> *)descendantViews;
/**
* (Experimental and unused for Paper) Pointer event handlers.
*/
@property (nonatomic, assign) RCTBubblingEventBlock onClick;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,294 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import <React/RCTTextView.h>
#import <MobileCoreServices/UTCoreTypes.h>
#import <React/RCTUtils.h>
#import <React/UIView+React.h>
#import <React/RCTTextShadowView.h>
#import <QuartzCore/QuartzCore.h>
@interface RCTTextView () <UIEditMenuInteractionDelegate>
@property (nonatomic, nullable) UIEditMenuInteraction *editMenuInteraction API_AVAILABLE(ios(16.0));
@end
@implementation RCTTextView {
CAShapeLayer *_highlightLayer;
UILongPressGestureRecognizer *_longPressGestureRecognizer;
NSArray<UIView *> *_Nullable _descendantViews;
NSTextStorage *_Nullable _textStorage;
CGRect _contentFrame;
}
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
self.isAccessibilityElement = YES;
self.accessibilityTraits |= UIAccessibilityTraitStaticText;
self.opaque = NO;
self.contentMode = UIViewContentModeRedraw;
}
return self;
}
- (NSString *)description
{
NSString *stringToAppend = [NSString stringWithFormat:@" reactTag: %@; text: %@", self.reactTag, _textStorage.string];
return [[super description] stringByAppendingString:stringToAppend];
}
- (void)setSelectable:(BOOL)selectable
{
if (_selectable == selectable) {
return;
}
_selectable = selectable;
if (_selectable) {
[self enableContextMenu];
} else {
[self disableContextMenu];
}
}
- (void)reactSetFrame:(CGRect)frame
{
// Text looks super weird if its frame is animated.
// This disables the frame animation, without affecting opacity, etc.
[UIView performWithoutAnimation:^{
[super reactSetFrame:frame];
}];
}
- (void)didUpdateReactSubviews
{
// Do nothing, as subviews are managed by `setTextStorage:` method
}
- (void)setTextStorage:(NSTextStorage *)textStorage
contentFrame:(CGRect)contentFrame
descendantViews:(NSArray<UIView *> *)descendantViews
{
_textStorage = textStorage;
_contentFrame = contentFrame;
// FIXME: Optimize this.
for (UIView *view in _descendantViews) {
[view removeFromSuperview];
}
_descendantViews = descendantViews;
for (UIView *view in descendantViews) {
[self addSubview:view];
}
[self setNeedsDisplay];
}
- (void)drawRect:(CGRect)rect
{
[super drawRect:rect];
if (!_textStorage) {
return;
}
NSLayoutManager *layoutManager = _textStorage.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
#if TARGET_OS_MACCATALYST
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSaveGState(context);
// NSLayoutManager tries to draw text with sub-pixel anti-aliasing by default on
// macOS, but rendering SPAA onto a transparent background produces poor results.
// CATextLayer disables font smoothing by default now on macOS; we follow suit.
CGContextSetShouldSmoothFonts(context, NO);
#endif
NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
[layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:_contentFrame.origin];
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:_contentFrame.origin];
__block UIBezierPath *highlightPath = nil;
NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL];
[_textStorage
enumerateAttribute:RCTTextAttributesIsHighlightedAttributeName
inRange:characterRange
options:0
usingBlock:^(NSNumber *value, NSRange range, __unused BOOL *stop) {
if (!value.boolValue) {
return;
}
[layoutManager
enumerateEnclosingRectsForGlyphRange:range
withinSelectedGlyphRange:range
inTextContainer:textContainer
usingBlock:^(CGRect enclosingRect, __unused BOOL *anotherStop) {
UIBezierPath *path = [UIBezierPath
bezierPathWithRoundedRect:CGRectInset(enclosingRect, -2, -2)
cornerRadius:2];
if (highlightPath) {
[highlightPath appendPath:path];
} else {
highlightPath = path;
}
}];
}];
if (highlightPath) {
if (!_highlightLayer) {
_highlightLayer = [CAShapeLayer layer];
_highlightLayer.fillColor = [UIColor colorWithWhite:0 alpha:0.25].CGColor;
[self.layer addSublayer:_highlightLayer];
}
_highlightLayer.position = _contentFrame.origin;
_highlightLayer.path = highlightPath.CGPath;
} else {
[_highlightLayer removeFromSuperlayer];
_highlightLayer = nil;
}
#if TARGET_OS_MACCATALYST
CGContextRestoreGState(context);
#endif
}
- (NSNumber *)reactTagAtPoint:(CGPoint)point
{
NSNumber *reactTag = self.reactTag;
CGFloat fraction;
NSLayoutManager *layoutManager = _textStorage.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
NSUInteger characterIndex = [layoutManager characterIndexForPoint:point
inTextContainer:textContainer
fractionOfDistanceBetweenInsertionPoints:&fraction];
// If the point is not before (fraction == 0.0) the first character and not
// after (fraction == 1.0) the last character, then the attribute is valid.
if (_textStorage.length > 0 && (fraction > 0 || characterIndex > 0) &&
(fraction < 1 || characterIndex < _textStorage.length - 1)) {
reactTag = [_textStorage attribute:RCTTextAttributesTagAttributeName atIndex:characterIndex effectiveRange:NULL];
}
return reactTag;
}
- (void)didMoveToWindow
{
[super didMoveToWindow];
if (!self.window) {
self.layer.contents = nil;
if (_highlightLayer) {
[_highlightLayer removeFromSuperlayer];
_highlightLayer = nil;
}
} else if (_textStorage) {
[self setNeedsDisplay];
}
}
#pragma mark - Accessibility
- (NSString *)accessibilityLabel
{
NSString *superAccessibilityLabel = [super accessibilityLabel];
if (superAccessibilityLabel) {
return superAccessibilityLabel;
}
return _textStorage.string;
}
#pragma mark - Context Menu
- (void)enableContextMenu
{
_longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
action:@selector(handleLongPress:)];
if (@available(iOS 16.0, *)) {
_editMenuInteraction = [[UIEditMenuInteraction alloc] initWithDelegate:self];
[self addInteraction:_editMenuInteraction];
}
[self addGestureRecognizer:_longPressGestureRecognizer];
}
- (void)disableContextMenu
{
[self removeGestureRecognizer:_longPressGestureRecognizer];
if (@available(iOS 16.0, *)) {
[self removeInteraction:_editMenuInteraction];
_editMenuInteraction = nil;
}
_longPressGestureRecognizer = nil;
}
- (void)handleLongPress:(UILongPressGestureRecognizer *)gesture
{
if (@available(iOS 16.0, macCatalyst 16.0, *)) {
CGPoint location = [gesture locationInView:self];
UIEditMenuConfiguration *config = [UIEditMenuConfiguration configurationWithIdentifier:nil sourcePoint:location];
if (_editMenuInteraction) {
[_editMenuInteraction presentEditMenuWithConfiguration:config];
}
} else {
UIMenuController *menuController = [UIMenuController sharedMenuController];
if (menuController.isMenuVisible) {
return;
}
[menuController showMenuFromView:self rect:self.bounds];
}
}
- (BOOL)canBecomeFirstResponder
{
return _selectable;
}
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
if (_selectable && action == @selector(copy:)) {
return YES;
}
return [self.nextResponder canPerformAction:action withSender:sender];
}
- (void)copy:(id)sender
{
NSAttributedString *attributedText = _textStorage;
NSMutableDictionary *item = [NSMutableDictionary new];
NSData *rtf = [attributedText dataFromRange:NSMakeRange(0, attributedText.length)
documentAttributes:@{NSDocumentTypeDocumentAttribute : NSRTFDTextDocumentType}
error:nil];
if (rtf) {
[item setObject:rtf forKey:(id)kUTTypeFlatRTFD];
}
[item setObject:attributedText.string forKey:(id)kUTTypeUTF8PlainText];
UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
pasteboard.items = @[ item ];
}
@end

View File

@@ -0,0 +1,14 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import <React/RCTViewManager.h>
#import "RCTBaseTextViewManager.h"
@interface RCTTextViewManager : RCTBaseTextViewManager
@end

View File

@@ -0,0 +1,93 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import <React/RCTTextViewManager.h>
#import <React/RCTShadowView+Layout.h>
#import <React/RCTShadowView.h>
#import <React/RCTUIManager.h>
#import <React/RCTUIManagerObserverCoordinator.h>
#import <React/RCTUIManagerUtils.h>
#import <React/RCTTextShadowView.h>
#import <React/RCTTextView.h>
@interface RCTTextViewManager () <RCTUIManagerObserver>
@end
@implementation RCTTextViewManager {
NSHashTable<RCTTextShadowView *> *_shadowViews;
}
RCT_EXPORT_MODULE(RCTText)
RCT_REMAP_SHADOW_PROPERTY(numberOfLines, maximumNumberOfLines, NSInteger)
RCT_REMAP_SHADOW_PROPERTY(ellipsizeMode, lineBreakMode, NSLineBreakMode)
RCT_REMAP_SHADOW_PROPERTY(adjustsFontSizeToFit, adjustsFontSizeToFit, BOOL)
RCT_REMAP_SHADOW_PROPERTY(minimumFontScale, minimumFontScale, CGFloat)
RCT_EXPORT_SHADOW_PROPERTY(onTextLayout, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(selectable, BOOL)
- (void)setBridge:(RCTBridge *)bridge
{
[super setBridge:bridge];
_shadowViews = [NSHashTable weakObjectsHashTable];
[bridge.uiManager.observerCoordinator addObserver:self];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleDidUpdateMultiplierNotification)
name:@"RCTAccessibilityManagerDidUpdateMultiplierNotification"
object:[bridge moduleForName:@"AccessibilityManager"
lazilyLoadIfNecessary:YES]];
}
- (UIView *)view
{
return [RCTTextView new];
}
- (RCTShadowView *)shadowView
{
RCTTextShadowView *shadowView = [[RCTTextShadowView alloc] initWithBridge:self.bridge];
shadowView.textAttributes.fontSizeMultiplier =
[[[self.bridge moduleForName:@"AccessibilityManager"] valueForKey:@"multiplier"] floatValue];
[_shadowViews addObject:shadowView];
return shadowView;
}
#pragma mark - RCTUIManagerObserver
- (void)uiManagerWillPerformMounting:(__unused RCTUIManager *)uiManager
{
for (RCTTextShadowView *shadowView in _shadowViews) {
[shadowView uiManagerWillPerformMounting];
}
}
#pragma mark - Font Size Multiplier
- (void)handleDidUpdateMultiplierNotification
{
CGFloat fontSizeMultiplier =
[[[self.bridge moduleForName:@"AccessibilityManager"] valueForKey:@"multiplier"] floatValue];
NSHashTable<RCTTextShadowView *> *shadowViews = _shadowViews;
RCTExecuteOnUIManagerQueue(^{
for (RCTTextShadowView *shadowView in shadowViews) {
shadowView.textAttributes.fontSizeMultiplier = fontSizeMultiplier;
[shadowView dirtyLayout];
}
[self.bridge.uiManager setNeedsLayout];
});
}
@end