Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/mobile/src/features/common/alerts/AppDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,27 @@ export default function AppDialog({
<Text style={styles.title}>{rendered.title}</Text>
{rendered.message ? <Text style={styles.message}>{rendered.message}</Text> : null}

{rendered.link ? (
<Pressable
onPress={() => {
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 }] },
]}
>
<Ionicons name="open-outline" size={15} color={v.accent} />
<Text style={[styles.linkChipText, { color: v.accent }]} numberOfLines={1}>
{rendered.link.label}
</Text>
</Pressable>
) : null}

<View style={[styles.buttonRow, rendered.buttons.length === 1 && styles.buttonRowSingle]}>
{rendered.buttons.map((button, i) => {
const isCancel = button.style === 'cancel';
Expand Down Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions packages/mobile/src/features/common/alerts/appAlert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function showAppAlert(
variant: options?.variant ?? 'info',
cancelable: options?.cancelable ?? true,
icon: options?.icon,
link: options?.link,
onDismiss: options?.onDismiss,
});
}
Expand Down
8 changes: 8 additions & 0 deletions packages/mobile/src/features/common/alerts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -34,6 +41,7 @@ export interface QueuedDialog {
variant: DialogVariant;
cancelable: boolean;
icon?: IoniconName;
link?: DialogLink;
onDismiss?: () => void;
}

Expand Down
105 changes: 105 additions & 0 deletions packages/mobile/src/features/common/external-link.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 },
},
);
}
36 changes: 26 additions & 10 deletions packages/mobile/src/features/profile/ProfileScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { useCallback, useState } from 'react';
import {
Linking,
Pressable,
ScrollView,
StyleSheet,
Expand All @@ -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';

Expand Down Expand Up @@ -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 (
<View style={styles.container}>
<StatusBar style="light" />
Expand Down Expand Up @@ -224,6 +239,7 @@ export default function ProfileScreen() {
tint={colors.slate}
title="About"
value={`v${version}`}
onPress={showAbout}
isLast
/>
</View>
Expand Down