From c475bb7ce1cbbc319406390a6a6e82a529063c25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 11:33:38 +0000 Subject: [PATCH 1/4] Wire up Firebase (project vintlyy) and switch chat media to Cloudinary --- src/lib/chat.ts | 15 +++++++-------- src/lib/cloudinary.ts | 37 +++++++++++++++++++++++++++++++++++++ src/lib/firebase.ts | 6 ++---- src/lib/firebaseConfig.ts | 39 +++++++++++++++++---------------------- 4 files changed, 63 insertions(+), 34 deletions(-) create mode 100644 src/lib/cloudinary.ts diff --git a/src/lib/chat.ts b/src/lib/chat.ts index 62710e9..72d1575 100644 --- a/src/lib/chat.ts +++ b/src/lib/chat.ts @@ -12,8 +12,8 @@ import { serverTimestamp, limit, } from 'firebase/firestore' -import { ref, uploadBytes, getDownloadURL } from 'firebase/storage' import { getFb } from './firebase' +import { uploadToCloudinary } from './cloudinary' export type MsgKind = 'text' | 'image' | 'gif' | 'voice' | 'file' @@ -121,11 +121,10 @@ export async function sendMessage( ) } -export async function uploadMedia(cid: string, file: Blob, ext: string): Promise { - const fb = getFb() - if (!fb) throw new Error('offline') - const path = `chat/${cid}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}` - const r = ref(fb.storage, path) - await uploadBytes(r, file) - return getDownloadURL(r) +export async function uploadMedia(_cid: string, file: Blob, ext: string): Promise { + // Media goes to Cloudinary (free); 'auto' lets it detect image/video/audio. + const isImg = /^(png|jpe?g|gif|webp|heic|bmp)$/i.test(ext) + const isAv = /^(mp4|mov|webm|m4a|mp3|ogg|wav|aac)$/i.test(ext) + const type = isImg ? 'image' : isAv ? 'video' : ('auto' as any) + return uploadToCloudinary(file, type) } diff --git a/src/lib/cloudinary.ts b/src/lib/cloudinary.ts new file mode 100644 index 0000000..b9fd7db --- /dev/null +++ b/src/lib/cloudinary.ts @@ -0,0 +1,37 @@ +// Cloudinary media uploads (free) — used for chat photos, files & voice notes. +// +// IMPORTANT: a phone app uploads with an *unsigned upload preset*, so we never +// embed the API secret. Two values are needed (both from your Cloudinary +// dashboard, doable on a phone): +// +// 1. CLOUD_NAME — shown at the top of your Cloudinary dashboard +// (Programmable Media → looks like "dxxxxxxx", NOT the API key/number). +// +// 2. UPLOAD_PRESET — create an unsigned preset: +// Settings (gear) → Upload → "Upload presets" → "Add upload preset" → +// set "Signing Mode" = Unsigned → Save. Name it exactly: vintly_unsigned +// +// After filling CLOUD_NAME below, chat media uploads work in the next build. + +const CLOUD_NAME = 'YOUR_CLOUD_NAME' +const UPLOAD_PRESET = 'vintly_unsigned' + +export const cloudinaryReady = !CLOUD_NAME.startsWith('YOUR_') + +// Uploads any blob/file and returns a hosted URL. +export async function uploadToCloudinary(file: Blob, resourceType: 'image' | 'video' | 'raw' = 'auto' as any): Promise { + if (!cloudinaryReady) throw new Error('Cloudinary not configured (set CLOUD_NAME in src/lib/cloudinary.ts).') + // "auto" lets Cloudinary detect images/video/audio; raw is used for misc files. + const type = resourceType || 'auto' + const url = `https://api.cloudinary.com/v1_1/${CLOUD_NAME}/${type}/upload` + const form = new FormData() + form.append('file', file) + form.append('upload_preset', UPLOAD_PRESET) + const res = await fetch(url, { method: 'POST', body: form }) + if (!res.ok) { + const detail = await res.text().catch(() => '') + throw new Error(`Cloudinary upload failed: ${res.status} ${detail}`) + } + const json = await res.json() + return json.secure_url as string +} diff --git a/src/lib/firebase.ts b/src/lib/firebase.ts index dcb7134..7cfec7e 100644 --- a/src/lib/firebase.ts +++ b/src/lib/firebase.ts @@ -4,13 +4,12 @@ import { initializeApp, type FirebaseApp } from 'firebase/app' import { getAuth, type Auth } from 'firebase/auth' import { getFirestore, type Firestore } from 'firebase/firestore' -import { getStorage, type FirebaseStorage } from 'firebase/storage' import { firebaseConfig, isFirebaseConfigured } from './firebaseConfig' +// Media is stored on Cloudinary (see cloudinary.ts), not Firebase Storage. let app: FirebaseApp | null = null let _auth: Auth | null = null let _db: Firestore | null = null -let _storage: FirebaseStorage | null = null export const firebaseReady = isFirebaseConfigured @@ -20,12 +19,11 @@ function ensure() { app = initializeApp(firebaseConfig) _auth = getAuth(app) _db = getFirestore(app) - _storage = getStorage(app) } return true } export function getFb() { if (!ensure()) return null - return { app: app!, auth: _auth!, db: _db!, storage: _storage! } + return { app: app!, auth: _auth!, db: _db! } } diff --git a/src/lib/firebaseConfig.ts b/src/lib/firebaseConfig.ts index 25031c6..614ea21 100644 --- a/src/lib/firebaseConfig.ts +++ b/src/lib/firebaseConfig.ts @@ -1,29 +1,24 @@ -// 👇 PASTE YOUR FIREBASE CONFIG HERE (Phase 2: accounts + chat + push) +// ✅ Firebase is configured for project "vintlyy". +// These web-config values are PUBLIC by design (Google intends them to ship in +// client apps); real security comes from Firestore rules, not from hiding these. // -// How to get it from your PHONE (no computer needed): -// 1. Go to https://console.firebase.google.com and sign in with Google. -// 2. Tap "Create a project" → name it "Vintly" → Continue (you can disable Analytics). -// 3. Inside the project, tap the "Web" icon to "Add a web app", nickname "Vintly". -// 4. Firebase shows a `firebaseConfig = { ... }` block. Copy those values below. -// 5. In the left menu: Build → Authentication → Get started → enable "Email/Password". -// 6. Build → Firestore Database → Create database → Start in *test mode* (for now). -// 7. Build → Storage → Get started (for sending media in chat). +// Media (photos, voice notes, files) is NOT stored in Firebase Storage — that +// now requires a paid plan. Vintly uses Cloudinary (free) instead. See +// src/lib/cloudinary.ts. // -// These keys are SAFE to commit — Firebase web config is public by design; -// security is enforced by Firestore/Storage rules, not by hiding these. -// -// Until you fill these in, Vintly runs fully in OFFLINE mode (tasks, notes, -// calendar, reminders, streaks all work locally on your device). +// Remaining one-time setup in the Firebase console (from your phone): +// • Build → Authentication → Get started → enable "Email/Password". +// • Build → Firestore Database → Create database → Start in *test mode*. export const firebaseConfig = { - apiKey: 'YOUR_API_KEY', - authDomain: 'YOUR_PROJECT.firebaseapp.com', - projectId: 'YOUR_PROJECT', - storageBucket: 'YOUR_PROJECT.appspot.com', - messagingSenderId: 'YOUR_SENDER_ID', - appId: 'YOUR_APP_ID', + apiKey: 'AIzaSyBvFF70OqRN-N9jhN8TLTCgx6j76yoxdNs', + authDomain: 'vintlyy.firebaseapp.com', + projectId: 'vintlyy', + storageBucket: 'vintlyy.firebasestorage.app', + messagingSenderId: '1085757864702', + appId: '1:1085757864702:web:e61c8ffcd818301a1f43af', + measurementId: 'G-NTL7PY9JO1', } export const isFirebaseConfigured = - !firebaseConfig.apiKey.startsWith('YOUR_') && - firebaseConfig.apiKey.length > 10 + !firebaseConfig.apiKey.startsWith('YOUR_') && firebaseConfig.apiKey.length > 10 From 57c665c2ee74728ff38080941802c1c48485b96e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 11:44:42 +0000 Subject: [PATCH 2/4] Set Cloudinary cloud name (dpxrtof5z) to enable chat media uploads --- src/lib/cloudinary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/cloudinary.ts b/src/lib/cloudinary.ts index b9fd7db..62347b2 100644 --- a/src/lib/cloudinary.ts +++ b/src/lib/cloudinary.ts @@ -13,7 +13,7 @@ // // After filling CLOUD_NAME below, chat media uploads work in the next build. -const CLOUD_NAME = 'YOUR_CLOUD_NAME' +const CLOUD_NAME = 'dpxrtof5z' const UPLOAD_PRESET = 'vintly_unsigned' export const cloudinaryReady = !CLOUD_NAME.startsWith('YOUR_') From f167ec191832c66ca554b8794154cd29889c6bce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 12:02:25 +0000 Subject: [PATCH 3/4] Upgrade notes (checklists/search/labels/archive), fix step tracking & reminders, add Reminders screen, roomier premium UI --- src/App.tsx | 2 + src/lib/notifications.ts | 43 ++++++- src/lib/store.ts | 23 +++- src/screens/Calendar.tsx | 6 +- src/screens/Fitness.tsx | 17 +-- src/screens/Home.tsx | 112 +++++++++--------- src/screens/Notes.tsx | 233 +++++++++++++++++++++++++++++--------- src/screens/Reminders.tsx | 107 +++++++++++++++++ src/screens/Tasks.tsx | 6 +- 9 files changed, 419 insertions(+), 130 deletions(-) create mode 100644 src/screens/Reminders.tsx diff --git a/src/App.tsx b/src/App.tsx index 5a02be5..41cda74 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ 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 Profile from './screens/Profile' import Settings from './screens/Settings' import Auth from './screens/Auth' @@ -37,6 +38,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 0b2c3e3..72864d2 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -3,12 +3,32 @@ import { LocalNotifications } from '@capacitor/local-notifications' import { Capacitor } from '@capacitor/core' const isNative = Capacitor.isNativePlatform() +let channelReady = false + +async function ensureChannel() { + if (!isNative || channelReady) return + try { + await LocalNotifications.createChannel({ + id: 'vintly_reminders', + name: 'Reminders & alarms', + description: 'Task, event and alarm reminders', + importance: 5, // HIGH — heads-up + sound + visibility: 1, + vibration: true, + }) + channelReady = true + } catch { + /* ignore */ + } +} export async function ensureNotificationPermission(): Promise { try { if (isNative) { - const p = await LocalNotifications.requestPermissions() - return p.display === 'granted' + let perm = await LocalNotifications.checkPermissions() + if (perm.display !== 'granted') perm = await LocalNotifications.requestPermissions() + await ensureChannel() + return perm.display === 'granted' } if ('Notification' in window) { const p = await Notification.requestPermission() @@ -22,23 +42,27 @@ export async function ensureNotificationPermission(): Promise { let webIdCounter = 1 -// Schedule a reminder/alarm at a specific time. +// Schedule a reminder/alarm at a specific time (optionally repeating). export async function scheduleReminder(opts: { id?: number title: string body: string at: Date + repeat?: 'none' | 'daily' | 'weekly' }): Promise { - const id = opts.id ?? Math.floor(Date.now() % 2_000_000_000) + const id = opts.id ?? Math.floor(Math.random() * 2_000_000_000) if (isNative) { + await ensureNotificationPermission() + const every = opts.repeat === 'daily' ? 'day' : opts.repeat === 'weekly' ? 'week' : undefined await LocalNotifications.schedule({ notifications: [ { id, title: opts.title, body: opts.body, - schedule: { at: opts.at, allowWhileIdle: true }, + channelId: 'vintly_reminders', smallIcon: 'ic_stat_icon', + schedule: { at: opts.at, allowWhileIdle: true, ...(every ? { every } : {}) }, }, ], }) @@ -74,9 +98,16 @@ export async function cancelReminder(id: number) { // Immediate motivational nudge (used by streak / engagement system). export async function notifyNow(title: string, body: string) { if (isNative) { + await ensureNotificationPermission() await LocalNotifications.schedule({ notifications: [ - { id: Math.floor(Math.random() * 1_000_000), title, body, schedule: { at: new Date(Date.now() + 500) } }, + { + id: Math.floor(Math.random() * 1_000_000), + title, + body, + channelId: 'vintly_reminders', + schedule: { at: new Date(Date.now() + 400) }, + }, ], }) } else if ('Notification' in window && Notification.permission === 'granted') { diff --git a/src/lib/store.ts b/src/lib/store.ts index 061eed7..9c2aac9 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -15,13 +15,18 @@ export interface Task { points: number } +export interface ChecklistItem { id: string; text: string; done: boolean } + export interface Note { id: string title: string body: string color: string // rgb triple or '' for default pinned: boolean - checklist?: { id: string; text: string; done: boolean }[] + archived: boolean + isChecklist: boolean + checklist: ChecklistItem[] + labels: string[] updatedAt: number } @@ -190,7 +195,10 @@ export const useStore = create()( body: n.body || '', color: n.color || '', pinned: false, - checklist: n.checklist, + archived: false, + isChecklist: n.isChecklist ?? false, + checklist: n.checklist ?? [], + labels: n.labels ?? [], updatedAt: Date.now(), }, ...s.notes, @@ -261,7 +269,16 @@ export const useStore = create()( { name: 'vintly-store-v1', onRehydrateStorage: () => (state) => { - if (state) applyTheme(state.settings.theme, state.settings.accent) + if (!state) return + // Backfill fields added in later versions so old notes don't crash. + state.notes = (state.notes || []).map((n: any) => ({ + archived: false, + isChecklist: false, + checklist: [], + labels: [], + ...n, + })) + applyTheme(state.settings.theme, state.settings.accent) }, }, ), diff --git a/src/screens/Calendar.tsx b/src/screens/Calendar.tsx index 2c693d7..92ed864 100644 --- a/src/screens/Calendar.tsx +++ b/src/screens/Calendar.tsx @@ -48,9 +48,9 @@ export default function CalendarScreen() { } return ( -
-
-

{format(cursor, 'MMMM yyyy')}

+
+
+

{format(cursor, 'MMMM yyyy')}

diff --git a/src/screens/Fitness.tsx b/src/screens/Fitness.tsx index 4a14931..51331a9 100644 --- a/src/screens/Fitness.tsx +++ b/src/screens/Fitness.tsx @@ -11,8 +11,8 @@ export default function Fitness() { const [tracking, setTracking] = useState(false) const [supported, setSupported] = useState(true) const lastPeak = useRef(0) - const lastMag = useRef(0) - const goingUp = useRef(false) + const gravity = useRef(9.8) // running baseline; works whether or not gravity is included + const armed = useRef(true) const removeRef = useRef void)>(null) const count = steps.count @@ -23,16 +23,17 @@ export default function Fitness() { function onAccel(x: number, y: number, z: number) { const mag = Math.sqrt(x * x + y * y + z * z) + // Low-pass baseline tracks gravity (≈9.8 if included, ≈0 if not). + gravity.current = gravity.current * 0.9 + mag * 0.1 + const linear = mag - gravity.current // oscillates around 0 while walking const now = Date.now() - // smooth + detect a peak crossing ~ walking impact threshold - const delta = mag - lastMag.current - lastMag.current = mag - if (delta > 0.6) goingUp.current = true - if (goingUp.current && delta < -0.6 && mag > 10.5 && now - lastPeak.current > 300) { - goingUp.current = false + // Peak detection with a refractory period to avoid double-counting. + if (armed.current && linear > 1.15 && now - lastPeak.current > 280) { + armed.current = false lastPeak.current = now setSteps(useStore.getState().steps.count + 1) } + if (linear < 0.35) armed.current = true } async function start() { diff --git a/src/screens/Home.tsx b/src/screens/Home.tsx index 8cca8dc..e8e3c8e 100644 --- a/src/screens/Home.tsx +++ b/src/screens/Home.tsx @@ -1,5 +1,8 @@ import { Link } from 'react-router-dom' -import { Flame, Trophy, Footprints, Bell, Settings as Cog, CheckCircle2, Plus, Sparkles } from 'lucide-react' +import { + Flame, Trophy, Footprints, Bell, Settings as Cog, CheckCircle2, Plus, + AlarmClock, StickyNote, Dumbbell, User, +} from 'lucide-react' import { useStore } from '../lib/store' import { Card } from '../components/ui' @@ -19,86 +22,93 @@ export default function Home() { .sort((a, b) => a.date - b.date) .slice(0, 2) + const quick = [ + { to: '/reminders', icon: AlarmClock, label: 'Reminders', tint: '245 158 11' }, + { to: '/notes', icon: StickyNote, label: 'Notes', tint: '16 185 129' }, + { to: '/fitness', icon: Dumbbell, label: 'Fitness', tint: '244 63 94' }, + { to: '/profile', icon: User, label: 'Profile', tint: '14 165 233' }, + ] + return ( -
+
{/* Header */} -
+
-

{greet},

-

{profile.displayName} {profile.avatar}

-
-
- - - - - - +

{greet},

+

{profile.displayName} {profile.avatar}

+ + +
{/* Streak + Points hero */} -
- - -

{engagement.streak}

-

day streak 🔥

+
+ + +

{engagement.streak}

+

day streak 🔥

- - -

{engagement.points}

-

points earned

+ + +

{engagement.points}

+

points earned

{/* Steps ring */} - -
- - - + +
+ + + - +
-

{steps.count.toLocaleString()} steps

+

{steps.count.toLocaleString()} steps

{stepPct}% of {settings.stepGoal.toLocaleString()} goal

- + {/* Quick actions */} +
+ {quick.map(({ to, icon: Icon, label, tint }) => ( + + + + + {label} + + ))} +
+ {/* Today */} -
-

Today

+
+

Today

View all
- +
{doneToday} done · {todayTasks.length} to go
-
- {todayTasks.slice(0, 3).map((t) => ( -
+
+ {todayTasks.slice(0, 4).map((t) => ( +
{t.title} - +{t.points} + +{t.points}
))} {todayTasks.length === 0 && ( -

All clear. Add something to crush today ✨

+

All clear. Add something to crush today ✨

)}
- + Add task @@ -106,12 +116,12 @@ export default function Home() { {/* Upcoming */} {upcoming.length > 0 && ( <> -

Upcoming

-
+

Upcoming

+
{upcoming.map((e) => ( - - - {e.title} + + + {e.title} {new Date(e.date).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} diff --git a/src/screens/Notes.tsx b/src/screens/Notes.tsx index acc24b0..18d22a1 100644 --- a/src/screens/Notes.tsx +++ b/src/screens/Notes.tsx @@ -1,31 +1,39 @@ -import { useState } from 'react' -import { Plus, Pin, Trash2, X } from 'lucide-react' -import { useStore, type Note } from '../lib/store' -import { Sheet, Input, Textarea, EmptyState } from '../components/ui' -import { StickyNote } from 'lucide-react' +import { useMemo, useState } from 'react' +import { + Plus, Pin, Trash2, X, Search, CheckSquare, Square, Archive, ArchiveRestore, + ListChecks, Tag, StickyNote, Check, +} from 'lucide-react' +import { useStore, type Note, type ChecklistItem } from '../lib/store' +import { Sheet, Input, EmptyState } from '../components/ui' -const COLORS = ['', '124 92 255', '16 185 129', '244 63 94', '245 158 11', '14 165 233'] +const COLORS = ['', '124 92 255', '16 185 129', '244 63 94', '245 158 11', '14 165 233', '236 72 153'] +const uid = () => Math.random().toString(36).slice(2) export default function Notes() { const { notes, addNote, updateNote, deleteNote } = useStore() const [editing, setEditing] = useState(null) const [open, setOpen] = useState(false) + const [q, setQ] = useState('') + const [showArchived, setShowArchived] = useState(false) + const [labelInput, setLabelInput] = useState('') - function openNew() { - const id = addNote({}) - const n = useStore.getState().notes.find((x) => x.id === id)! - setEditing(n) + const allLabels = useMemo( + () => Array.from(new Set(notes.flatMap((n) => n.labels || []))), + [notes], + ) + + function openNew(asChecklist = false) { + const id = addNote({ isChecklist: asChecklist, checklist: asChecklist ? [{ id: uid(), text: '', done: false }] : [] }) + setEditing(useStore.getState().notes.find((x) => x.id === id)!) setOpen(true) } - function openExisting(n: Note) { - setEditing(n) - setOpen(true) + function openExisting(n: Note) { setEditing(n); setOpen(true) } + function isEmpty(n: Note) { + return !n.title.trim() && !n.body.trim() && !(n.checklist || []).some((c) => c.text.trim()) } function close() { - // discard empty notes - if (editing && !editing.title.trim() && !editing.body.trim()) deleteNote(editing.id) - setOpen(false) - setEditing(null) + if (editing && isEmpty(editing)) deleteNote(editing.id) + setOpen(false); setEditing(null); setLabelInput('') } function patch(p: Partial) { if (!editing) return @@ -33,71 +41,184 @@ export default function Notes() { setEditing(next) updateNote(editing.id, p) } + // checklist helpers + function setItem(id: string, p: Partial) { + if (!editing) return + patch({ checklist: editing.checklist.map((c) => (c.id === id ? { ...c, ...p } : c)) }) + } + function addItem() { + if (!editing) return + patch({ checklist: [...editing.checklist, { id: uid(), text: '', done: false }] }) + } + function removeItem(id: string) { + if (!editing) return + patch({ checklist: editing.checklist.filter((c) => c.id !== id) }) + } + function addLabel() { + if (!editing || !labelInput.trim()) return + if (!editing.labels.includes(labelInput.trim())) patch({ labels: [...editing.labels, labelInput.trim()] }) + setLabelInput('') + } - const pinned = notes.filter((n) => n.pinned) - const others = notes.filter((n) => !n.pinned) + const term = q.toLowerCase().trim() + const visible = notes + .filter((n) => n.archived === showArchived) + .filter((n) => + !term || + n.title.toLowerCase().includes(term) || + n.body.toLowerCase().includes(term) || + n.labels.some((l) => l.toLowerCase().includes(term)) || + n.checklist.some((c) => c.text.toLowerCase().includes(term)), + ) + const pinned = visible.filter((n) => n.pinned) + const others = visible.filter((n) => !n.pinned) const grid = (list: Note[]) => (
- {list.map((n) => ( - - ))} + {list.map((n) => { + const doneCount = n.checklist.filter((c) => c.done).length + return ( + + ) + })}
) return ( -
-
-

Notes

-
- {notes.length === 0 ? ( - } title="Your notes live here" hint="Capture ideas, lists and reminders. Tap + to start." /> + {/* Search */} +
+ + setQ(e.target.value)} placeholder="Search notes, lists, labels…" className="flex-1 bg-transparent py-3 outline-none placeholder:text-muted" /> + {q && } +
+ + {visible.length === 0 ? ( + } title={showArchived ? 'No archived notes' : 'Your notes live here'} hint={showArchived ? undefined : 'Capture ideas, checklists and reminders. Tap + to start.'} /> ) : ( <> - {pinned.length > 0 &&

Pinned

} + {pinned.length > 0 &&

Pinned

} {pinned.length > 0 && grid(pinned)} - {others.length > 0 && pinned.length > 0 &&

Others

} + {others.length > 0 && pinned.length > 0 &&

Others

} {grid(others)} )} + {/* FAB cluster */} + {!showArchived && ( +
+ + +
+ )} + {editing && ( -
+
-
- + + - +
+
+ +
- patch({ title: e.target.value })} className="!border-0 !bg-transparent !px-0 text-lg font-bold" /> -