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
3 changes: 3 additions & 0 deletions .github/workflows/build-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
npx cap add android
fi

- name: Patch Android for Health Connect
run: node scripts/patch-android.mjs

- name: Generate app icon & splash
run: npx capacitor-assets generate --android || echo "asset generation skipped"

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ dist
android
.gradle
local.properties
# Stable signing key is intentionally committed so APK updates install in place.
*.keystore
!vintly-debug.keystore

# Env
.env
Expand Down
34 changes: 34 additions & 0 deletions firestore.rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
rules_version = '2';
// Vintly Firestore security rules.
// Paste these into: Firebase Console → Firestore Database → Rules → Publish.
// They replace insecure "test mode" (which expires) with proper protection:
// only signed-in users can read/write, and chats are private to their members.

service cloud.firestore {
match /databases/{database}/documents {

// Public user profiles — anyone signed in can look up a username to start a chat,
// but you can only create/edit your own profile document.
match /users/{uid} {
allow read: if request.auth != null;
allow create, update: if request.auth != null && request.auth.uid == uid;
}

// Conversations — only the members listed on the conversation can access it.
match /conversations/{cid} {
allow read, write: if request.auth != null
&& request.auth.uid in resource.data.members;
allow create: if request.auth != null
&& request.auth.uid in request.resource.data.members;

// Messages inside a conversation — restricted to that conversation's members.
// read/create/update (reactions) for any member; delete only by the sender.
match /messages/{mid} {
allow read, create, update: if request.auth != null
&& request.auth.uid in get(/databases/$(database)/documents/conversations/$(cid)).data.members;
allow delete: if request.auth != null
&& resource.data.from == request.auth.uid;
}
}
}
}
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@capacitor/motion": "^6.0.0",
"@capacitor/preferences": "^6.0.2",
"@capacitor/status-bar": "^6.0.1",
"capacitor-health": "0.0.14",
"date-fns": "^3.6.0",
"firebase": "^10.13.2",
"lucide-react": "^0.439.0",
Expand Down
Binary file added public/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/icon-background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/icon-foreground.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified resources/splash.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
96 changes: 96 additions & 0 deletions scripts/patch-android.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Patches the CI-generated Android project for Health Connect support:
// • adds Health Connect <queries>, health read permissions, and the
// permissions-rationale activity to AndroidManifest.xml
// • bumps minSdkVersion to 26 (required by androidx.health.connect)
// Safe to run repeatedly (idempotent).
import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs'

const manifestPath = 'android/app/src/main/AndroidManifest.xml'
const variablesPath = 'android/variables.gradle'
const appGradlePath = 'android/app/build.gradle'
const keystoreSrc = 'vintly-debug.keystore'
const keystoreDst = 'android/app/vintly-debug.keystore'

const PERMS_AND_QUERIES = `
<!-- Vintly: Health Connect -->
<uses-permission android:name="android.permission.health.READ_STEPS" />
<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>
`

const RATIONALE_ACTIVITY = `
<!-- Vintly: Health Connect permissions rationale -->
<activity android:name="com.fit_up.health.capacitor.PermissionsRationaleActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<activity-alias android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity="com.fit_up.health.capacitor.PermissionsRationaleActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
`

function patchManifest() {
if (!existsSync(manifestPath)) {
console.log('AndroidManifest.xml not found, skipping')
return
}
let m = readFileSync(manifestPath, 'utf8')
if (m.includes('android.permission.health.READ_STEPS')) {
console.log('Manifest already patched')
return
}
// Insert rationale activities before </application>
m = m.replace('</application>', `${RATIONALE_ACTIVITY} </application>`)
// Insert permissions + queries before </manifest>
m = m.replace('</manifest>', `${PERMS_AND_QUERIES}</manifest>`)
writeFileSync(manifestPath, m)
console.log('Patched AndroidManifest.xml for Health Connect')
}

function patchMinSdk() {
if (!existsSync(variablesPath)) {
console.log('variables.gradle not found, skipping minSdk bump')
return
}
let v = readFileSync(variablesPath, 'utf8')
v = v.replace(/minSdkVersion\s*=\s*\d+/, 'minSdkVersion = 26')
writeFileSync(variablesPath, v)
console.log('Set minSdkVersion = 26')
}

// Use a committed, stable signing key so APK updates install over the old app
// (no uninstall needed). Applies to the debug build type automatically.
function patchSigning() {
if (!existsSync(appGradlePath) || !existsSync(keystoreSrc)) {
console.log('app build.gradle or keystore missing, skipping signing patch')
return
}
copyFileSync(keystoreSrc, keystoreDst)
let g = readFileSync(appGradlePath, 'utf8')
if (g.includes('vintly-debug.keystore')) { console.log('signing already patched'); return }
const block = ` signingConfigs {
debug {
storeFile file('vintly-debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {`
g = g.replace(' buildTypes {', block)
writeFileSync(appGradlePath, g)
console.log('Patched app/build.gradle with stable debug signing')
}

patchManifest()
patchMinSdk()
patchSigning()
29 changes: 27 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,38 @@ import BottomNav from './components/BottomNav'
import Home from './screens/Home'
import Tasks from './screens/Tasks'
import Notes from './screens/Notes'
import NoteEditor from './screens/NoteEditor'
import Bin from './screens/Bin'
import CalendarScreen from './screens/Calendar'
import ChatList from './screens/ChatList'
import ChatRoom from './screens/ChatRoom'
import Fitness from './screens/Fitness'
import Reminders from './screens/Reminders'
import Games from './screens/Games'
import Weather from './screens/Weather'
import QuickMath from './games/QuickMath'
import MemoryMatch from './games/MemoryMatch'
import Snake from './games/Snake'
import CarRace from './games/CarRace'
import Profile from './screens/Profile'
import Settings from './screens/Settings'
import Auth from './screens/Auth'
import { ensureNotificationPermission } from './lib/notifications'
import { syncEngagementNudges } from './lib/engagement'
import { startAlarmWatcher } from './lib/alarm'
import AlarmRing from './components/AlarmRing'
import { useStore } from './lib/store'

export default function App() {
const loc = useLocation()
const hideNav = loc.pathname.startsWith('/chat/') || loc.pathname === '/auth'
const hideNav = loc.pathname.startsWith('/chat/') || loc.pathname.startsWith('/note/') || loc.pathname.startsWith('/games/') || loc.pathname === '/auth'

useEffect(() => {
ensureNotificationPermission()
ensureNotificationPermission().then(() => {
// (Re)schedule daily motivational nudges + streak reminders.
syncEngagementNudges(useStore.getState().settings)
})
startAlarmWatcher()
if (Capacitor.isNativePlatform()) {
import('@capacitor/status-bar')
.then(({ StatusBar, Style }) => StatusBar.setStyle({ style: Style.Dark }))
Expand All @@ -34,17 +50,26 @@ export default function App() {
<Route path="/" element={<Home />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/notes" element={<Notes />} />
<Route path="/note/:id" element={<NoteEditor />} />
<Route path="/bin" element={<Bin />} />
<Route path="/calendar" element={<CalendarScreen />} />
<Route path="/chat" element={<ChatList />} />
<Route path="/chat/:cid" element={<ChatRoom />} />
<Route path="/fitness" element={<Fitness />} />
<Route path="/reminders" element={<Reminders />} />
<Route path="/games" element={<Games />} />
<Route path="/weather" element={<Weather />} />
<Route path="/games/math" element={<QuickMath />} />
<Route path="/games/memory" element={<MemoryMatch />} />
<Route path="/games/snake" element={<Snake />} />
<Route path="/games/car" element={<CarRace />} />
<Route path="/profile" element={<Profile />} />
<Route path="/settings" element={<Settings />} />
<Route path="/auth" element={<Auth />} />
</Routes>
{!hideNav && <div className="h-24" />}
{!hideNav && <BottomNav />}
<AlarmRing />
</div>
)
}
48 changes: 48 additions & 0 deletions src/components/AlarmRing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { AlarmClock, BellOff, Clock } from 'lucide-react'
import { onAlarmRing, stopAlarm, ringingTitle } from '../lib/alarm'
import { useStore } from '../lib/store'
import { scheduleReminder } from '../lib/notifications'

// Full-screen "ringing" alarm shown when an alarm reminder is due (app open).
export default function AlarmRing() {
const [id, setId] = useState<string | null>(null)
const { reminders, addReminder } = useStore()

useEffect(() => onAlarmRing(setId), [])
if (!id) return null
const title = ringingTitle()

function dismiss() {
useStore.setState((s) => ({ reminders: s.reminders.map((r) => (r.id === id ? { ...r, done: true } : r)) }))
stopAlarm()
}
async function snooze() {
const at = Date.now() + 5 * 60000
const r = reminders.find((x) => x.id === id)
const newId = Math.random().toString(36).slice(2)
const notifId = await scheduleReminder({ title: '⏰ ' + (r?.title || 'Alarm'), body: 'Snoozed reminder', at: new Date(at) })
addReminder({ id: newId, title: r?.title || 'Alarm', at, notifId, repeat: 'none', done: false, alarm: true })
stopAlarm()
}

return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-gradient-to-b from-brand/40 to-surface px-8 text-center">
<div className="grid h-28 w-28 animate-pulse place-items-center rounded-full bg-brand/30 text-brand">
<AlarmClock size={56} />
</div>
<p className="mt-6 text-sm uppercase tracking-widest text-muted">Alarm</p>
<h1 className="mt-1 text-3xl font-extrabold">{title}</h1>
<p className="mt-2 text-muted">{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</p>

<div className="mt-12 flex w-full max-w-xs flex-col gap-3">
<button onClick={dismiss} className="flex items-center justify-center gap-2 rounded-2xl bg-brand py-4 font-bold text-white shadow-glow">
<BellOff size={20} /> Dismiss
</button>
<button onClick={snooze} className="flex items-center justify-center gap-2 rounded-2xl bg-card border border-line py-4 font-semibold">
<Clock size={20} /> Snooze 5 min
</button>
</div>
</div>
)
}
13 changes: 13 additions & 0 deletions src/components/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement
)
}

// Renders an avatar that is either an uploaded image URL or an emoji.
export function Avatar({ value, size = 48, className = '' }: { value: string; size?: number; className?: string }) {
const isImg = /^https?:\/\//.test(value)
return (
<span
className={`grid shrink-0 place-items-center overflow-hidden rounded-3xl bg-brand/20 ${className}`}
style={{ width: size, height: size, fontSize: size * 0.5 }}
>
{isImg ? <img src={value} alt="" className="h-full w-full object-cover" /> : value}
</span>
)
}

export function EmptyState({ icon, title, hint }: { icon: React.ReactNode; title: string; hint?: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
Expand Down
Loading
Loading