diff --git a/packages/mobile/src/features/common/alerts/AppDialog.tsx b/packages/mobile/src/features/common/alerts/AppDialog.tsx
index 18b5dd04..a67175bb 100644
--- a/packages/mobile/src/features/common/alerts/AppDialog.tsx
+++ b/packages/mobile/src/features/common/alerts/AppDialog.tsx
@@ -146,6 +146,27 @@ export default function AppDialog({
{rendered.title}
{rendered.message ? {rendered.message} : null}
+ {rendered.link ? (
+ {
+ rendered.link?.onPress();
+ close();
+ }}
+ accessibilityRole="link"
+ accessibilityLabel={rendered.link.label}
+ style={({ pressed }) => [
+ styles.linkChip,
+ { backgroundColor: v.badgeBg },
+ pressed && { opacity: 0.75, transform: [{ scale: 0.97 }] },
+ ]}
+ >
+
+
+ {rendered.link.label}
+
+
+ ) : null}
+
{rendered.buttons.map((button, i) => {
const isCancel = button.style === 'cancel';
@@ -211,6 +232,17 @@ const styles = StyleSheet.create({
sparkle: { position: 'absolute', width: 8, height: 8, borderRadius: 4 },
title: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, textAlign: 'center', letterSpacing: -0.2 },
message: { fontSize: 14, color: colors.textSecondary, textAlign: 'center', marginTop: 8, lineHeight: 20 },
+ linkChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ marginTop: 14,
+ paddingHorizontal: 16,
+ height: 38,
+ maxWidth: '100%',
+ borderRadius: radii.pill,
+ },
+ linkChipText: { fontSize: 14, fontWeight: '700', flexShrink: 1 },
buttonRow: { flexDirection: 'row', gap: 10, marginTop: 22, width: '100%' },
buttonRowSingle: { justifyContent: 'center' },
button: { flex: 1, height: 48, borderRadius: radii.md, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 12 },
diff --git a/packages/mobile/src/features/common/alerts/appAlert.ts b/packages/mobile/src/features/common/alerts/appAlert.ts
index e3c62a08..ab8ca67e 100644
--- a/packages/mobile/src/features/common/alerts/appAlert.ts
+++ b/packages/mobile/src/features/common/alerts/appAlert.ts
@@ -42,6 +42,7 @@ export function showAppAlert(
variant: options?.variant ?? 'info',
cancelable: options?.cancelable ?? true,
icon: options?.icon,
+ link: options?.link,
onDismiss: options?.onDismiss,
});
}
diff --git a/packages/mobile/src/features/common/alerts/types.ts b/packages/mobile/src/features/common/alerts/types.ts
index 210de11b..84e47466 100644
--- a/packages/mobile/src/features/common/alerts/types.ts
+++ b/packages/mobile/src/features/common/alerts/types.ts
@@ -11,11 +11,18 @@ export interface AlertButton {
onPress?: () => void;
}
+/** Tappable link chip rendered between the dialog message and its buttons. */
+export interface DialogLink {
+ label: string;
+ onPress: () => void;
+}
+
export interface AlertOptions {
variant?: DialogVariant;
/** Backdrop tap / Android back dismiss. Defaults to true, matching Alert.alert. */
cancelable?: boolean;
icon?: IoniconName;
+ link?: DialogLink;
onDismiss?: () => void;
}
@@ -34,6 +41,7 @@ export interface QueuedDialog {
variant: DialogVariant;
cancelable: boolean;
icon?: IoniconName;
+ link?: DialogLink;
onDismiss?: () => void;
}
diff --git a/packages/mobile/src/features/common/external-link.ts b/packages/mobile/src/features/common/external-link.ts
new file mode 100644
index 00000000..2794406a
--- /dev/null
+++ b/packages/mobile/src/features/common/external-link.ts
@@ -0,0 +1,105 @@
+import { Linking } from 'react-native';
+import * as WebBrowser from 'expo-web-browser';
+import { showAppAlert } from './alerts/appAlert';
+import { colors } from './tokens';
+import type { IoniconName } from './alerts/types';
+
+/** Human display form of a URL for link chips: no protocol, no trailing slash. */
+export function displayUrl(url: string): string {
+ return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
+}
+
+/**
+ * Open a web page in the in-app browser sheet so the user never leaves the
+ * app. Falls back to the system browser, then to an info dialog with the URL.
+ */
+export async function openInAppBrowser(url: string): Promise {
+ try {
+ await WebBrowser.openBrowserAsync(url, {
+ controlsColor: colors.brandBlue,
+ toolbarColor: colors.cardBg,
+ presentationStyle: WebBrowser.WebBrowserPresentationStyle.PAGE_SHEET,
+ });
+ } catch {
+ try {
+ await Linking.openURL(url);
+ } catch {
+ showAppAlert('Unable to open page', `Visit ${displayUrl(url)} in your browser.`, undefined, {
+ variant: 'info',
+ icon: 'globe-outline',
+ });
+ }
+ }
+}
+
+/**
+ * Confirm-before-leaving popup for web links: shows the destination as a
+ * tappable chip plus an "Open page" button. Nothing opens until the user
+ * explicitly chooses to.
+ */
+export function confirmWebLink({
+ title,
+ message,
+ url,
+ icon,
+}: {
+ title: string;
+ message: string;
+ url: string;
+ icon?: IoniconName;
+}): void {
+ showAppAlert(
+ title,
+ message,
+ [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Open page', onPress: () => void openInAppBrowser(url) },
+ ],
+ {
+ variant: 'info',
+ icon,
+ link: { label: displayUrl(url), onPress: () => void openInAppBrowser(url) },
+ },
+ );
+}
+
+/**
+ * Confirm-before-leaving popup for email links: shows the address as a
+ * tappable chip plus an "Open Mail" button that launches the mail composer.
+ */
+export function confirmEmail({
+ title,
+ message,
+ email,
+ subject,
+ icon,
+}: {
+ title: string;
+ message: string;
+ email: string;
+ subject?: string;
+ icon?: IoniconName;
+}): void {
+ const mailto = `mailto:${email}${subject ? `?subject=${encodeURIComponent(subject)}` : ''}`;
+ const openMail = () => {
+ Linking.openURL(mailto).catch(() => {
+ showAppAlert('Unable to open mail app', `Email us at ${email}`, undefined, {
+ variant: 'info',
+ icon: icon ?? 'mail-outline',
+ });
+ });
+ };
+ showAppAlert(
+ title,
+ message,
+ [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Open Mail', onPress: openMail },
+ ],
+ {
+ variant: 'info',
+ icon,
+ link: { label: email, onPress: openMail },
+ },
+ );
+}
diff --git a/packages/mobile/src/features/profile/ProfileScreen.tsx b/packages/mobile/src/features/profile/ProfileScreen.tsx
index 6bce90ab..85f0cff4 100644
--- a/packages/mobile/src/features/profile/ProfileScreen.tsx
+++ b/packages/mobile/src/features/profile/ProfileScreen.tsx
@@ -1,6 +1,5 @@
import React, { useCallback, useState } from 'react';
import {
- Linking,
Pressable,
ScrollView,
StyleSheet,
@@ -16,6 +15,7 @@ import { useFocusEffect, useRouter } from 'expo-router';
import { useAuth } from '../../lib/AuthContext';
import apiClient from '../../lib/api-client';
import { showAppAlert } from '../common/alerts/appAlert';
+import { confirmEmail, confirmWebLink, openInAppBrowser } from '../common/external-link';
import { Skeleton } from '../common/Skeleton';
import { colors, typography, radii, shadow, gradients, alpha } from '../common/tokens';
@@ -108,22 +108,37 @@ export default function ProfileScreen() {
};
const openSupport = () => {
- Linking.openURL('mailto:support@rayhealthevv.com?subject=RayHealthEVV%20Support').catch(() => {
- showAppAlert('Support', 'Email us at support@rayhealthevv.com', undefined, { variant: 'info', icon: 'help-buoy-outline' });
+ confirmEmail({
+ title: 'Contact Support',
+ message: 'Our support team is ready to help. Reach us by email and we will get back to you.',
+ email: 'support@rayhealthevv.com',
+ subject: 'RayHealthEVV Support',
+ icon: 'help-buoy-outline',
});
};
const openPrivacyPolicy = () => {
- Linking.openURL('https://rayhealthevv.com/privacy').catch(() => {
- showAppAlert(
- 'Privacy policy',
- 'Visit https://rayhealthevv.com/privacy in your browser.',
- undefined,
- { variant: 'info', icon: 'shield-checkmark-outline' },
- );
+ confirmWebLink({
+ title: 'Privacy policy',
+ message: 'Our privacy policy is available on the RayHealthEVV website. It explains how we protect and handle your data.',
+ url: 'https://rayhealthevv.com/privacy',
+ icon: 'shield-checkmark-outline',
});
};
+ const showAbout = () => {
+ showAppAlert(
+ 'RayHealthEVV',
+ `Version ${version}. Electronic Visit Verification for home care, built for Pennsylvania agencies and caregivers. Learn more on our website.`,
+ [{ text: 'Close', style: 'cancel' }],
+ {
+ variant: 'info',
+ icon: 'information-circle-outline',
+ link: { label: 'rayhealthevv.com', onPress: () => void openInAppBrowser('https://rayhealthevv.com') },
+ },
+ );
+ };
+
return (
@@ -224,6 +239,7 @@ export default function ProfileScreen() {
tint={colors.slate}
title="About"
value={`v${version}`}
+ onPress={showAbout}
isLast
/>