diff --git a/apps/client/app.json b/apps/client/app.json
index 794c7c2..decefc2 100644
--- a/apps/client/app.json
+++ b/apps/client/app.json
@@ -1,6 +1,6 @@
{
"expo": {
- "name": "client",
+ "name": "CMUnify",
"slug": "client",
"version": "1.0.0",
"orientation": "portrait",
@@ -33,9 +33,9 @@
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
- "backgroundColor": "#ffffff",
+ "backgroundColor": "#FAFAF9",
"dark": {
- "backgroundColor": "#000000"
+ "backgroundColor": "#0C0E12"
}
}
]
diff --git a/apps/client/app/+html.tsx b/apps/client/app/+html.tsx
new file mode 100644
index 0000000..cf213ab
--- /dev/null
+++ b/apps/client/app/+html.tsx
@@ -0,0 +1,84 @@
+import { ScrollViewStyleReset } from 'expo-router/html';
+import { type PropsWithChildren } from 'react';
+
+/**
+ * Custom HTML shell for the static web export (expo-router +html).
+ *
+ * Carries everything the PWA needs: manifest, icons, iOS standalone tags,
+ * theme-color for both color schemes, safe-area-aware viewport, mobile
+ * polish CSS (no tap highlight, no overscroll bounce, no iOS text inflation),
+ * and service-worker registration.
+ */
+export default function Root({ children }: PropsWithChildren) {
+ return (
+
+
+
+
+ CMUnify
+
+
+
+ {/* PWA */}
+
+
+
+
+
+ {/* iOS installed-app behavior */}
+
+
+
+
+
+
+
+
+ {/* Mobile polish: paint the root in the right scheme, kill tap
+ flashes and rubber-band overscroll, respect notches */}
+
+
+ {/* Service worker: offline shell + cached assets */}
+
+
+ {children}
+
+ );
+}
diff --git a/apps/client/app/_layout.tsx b/apps/client/app/_layout.tsx
index 7b8f38c..93ac099 100644
--- a/apps/client/app/_layout.tsx
+++ b/apps/client/app/_layout.tsx
@@ -1,6 +1,7 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
import 'react-native-reanimated';
import { AuthProvider } from '@/contexts/AuthContext';
@@ -37,20 +38,24 @@ function ThemedStack() {
export default function RootLayout() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ // SafeAreaProvider lets the tab bar and headers respect notches and the
+ // home indicator when the PWA is installed to a phone's home screen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/client/assets/images/favicon.png b/apps/client/assets/images/favicon.png
index 408bd74..4d4d886 100644
Binary files a/apps/client/assets/images/favicon.png and b/apps/client/assets/images/favicon.png differ
diff --git a/apps/client/public/apple-touch-icon.png b/apps/client/public/apple-touch-icon.png
new file mode 100644
index 0000000..7af4f60
Binary files /dev/null and b/apps/client/public/apple-touch-icon.png differ
diff --git a/apps/client/public/icons/icon-192.png b/apps/client/public/icons/icon-192.png
new file mode 100644
index 0000000..eeebe43
Binary files /dev/null and b/apps/client/public/icons/icon-192.png differ
diff --git a/apps/client/public/icons/icon-512.png b/apps/client/public/icons/icon-512.png
new file mode 100644
index 0000000..859202c
Binary files /dev/null and b/apps/client/public/icons/icon-512.png differ
diff --git a/apps/client/public/icons/icon-maskable-512.png b/apps/client/public/icons/icon-maskable-512.png
new file mode 100644
index 0000000..b787809
Binary files /dev/null and b/apps/client/public/icons/icon-maskable-512.png differ
diff --git a/apps/client/public/manifest.json b/apps/client/public/manifest.json
new file mode 100644
index 0000000..cfa8628
--- /dev/null
+++ b/apps/client/public/manifest.json
@@ -0,0 +1,50 @@
+{
+ "name": "CMUnify — Every campus event, one place",
+ "short_name": "CMUnify",
+ "description": "Every CMU event from Slack, Discord, and campus feeds in one calendar, with recommendations that learn what you actually go to.",
+ "id": "/",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#FAFAF9",
+ "theme_color": "#FAFAF9",
+ "categories": ["education", "social", "productivity"],
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "Calendar",
+ "url": "/calendar",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ },
+ {
+ "name": "Find events",
+ "url": "/find",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ },
+ {
+ "name": "Freshman Guide",
+ "url": "/resources",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ }
+ ]
+}
diff --git a/apps/client/public/sw.js b/apps/client/public/sw.js
new file mode 100644
index 0000000..c3b30b9
--- /dev/null
+++ b/apps/client/public/sw.js
@@ -0,0 +1,101 @@
+/*
+ * CMUnify service worker.
+ *
+ * Strategy:
+ * - Hashed build assets (/_expo/static/...) are immutable -> cache-first
+ * - Navigations -> network-first, falling back to the cached app shell so
+ * the app opens offline after the first visit
+ * - Other same-origin GETs (icons, manifest) -> stale-while-revalidate
+ *
+ * Bump CACHE_VERSION to invalidate old caches on deploy.
+ */
+const CACHE_VERSION = 'cmunify-v1';
+const SHELL_URLS = ['/', '/manifest.json', '/icons/icon-192.png', '/icons/icon-512.png'];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ (async () => {
+ const cache = await caches.open(CACHE_VERSION);
+ await cache.addAll(SHELL_URLS);
+ // The page's first-load requests happen before this worker controls
+ // it, so the hashed bundles never hit the fetch handler. Parse the
+ // shell HTML here and precache every /_expo asset it references so
+ // the app works offline after a single visit.
+ try {
+ const response = await fetch('/');
+ const html = await response.text();
+ const assets = [...html.matchAll(/(?:src|href)="(\/_expo\/[^"]+)"/g)].map((m) => m[1]);
+ if (assets.length) await cache.addAll([...new Set(assets)]);
+ } catch {
+ // Offline install — runtime caching will fill in later
+ }
+ await self.skipWaiting();
+ })()
+ );
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key)))
+ )
+ .then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+ if (request.method !== 'GET') return;
+
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin) return; // never touch cross-origin (Supabase, Google)
+
+ // Immutable hashed bundles: cache-first
+ if (url.pathname.startsWith('/_expo/static/')) {
+ event.respondWith(
+ caches.open(CACHE_VERSION).then(async (cache) => {
+ const cached = await cache.match(request);
+ if (cached) return cached;
+ const response = await fetch(request);
+ if (response.ok) cache.put(request, response.clone());
+ return response;
+ })
+ );
+ return;
+ }
+
+ // Navigations: network-first with shell fallback for offline
+ if (request.mode === 'navigate') {
+ event.respondWith(
+ fetch(request)
+ .then((response) => {
+ if (response.ok) {
+ const copy = response.clone();
+ caches.open(CACHE_VERSION).then((cache) => cache.put(request, copy));
+ }
+ return response;
+ })
+ .catch(async () => {
+ const cache = await caches.open(CACHE_VERSION);
+ return (await cache.match(request)) || (await cache.match('/'));
+ })
+ );
+ return;
+ }
+
+ // Everything else same-origin: stale-while-revalidate
+ event.respondWith(
+ caches.open(CACHE_VERSION).then(async (cache) => {
+ const cached = await cache.match(request);
+ const network = fetch(request)
+ .then((response) => {
+ if (response.ok) cache.put(request, response.clone());
+ return response;
+ })
+ .catch(() => cached);
+ return cached || network;
+ })
+ );
+});