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,33 @@
import { ConfigPlugin } from 'expo/config-plugins';
export type NotificationsPluginProps = {
/**
* Local path to an image to use as the icon for push notifications.
* 96x96 all-white png with transparency. We recommend following
* [Google's design guidelines](https://material.io/design/iconography/product-icons.html#design-principles).
* @platform android
*/
icon?: string;
/**
* Tint color for the push notification image when it appears in the notification tray.
* @default '#ffffff'
* @platform android
*/
color?: string;
/**
* Default channel for FCMv1 notifications.
* @platform android
*/
defaultChannel?: string;
/**
* Array of local paths to sound files (.wav recommended) that can be used as custom notification sounds.
*/
sounds?: string[];
/**
* Environment of the app: either 'development' or 'production'.
* @default 'development'
* @platform ios
*/
mode?: 'development' | 'production';
};
declare const _default: ConfigPlugin<void | NotificationsPluginProps>;
export default _default;

View File

@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const config_plugins_1 = require("expo/config-plugins");
const withNotificationsAndroid_1 = require("./withNotificationsAndroid");
const withNotificationsIOS_1 = require("./withNotificationsIOS");
const pkg = require('expo-notifications/package.json');
const withNotifications = (config, props) => {
config = (0, withNotificationsAndroid_1.withNotificationsAndroid)(config, props || {});
config = (0, withNotificationsIOS_1.withNotificationsIOS)(config, props || {});
return config;
};
exports.default = (0, config_plugins_1.createRunOncePlugin)(withNotifications, pkg.name, pkg.version);

View File

@@ -0,0 +1,46 @@
import { ExpoConfig } from 'expo/config';
import { AndroidConfig, ConfigPlugin } from 'expo/config-plugins';
import { NotificationsPluginProps } from './withNotifications';
type DPIString = 'mdpi' | 'hdpi' | 'xhdpi' | 'xxhdpi' | 'xxxhdpi';
type dpiMap = Record<DPIString, {
folderName: string;
scale: number;
}>;
export declare const ANDROID_RES_PATH = "android/app/src/main/res/";
export declare const dpiValues: dpiMap;
export declare const META_DATA_FCM_NOTIFICATION_ICON = "com.google.firebase.messaging.default_notification_icon";
export declare const META_DATA_FCM_NOTIFICATION_ICON_COLOR = "com.google.firebase.messaging.default_notification_color";
export declare const META_DATA_FCM_NOTIFICATION_DEFAULT_CHANNEL_ID = "com.google.firebase.messaging.default_notification_channel_id";
export declare const META_DATA_LOCAL_NOTIFICATION_ICON = "expo.modules.notifications.default_notification_icon";
export declare const META_DATA_LOCAL_NOTIFICATION_ICON_COLOR = "expo.modules.notifications.default_notification_color";
export declare const NOTIFICATION_ICON = "notification_icon";
export declare const NOTIFICATION_ICON_RESOURCE: string;
export declare const NOTIFICATION_ICON_COLOR = "notification_icon_color";
export declare const NOTIFICATION_ICON_COLOR_RESOURCE: string;
export declare const withNotificationIcons: ConfigPlugin<{
icon: string | null;
}>;
export declare const withNotificationIconColor: ConfigPlugin<{
color: string | null;
}>;
export declare const withNotificationManifest: ConfigPlugin<{
icon: string | null;
color: string | null;
defaultChannel: string | null;
}>;
export declare const withNotificationSounds: ConfigPlugin<{
sounds: string[];
}>;
export declare function getNotificationIcon(config: ExpoConfig): string | null;
export declare function getNotificationColor(config: ExpoConfig): string | null;
export declare function setNotificationIconColor(color: string | null, colors: AndroidConfig.Resources.ResourceXML): AndroidConfig.Resources.ResourceXML;
/**
* Applies notification icon configuration for expo-notifications
*/
export declare function setNotificationIconAsync(projectRoot: string, icon: string | null): Promise<void>;
/**
* Save sound files to `<project-root>/android/app/src/main/res/raw`
*/
export declare function setNotificationSounds(projectRoot: string, sounds: string[]): void;
export declare const withNotificationsAndroid: ConfigPlugin<NotificationsPluginProps>;
export {};

View File

@@ -0,0 +1,200 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.withNotificationsAndroid = exports.setNotificationSounds = exports.setNotificationIconAsync = exports.setNotificationIconColor = exports.getNotificationColor = exports.getNotificationIcon = exports.withNotificationSounds = exports.withNotificationManifest = exports.withNotificationIconColor = exports.withNotificationIcons = exports.NOTIFICATION_ICON_COLOR_RESOURCE = exports.NOTIFICATION_ICON_COLOR = exports.NOTIFICATION_ICON_RESOURCE = exports.NOTIFICATION_ICON = exports.META_DATA_LOCAL_NOTIFICATION_ICON_COLOR = exports.META_DATA_LOCAL_NOTIFICATION_ICON = exports.META_DATA_FCM_NOTIFICATION_DEFAULT_CHANNEL_ID = exports.META_DATA_FCM_NOTIFICATION_ICON_COLOR = exports.META_DATA_FCM_NOTIFICATION_ICON = exports.dpiValues = exports.ANDROID_RES_PATH = void 0;
const image_utils_1 = require("@expo/image-utils");
const config_plugins_1 = require("expo/config-plugins");
const fs_1 = require("fs");
const path_1 = require("path");
const { Colors } = config_plugins_1.AndroidConfig;
exports.ANDROID_RES_PATH = 'android/app/src/main/res/';
exports.dpiValues = {
mdpi: { folderName: 'mipmap-mdpi', scale: 1 },
hdpi: { folderName: 'mipmap-hdpi', scale: 1.5 },
xhdpi: { folderName: 'mipmap-xhdpi', scale: 2 },
xxhdpi: { folderName: 'mipmap-xxhdpi', scale: 3 },
xxxhdpi: { folderName: 'mipmap-xxxhdpi', scale: 4 },
};
const { addMetaDataItemToMainApplication, getMainApplicationOrThrow, removeMetaDataItemFromMainApplication, } = config_plugins_1.AndroidConfig.Manifest;
const BASELINE_PIXEL_SIZE = 24;
const ERROR_MSG_PREFIX = 'An error occurred while configuring Android notifications. ';
exports.META_DATA_FCM_NOTIFICATION_ICON = 'com.google.firebase.messaging.default_notification_icon';
exports.META_DATA_FCM_NOTIFICATION_ICON_COLOR = 'com.google.firebase.messaging.default_notification_color';
exports.META_DATA_FCM_NOTIFICATION_DEFAULT_CHANNEL_ID = 'com.google.firebase.messaging.default_notification_channel_id';
exports.META_DATA_LOCAL_NOTIFICATION_ICON = 'expo.modules.notifications.default_notification_icon';
exports.META_DATA_LOCAL_NOTIFICATION_ICON_COLOR = 'expo.modules.notifications.default_notification_color';
// TODO @vonovak add config for local notification large icon
// expo.modules.notifications.large_notification_icon
exports.NOTIFICATION_ICON = 'notification_icon';
exports.NOTIFICATION_ICON_RESOURCE = `@drawable/${exports.NOTIFICATION_ICON}`;
exports.NOTIFICATION_ICON_COLOR = 'notification_icon_color';
exports.NOTIFICATION_ICON_COLOR_RESOURCE = `@color/${exports.NOTIFICATION_ICON_COLOR}`;
const withNotificationIcons = (config, { icon }) => {
// If no icon provided in the config plugin props, fallback to value from app.json
icon = icon || getNotificationIcon(config);
return (0, config_plugins_1.withDangerousMod)(config, [
'android',
async (config) => {
await setNotificationIconAsync(config.modRequest.projectRoot, icon);
return config;
},
]);
};
exports.withNotificationIcons = withNotificationIcons;
const withNotificationIconColor = (config, { color }) => {
// If no color provided in the config plugin props, fallback to value from app.json
return (0, config_plugins_1.withAndroidColors)(config, (config) => {
color = color || getNotificationColor(config);
config.modResults = setNotificationIconColor(color, config.modResults);
return config;
});
};
exports.withNotificationIconColor = withNotificationIconColor;
const withNotificationManifest = (config, { icon, color, defaultChannel }) => {
// If no icon or color provided in the config plugin props, fallback to value from app.json
icon = icon || getNotificationIcon(config);
color = color || getNotificationColor(config);
defaultChannel = defaultChannel || null;
return (0, config_plugins_1.withAndroidManifest)(config, (config) => {
config.modResults = setNotificationConfig({ icon, color, defaultChannel }, config.modResults);
return config;
});
};
exports.withNotificationManifest = withNotificationManifest;
const withNotificationSounds = (config, { sounds }) => {
return (0, config_plugins_1.withDangerousMod)(config, [
'android',
(config) => {
setNotificationSounds(config.modRequest.projectRoot, sounds);
return config;
},
]);
};
exports.withNotificationSounds = withNotificationSounds;
function getNotificationIcon(config) {
return config.notification?.icon || null;
}
exports.getNotificationIcon = getNotificationIcon;
function getNotificationColor(config) {
return config.notification?.color || null;
}
exports.getNotificationColor = getNotificationColor;
function setNotificationIconColor(color, colors) {
return Colors.assignColorValue(colors, {
name: exports.NOTIFICATION_ICON_COLOR,
value: color,
});
}
exports.setNotificationIconColor = setNotificationIconColor;
/**
* Applies notification icon configuration for expo-notifications
*/
async function setNotificationIconAsync(projectRoot, icon) {
if (icon) {
await writeNotificationIconImageFilesAsync(icon, projectRoot);
}
else {
removeNotificationIconImageFiles(projectRoot);
}
}
exports.setNotificationIconAsync = setNotificationIconAsync;
function setNotificationConfig(props, manifest) {
const mainApplication = getMainApplicationOrThrow(manifest);
if (props.icon) {
addMetaDataItemToMainApplication(mainApplication, exports.META_DATA_FCM_NOTIFICATION_ICON, exports.NOTIFICATION_ICON_RESOURCE, 'resource');
addMetaDataItemToMainApplication(mainApplication, exports.META_DATA_LOCAL_NOTIFICATION_ICON, exports.NOTIFICATION_ICON_RESOURCE, 'resource');
}
else {
removeMetaDataItemFromMainApplication(mainApplication, exports.META_DATA_FCM_NOTIFICATION_ICON);
removeMetaDataItemFromMainApplication(mainApplication, exports.META_DATA_LOCAL_NOTIFICATION_ICON);
}
if (props.color) {
addMetaDataItemToMainApplication(mainApplication, exports.META_DATA_FCM_NOTIFICATION_ICON_COLOR, exports.NOTIFICATION_ICON_COLOR_RESOURCE, 'resource');
addMetaDataItemToMainApplication(mainApplication, exports.META_DATA_LOCAL_NOTIFICATION_ICON_COLOR, exports.NOTIFICATION_ICON_COLOR_RESOURCE, 'resource');
}
else {
removeMetaDataItemFromMainApplication(mainApplication, exports.META_DATA_FCM_NOTIFICATION_ICON_COLOR);
removeMetaDataItemFromMainApplication(mainApplication, exports.META_DATA_LOCAL_NOTIFICATION_ICON_COLOR);
}
if (props.defaultChannel) {
addMetaDataItemToMainApplication(mainApplication, exports.META_DATA_FCM_NOTIFICATION_DEFAULT_CHANNEL_ID, props.defaultChannel, 'value');
}
else {
removeMetaDataItemFromMainApplication(mainApplication, exports.META_DATA_FCM_NOTIFICATION_DEFAULT_CHANNEL_ID);
}
return manifest;
}
async function writeNotificationIconImageFilesAsync(icon, projectRoot) {
await Promise.all(Object.values(exports.dpiValues).map(async ({ folderName, scale }) => {
const drawableFolderName = folderName.replace('mipmap', 'drawable');
const dpiFolderPath = (0, path_1.resolve)(projectRoot, exports.ANDROID_RES_PATH, drawableFolderName);
if (!(0, fs_1.existsSync)(dpiFolderPath)) {
(0, fs_1.mkdirSync)(dpiFolderPath, { recursive: true });
}
const iconSizePx = BASELINE_PIXEL_SIZE * scale;
try {
const resizedIcon = (await (0, image_utils_1.generateImageAsync)({ projectRoot, cacheType: 'android-notification' }, {
src: icon,
width: iconSizePx,
height: iconSizePx,
resizeMode: 'cover',
backgroundColor: 'transparent',
})).source;
(0, fs_1.writeFileSync)((0, path_1.resolve)(dpiFolderPath, exports.NOTIFICATION_ICON + '.png'), resizedIcon);
}
catch (e) {
throw new Error(ERROR_MSG_PREFIX + 'Encountered an issue resizing Android notification icon: ' + e);
}
}));
}
function removeNotificationIconImageFiles(projectRoot) {
Object.values(exports.dpiValues).forEach(async ({ folderName }) => {
const drawableFolderName = folderName.replace('mipmap', 'drawable');
const dpiFolderPath = (0, path_1.resolve)(projectRoot, exports.ANDROID_RES_PATH, drawableFolderName);
const iconFile = (0, path_1.resolve)(dpiFolderPath, exports.NOTIFICATION_ICON + '.png');
if ((0, fs_1.existsSync)(iconFile)) {
(0, fs_1.unlinkSync)(iconFile);
}
});
}
/**
* Save sound files to `<project-root>/android/app/src/main/res/raw`
*/
function setNotificationSounds(projectRoot, sounds) {
if (!Array.isArray(sounds)) {
throw new Error(ERROR_MSG_PREFIX +
`Must provide an array of sound files in your app config, found ${typeof sounds}.`);
}
for (const soundFileRelativePath of sounds) {
writeNotificationSoundFile(soundFileRelativePath, projectRoot);
}
}
exports.setNotificationSounds = setNotificationSounds;
/**
* Copies the input file to the `<project-root>/android/app/src/main/res/raw` directory if
* there isn't already an existing file under that name.
*/
function writeNotificationSoundFile(soundFileRelativePath, projectRoot) {
const rawResourcesPath = (0, path_1.resolve)(projectRoot, exports.ANDROID_RES_PATH, 'raw');
const inputFilename = (0, path_1.basename)(soundFileRelativePath);
if (inputFilename) {
try {
const sourceFilepath = (0, path_1.resolve)(projectRoot, soundFileRelativePath);
const destinationFilepath = (0, path_1.resolve)(rawResourcesPath, inputFilename);
if (!(0, fs_1.existsSync)(rawResourcesPath)) {
(0, fs_1.mkdirSync)(rawResourcesPath, { recursive: true });
}
(0, fs_1.copyFileSync)(sourceFilepath, destinationFilepath);
}
catch (e) {
throw new Error(ERROR_MSG_PREFIX + 'Encountered an issue copying Android notification sounds: ' + e);
}
}
}
const withNotificationsAndroid = (config, { icon = null, color = null, sounds = [], defaultChannel = null }) => {
config = (0, exports.withNotificationIconColor)(config, { color });
config = (0, exports.withNotificationIcons)(config, { icon });
config = (0, exports.withNotificationManifest)(config, { icon, color, defaultChannel });
config = (0, exports.withNotificationSounds)(config, { sounds });
return config;
};
exports.withNotificationsAndroid = withNotificationsAndroid;

View File

@@ -0,0 +1,14 @@
import { ConfigPlugin, XcodeProject } from 'expo/config-plugins';
import { NotificationsPluginProps } from './withNotifications';
export declare const withNotificationsIOS: ConfigPlugin<NotificationsPluginProps>;
export declare const withNotificationSounds: ConfigPlugin<{
sounds: string[];
}>;
/**
* Save sound files to the Xcode project root and add them to the Xcode project.
*/
export declare function setNotificationSounds(projectRoot: string, { sounds, project, projectName, }: {
sounds: string[];
project: XcodeProject;
projectName: string | undefined;
}): XcodeProject;

View File

@@ -0,0 +1,58 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.setNotificationSounds = exports.withNotificationSounds = exports.withNotificationsIOS = void 0;
const config_plugins_1 = require("expo/config-plugins");
const fs_1 = require("fs");
const path_1 = require("path");
const ERROR_MSG_PREFIX = 'An error occurred while configuring iOS notifications. ';
const withNotificationsIOS = (config, { mode = 'development', sounds = [] }) => {
config = (0, config_plugins_1.withEntitlementsPlist)(config, (config) => {
config.modResults['aps-environment'] = mode;
return config;
});
config = (0, exports.withNotificationSounds)(config, { sounds });
return config;
};
exports.withNotificationsIOS = withNotificationsIOS;
const withNotificationSounds = (config, { sounds }) => {
return (0, config_plugins_1.withXcodeProject)(config, (config) => {
setNotificationSounds(config.modRequest.projectRoot, {
sounds,
project: config.modResults,
projectName: config.modRequest.projectName,
});
return config;
});
};
exports.withNotificationSounds = withNotificationSounds;
/**
* Save sound files to the Xcode project root and add them to the Xcode project.
*/
function setNotificationSounds(projectRoot, { sounds, project, projectName, }) {
if (!projectName) {
throw new Error(ERROR_MSG_PREFIX + `Unable to find iOS project name.`);
}
if (!Array.isArray(sounds)) {
throw new Error(ERROR_MSG_PREFIX +
`Must provide an array of sound files in your app config, found ${typeof sounds}.`);
}
const sourceRoot = config_plugins_1.IOSConfig.Paths.getSourceRoot(projectRoot);
for (const soundFileRelativePath of sounds) {
const fileName = (0, path_1.basename)(soundFileRelativePath);
const sourceFilepath = (0, path_1.resolve)(projectRoot, soundFileRelativePath);
const destinationFilepath = (0, path_1.resolve)(sourceRoot, fileName);
// Since it's possible that the filename is the same, but the
// file itself id different, let's copy it regardless
(0, fs_1.copyFileSync)(sourceFilepath, destinationFilepath);
if (!project.hasFile(`${projectName}/${fileName}`)) {
project = config_plugins_1.IOSConfig.XcodeUtils.addResourceFileToGroup({
filepath: `${projectName}/${fileName}`,
groupName: projectName,
isBuildFile: true,
project,
});
}
}
return project;
}
exports.setNotificationSounds = setNotificationSounds;

View File

@@ -0,0 +1 @@
module.exports = require('expo-module-scripts/jest-preset-plugin');

View File

@@ -0,0 +1,45 @@
import { ConfigPlugin, createRunOncePlugin } from 'expo/config-plugins';
import { withNotificationsAndroid } from './withNotificationsAndroid';
import { withNotificationsIOS } from './withNotificationsIOS';
const pkg = require('expo-notifications/package.json');
export type NotificationsPluginProps = {
/**
* Local path to an image to use as the icon for push notifications.
* 96x96 all-white png with transparency. We recommend following
* [Google's design guidelines](https://material.io/design/iconography/product-icons.html#design-principles).
* @platform android
*/
icon?: string;
/**
* Tint color for the push notification image when it appears in the notification tray.
* @default '#ffffff'
* @platform android
*/
color?: string;
/**
* Default channel for FCMv1 notifications.
* @platform android
*/
defaultChannel?: string;
/**
* Array of local paths to sound files (.wav recommended) that can be used as custom notification sounds.
*/
sounds?: string[];
/**
* Environment of the app: either 'development' or 'production'.
* @default 'development'
* @platform ios
*/
mode?: 'development' | 'production';
};
const withNotifications: ConfigPlugin<NotificationsPluginProps | void> = (config, props) => {
config = withNotificationsAndroid(config, props || {});
config = withNotificationsIOS(config, props || {});
return config;
};
export default createRunOncePlugin(withNotifications, pkg.name, pkg.version);

View File

@@ -0,0 +1,282 @@
import { generateImageAsync } from '@expo/image-utils';
import { ExpoConfig } from 'expo/config';
import {
AndroidConfig,
ConfigPlugin,
withDangerousMod,
withAndroidColors,
withAndroidManifest,
} from 'expo/config-plugins';
import { writeFileSync, unlinkSync, copyFileSync, existsSync, mkdirSync } from 'fs';
import { basename, resolve } from 'path';
import { NotificationsPluginProps } from './withNotifications';
const { Colors } = AndroidConfig;
type DPIString = 'mdpi' | 'hdpi' | 'xhdpi' | 'xxhdpi' | 'xxxhdpi';
type dpiMap = Record<DPIString, { folderName: string; scale: number }>;
export const ANDROID_RES_PATH = 'android/app/src/main/res/';
export const dpiValues: dpiMap = {
mdpi: { folderName: 'mipmap-mdpi', scale: 1 },
hdpi: { folderName: 'mipmap-hdpi', scale: 1.5 },
xhdpi: { folderName: 'mipmap-xhdpi', scale: 2 },
xxhdpi: { folderName: 'mipmap-xxhdpi', scale: 3 },
xxxhdpi: { folderName: 'mipmap-xxxhdpi', scale: 4 },
};
const {
addMetaDataItemToMainApplication,
getMainApplicationOrThrow,
removeMetaDataItemFromMainApplication,
} = AndroidConfig.Manifest;
const BASELINE_PIXEL_SIZE = 24;
const ERROR_MSG_PREFIX = 'An error occurred while configuring Android notifications. ';
export const META_DATA_FCM_NOTIFICATION_ICON =
'com.google.firebase.messaging.default_notification_icon';
export const META_DATA_FCM_NOTIFICATION_ICON_COLOR =
'com.google.firebase.messaging.default_notification_color';
export const META_DATA_FCM_NOTIFICATION_DEFAULT_CHANNEL_ID =
'com.google.firebase.messaging.default_notification_channel_id';
export const META_DATA_LOCAL_NOTIFICATION_ICON =
'expo.modules.notifications.default_notification_icon';
export const META_DATA_LOCAL_NOTIFICATION_ICON_COLOR =
'expo.modules.notifications.default_notification_color';
// TODO @vonovak add config for local notification large icon
// expo.modules.notifications.large_notification_icon
export const NOTIFICATION_ICON = 'notification_icon';
export const NOTIFICATION_ICON_RESOURCE = `@drawable/${NOTIFICATION_ICON}`;
export const NOTIFICATION_ICON_COLOR = 'notification_icon_color';
export const NOTIFICATION_ICON_COLOR_RESOURCE = `@color/${NOTIFICATION_ICON_COLOR}`;
export const withNotificationIcons: ConfigPlugin<{ icon: string | null }> = (config, { icon }) => {
// If no icon provided in the config plugin props, fallback to value from app.json
icon = icon || getNotificationIcon(config);
return withDangerousMod(config, [
'android',
async (config) => {
await setNotificationIconAsync(config.modRequest.projectRoot, icon);
return config;
},
]);
};
export const withNotificationIconColor: ConfigPlugin<{ color: string | null }> = (
config,
{ color }
) => {
// If no color provided in the config plugin props, fallback to value from app.json
return withAndroidColors(config, (config) => {
color = color || getNotificationColor(config);
config.modResults = setNotificationIconColor(color, config.modResults);
return config;
});
};
export const withNotificationManifest: ConfigPlugin<{
icon: string | null;
color: string | null;
defaultChannel: string | null;
}> = (config, { icon, color, defaultChannel }) => {
// If no icon or color provided in the config plugin props, fallback to value from app.json
icon = icon || getNotificationIcon(config);
color = color || getNotificationColor(config);
defaultChannel = defaultChannel || null;
return withAndroidManifest(config, (config) => {
config.modResults = setNotificationConfig({ icon, color, defaultChannel }, config.modResults);
return config;
});
};
export const withNotificationSounds: ConfigPlugin<{ sounds: string[] }> = (config, { sounds }) => {
return withDangerousMod(config, [
'android',
(config) => {
setNotificationSounds(config.modRequest.projectRoot, sounds);
return config;
},
]);
};
export function getNotificationIcon(config: ExpoConfig) {
return config.notification?.icon || null;
}
export function getNotificationColor(config: ExpoConfig) {
return config.notification?.color || null;
}
export function setNotificationIconColor(
color: string | null,
colors: AndroidConfig.Resources.ResourceXML
) {
return Colors.assignColorValue(colors, {
name: NOTIFICATION_ICON_COLOR,
value: color,
});
}
/**
* Applies notification icon configuration for expo-notifications
*/
export async function setNotificationIconAsync(projectRoot: string, icon: string | null) {
if (icon) {
await writeNotificationIconImageFilesAsync(icon, projectRoot);
} else {
removeNotificationIconImageFiles(projectRoot);
}
}
function setNotificationConfig(
props: { icon: string | null; color: string | null; defaultChannel?: string | null },
manifest: AndroidConfig.Manifest.AndroidManifest
) {
const mainApplication = getMainApplicationOrThrow(manifest);
if (props.icon) {
addMetaDataItemToMainApplication(
mainApplication,
META_DATA_FCM_NOTIFICATION_ICON,
NOTIFICATION_ICON_RESOURCE,
'resource'
);
addMetaDataItemToMainApplication(
mainApplication,
META_DATA_LOCAL_NOTIFICATION_ICON,
NOTIFICATION_ICON_RESOURCE,
'resource'
);
} else {
removeMetaDataItemFromMainApplication(mainApplication, META_DATA_FCM_NOTIFICATION_ICON);
removeMetaDataItemFromMainApplication(mainApplication, META_DATA_LOCAL_NOTIFICATION_ICON);
}
if (props.color) {
addMetaDataItemToMainApplication(
mainApplication,
META_DATA_FCM_NOTIFICATION_ICON_COLOR,
NOTIFICATION_ICON_COLOR_RESOURCE,
'resource'
);
addMetaDataItemToMainApplication(
mainApplication,
META_DATA_LOCAL_NOTIFICATION_ICON_COLOR,
NOTIFICATION_ICON_COLOR_RESOURCE,
'resource'
);
} else {
removeMetaDataItemFromMainApplication(mainApplication, META_DATA_FCM_NOTIFICATION_ICON_COLOR);
removeMetaDataItemFromMainApplication(mainApplication, META_DATA_LOCAL_NOTIFICATION_ICON_COLOR);
}
if (props.defaultChannel) {
addMetaDataItemToMainApplication(
mainApplication,
META_DATA_FCM_NOTIFICATION_DEFAULT_CHANNEL_ID,
props.defaultChannel,
'value'
);
} else {
removeMetaDataItemFromMainApplication(
mainApplication,
META_DATA_FCM_NOTIFICATION_DEFAULT_CHANNEL_ID
);
}
return manifest;
}
async function writeNotificationIconImageFilesAsync(icon: string, projectRoot: string) {
await Promise.all(
Object.values(dpiValues).map(async ({ folderName, scale }) => {
const drawableFolderName = folderName.replace('mipmap', 'drawable');
const dpiFolderPath = resolve(projectRoot, ANDROID_RES_PATH, drawableFolderName);
if (!existsSync(dpiFolderPath)) {
mkdirSync(dpiFolderPath, { recursive: true });
}
const iconSizePx = BASELINE_PIXEL_SIZE * scale;
try {
const resizedIcon = (
await generateImageAsync(
{ projectRoot, cacheType: 'android-notification' },
{
src: icon,
width: iconSizePx,
height: iconSizePx,
resizeMode: 'cover',
backgroundColor: 'transparent',
}
)
).source;
writeFileSync(resolve(dpiFolderPath, NOTIFICATION_ICON + '.png'), resizedIcon);
} catch (e) {
throw new Error(
ERROR_MSG_PREFIX + 'Encountered an issue resizing Android notification icon: ' + e
);
}
})
);
}
function removeNotificationIconImageFiles(projectRoot: string) {
Object.values(dpiValues).forEach(async ({ folderName }) => {
const drawableFolderName = folderName.replace('mipmap', 'drawable');
const dpiFolderPath = resolve(projectRoot, ANDROID_RES_PATH, drawableFolderName);
const iconFile = resolve(dpiFolderPath, NOTIFICATION_ICON + '.png');
if (existsSync(iconFile)) {
unlinkSync(iconFile);
}
});
}
/**
* Save sound files to `<project-root>/android/app/src/main/res/raw`
*/
export function setNotificationSounds(projectRoot: string, sounds: string[]) {
if (!Array.isArray(sounds)) {
throw new Error(
ERROR_MSG_PREFIX +
`Must provide an array of sound files in your app config, found ${typeof sounds}.`
);
}
for (const soundFileRelativePath of sounds) {
writeNotificationSoundFile(soundFileRelativePath, projectRoot);
}
}
/**
* Copies the input file to the `<project-root>/android/app/src/main/res/raw` directory if
* there isn't already an existing file under that name.
*/
function writeNotificationSoundFile(soundFileRelativePath: string, projectRoot: string) {
const rawResourcesPath = resolve(projectRoot, ANDROID_RES_PATH, 'raw');
const inputFilename = basename(soundFileRelativePath);
if (inputFilename) {
try {
const sourceFilepath = resolve(projectRoot, soundFileRelativePath);
const destinationFilepath = resolve(rawResourcesPath, inputFilename);
if (!existsSync(rawResourcesPath)) {
mkdirSync(rawResourcesPath, { recursive: true });
}
copyFileSync(sourceFilepath, destinationFilepath);
} catch (e) {
throw new Error(
ERROR_MSG_PREFIX + 'Encountered an issue copying Android notification sounds: ' + e
);
}
}
}
export const withNotificationsAndroid: ConfigPlugin<NotificationsPluginProps> = (
config,
{ icon = null, color = null, sounds = [], defaultChannel = null }
) => {
config = withNotificationIconColor(config, { color });
config = withNotificationIcons(config, { icon });
config = withNotificationManifest(config, { icon, color, defaultChannel });
config = withNotificationSounds(config, { sounds });
return config;
};

View File

@@ -0,0 +1,78 @@
import {
ConfigPlugin,
withEntitlementsPlist,
IOSConfig,
withXcodeProject,
XcodeProject,
} from 'expo/config-plugins';
import { copyFileSync } from 'fs';
import { basename, resolve } from 'path';
import { NotificationsPluginProps } from './withNotifications';
const ERROR_MSG_PREFIX = 'An error occurred while configuring iOS notifications. ';
export const withNotificationsIOS: ConfigPlugin<NotificationsPluginProps> = (
config,
{ mode = 'development', sounds = [] }
) => {
config = withEntitlementsPlist(config, (config) => {
config.modResults['aps-environment'] = mode;
return config;
});
config = withNotificationSounds(config, { sounds });
return config;
};
export const withNotificationSounds: ConfigPlugin<{ sounds: string[] }> = (config, { sounds }) => {
return withXcodeProject(config, (config) => {
setNotificationSounds(config.modRequest.projectRoot, {
sounds,
project: config.modResults,
projectName: config.modRequest.projectName,
});
return config;
});
};
/**
* Save sound files to the Xcode project root and add them to the Xcode project.
*/
export function setNotificationSounds(
projectRoot: string,
{
sounds,
project,
projectName,
}: { sounds: string[]; project: XcodeProject; projectName: string | undefined }
): XcodeProject {
if (!projectName) {
throw new Error(ERROR_MSG_PREFIX + `Unable to find iOS project name.`);
}
if (!Array.isArray(sounds)) {
throw new Error(
ERROR_MSG_PREFIX +
`Must provide an array of sound files in your app config, found ${typeof sounds}.`
);
}
const sourceRoot = IOSConfig.Paths.getSourceRoot(projectRoot);
for (const soundFileRelativePath of sounds) {
const fileName = basename(soundFileRelativePath);
const sourceFilepath = resolve(projectRoot, soundFileRelativePath);
const destinationFilepath = resolve(sourceRoot, fileName);
// Since it's possible that the filename is the same, but the
// file itself id different, let's copy it regardless
copyFileSync(sourceFilepath, destinationFilepath);
if (!project.hasFile(`${projectName}/${fileName}`)) {
project = IOSConfig.XcodeUtils.addResourceFileToGroup({
filepath: `${projectName}/${fileName}`,
groupName: projectName,
isBuildFile: true,
project,
});
}
}
return project;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "expo-module-scripts/tsconfig.plugin",
"compilerOptions": {
"outDir": "build",
"rootDir": "src"
},
"include": ["./src"],
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
}